From 7b70d70252a484c002a35da5177c594d1344bd0f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:36:25 +0900 Subject: [PATCH] feat(tasks-2): implement subagent orchestration container model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Container Model for subagent↔conductor communication: - ContainerMessage + channel-based escalation (ask_conductor, submit_plan) - Deliberate vs Exploratory preset workflows with SubagentPlanState - AnswerSubagentTool + ReviewSubagentPlanTool for conductor side - SessionRecorder extensions (RecordQuestion, RecordPlanSubmit) - Environment context injection (MEMORY.md extraction, plan context) - Orchestration guidance with escalation tools documentation Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 16 +- pkg/agent/context.go | 508 ++++- pkg/agent/loop.go | 2650 +++++++++++++++++++++++--- pkg/agent/session_recorder.go | 71 +- pkg/agent/session_recorder_test.go | 141 +- pkg/session/types.go | 9 + pkg/tools/answer_subagent.go | 138 ++ pkg/tools/ask_conductor.go | 110 ++ pkg/tools/ask_conductor_test.go | 77 + pkg/tools/session_recorder.go | 6 + pkg/tools/spawn.go | 53 +- pkg/tools/subagent.go | 1007 ++++++++-- pkg/tools/subagent_container_test.go | 125 ++ pkg/tools/subagent_env_test.go | 192 ++ pkg/tools/subagent_plan_test.go | 98 + pkg/tools/submit_plan.go | 186 ++ pkg/tools/submit_plan_test.go | 229 +++ todo/TASKS-2.md | 14 +- 18 files changed, 5116 insertions(+), 514 deletions(-) create mode 100644 pkg/tools/answer_subagent.go create mode 100644 pkg/tools/ask_conductor.go create mode 100644 pkg/tools/ask_conductor_test.go create mode 100644 pkg/tools/subagent_container_test.go create mode 100644 pkg/tools/subagent_env_test.go create mode 100644 pkg/tools/subagent_plan_test.go create mode 100644 pkg/tools/submit_plan.go create mode 100644 pkg/tools/submit_plan_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 64134ffeb..3f0652f1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,12 +19,24 @@ Lint: `golangci-lint run` - **Interview tool filtering**: `interviewAllowedTools` in `pkg/agent/loop.go` is the single source of truth for tools available during interview/review phases. Both `filterInterviewTools` (strips definitions before LLM call) and `isToolAllowedDuringInterview` (argument-level gating) reference this map. - **History clear**: `/plan start clear` wipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear. -## Subagent Orchestration (実装済み部分) +## Subagent Orchestration (実装済み) - **Startup flag**: `--orchestration` で on/off。`SubagentsConfig.Enabled` で gate。 - **Conductor identity**: orchestration 有効時に conductor identity + spawn/subagent guidance を system prompt へ注入。 - **Sandbox/Spawn**: `pkg/tools/sandbox.go`, `pkg/tools/spawn.go` 実装済み。 - **AgentReporter**: `orch.AgentReporter` / `orch.Noop` / `orch.Broadcaster` で統一。main/heartbeat/subagent 全セッションが同一 Broadcaster に発火。Mini App は `agentLoop.GetOrchBroadcaster()` → `handler.SetOrchBroadcaster()` で受信。 +- **Container Model (Q&A escalation)**: + - `ContainerMessage` + `inCh`/`outCh` channels on `SubagentTask` — deliberate preset (coder/worker/coordinator) のみ + - `ask_conductor` tool — subagent → conductor question (blocking) + - `answer_subagent` tool — conductor → subagent answer + - `submit_plan` tool — subagent → conductor plan review (blocking) + - `review_subagent_plan` tool — conductor → subagent approve/reject + - `PendingQuestions()` で conductor LLM loop に question/plan_review を注入 +- **Deliberate Plan Mode**: `SubagentPlanState` (Clarifying → Review → Executing → Completed) + - `runDeliberateTask()`: clarifying phase (ask_conductor + submit_plan のみ) → executing phase (全ツール) + - `runExploratoryTask()`: exploratory preset の single-phase loop +- **Environment injection**: `extractPlanContext()` で MEMORY.md から Context/Commands/Orchestration セクションを抽出 → subagent system prompt に注入 +- **SessionRecorder 拡張**: `RecordQuestion()` / `RecordPlanSubmit()` + `TurnQuestion` / `TurnPlanSubmit` TurnKind ## Session DAG (Phase 0–3 実装済み) @@ -61,7 +73,7 @@ Lint: `golangci-lint run` | ファイル | 概要 | |---|---| | [`todo/TASKS-1.md`](todo/TASKS-1.md) | ~~**Memory & Performance Optimization**~~ ✅ 実装済み(MemoryStore キャッシュ+パース済み state、FunctionCall.Arguments map統一、ToolDefinition.Parameters RawMessage化、検索結果フォーマット共通化、stats 定期フラッシュ) | -| [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode(TASKS-1 の型変更前提メモ追記済み) | +| [`todo/TASKS-2.md`](todo/TASKS-2.md) | ~~**Subagent Orchestration (Container Model)**~~ ✅ 実装済み(Container Q&A escalation、Deliberate Plan Mode、Environment injection、SessionRecorder 拡張) | | [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済み(Phase 0–3: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI) | | [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 | | [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees` の `list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI) | diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 469a0bfdc..ce571ec7b 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -19,71 +19,155 @@ import ( const orchestrationGuidance = `## Orchestration + + You are the conductor, not the performer. **Your primary job is to delegate, not to implement.** + + ### spawn (non-blocking) — DEFAULT choice + Returns immediately. Use for any task that can run independently. + Call the spawn tool with JSON arguments like this: + + Tool: spawn + Arguments: {"task": "Examine pkg/auth/ and report middleware pattern", "preset": "scout", "label": "auth-scout"} + + Tool: spawn + Arguments: {"task": "Implement rate limiter in pkg/ratelimit/ with tests", "preset": "coder", "label": "rate-limiter"} + + ### subagent (blocking) — only when you need the answer NOW + Blocks until the subagent finishes. Use only when you cannot proceed without the result. + Does not take a preset — it runs with default tools. + + Tool: subagent + Arguments: {"task": "Read pkg/config/config.go and list all SubagentsConfig fields", "label": "config-check"} + + ### When to use which + - spawn: parallel tasks, independent work, implementation, long analysis, >2 tool calls + - subagent: you need the result before your next decision + - inline: single quick tool call where delegation overhead is wasteful + + ### Presets (for spawn only) + | preset | role | can write | can exec | + |--------|------|-----------|----------| + | scout | explore, investigate | no | no | + | analyst | analyze, run tests | no | go test/vet, git | + | coder | implement + verify | yes (sandbox) | test/lint/fmt | + | worker | build + install | yes (sandbox) | build/package mgr | + | coordinator | orchestrate others | yes (sandbox) | general + spawn | + + ### Parallel spawning + Spawn multiple independent tasks at once — do NOT wait between them: + + Tool: spawn + Arguments: {"task": "Analyze error handling patterns in pkg/providers/", "preset": "analyst", "label": "error-patterns"} + + Tool: spawn + Arguments: {"task": "List all HTTP endpoints in pkg/miniapp/", "preset": "scout", "label": "endpoints"} + + After spawning, record the assignment in ## Orchestration > Delegated in MEMORY.md. -When results come back, synthesize findings and decide the next fork.` + +When results come back, synthesize findings and decide the next fork. + + + +### Subagent escalation + +Deliberate subagents (coder/worker/coordinator) may ask you questions or submit plans for review. + +When a subagent question appears, respond with the appropriate tool: + +- answer_subagent: Answer a subagent's clarifying question + +- review_subagent_plan: Approve or reject a subagent's execution plan (decision: "approved" or rejection feedback) + + + +### Orchestration Memory + +Maintain these sections in MEMORY.md under ## Orchestration: + +- **Delegated**: Active subagent assignments (task ID, preset, description) + +- **Findings**: Synthesized results from completed subagents + +- **Decisions**: Key architectural/implementation decisions made during orchestration` type ContextBuilder struct { - workspace string - workDir string // session-specific working directory (worktree or project subdir) - skillsLoader *skills.SkillsLoader - memory *MemoryStore - tools *tools.ToolRegistry // Direct reference to tool registry - peerNote string // set per-call from loop.go for peer session awareness - orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used + workspace string + + workDir string // session-specific working directory (worktree or project subdir) + + skillsLoader *skills.SkillsLoader + + memory *MemoryStore + + tools *tools.ToolRegistry // Direct reference to tool registry + + peerNote string // set per-call from loop.go for peer session awareness + + orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used // Cache for system prompt to avoid rebuilding on every call. + // This fixes issue #607: repeated reprocessing of the entire context. + // The cache auto-invalidates when workspace source files change (mtime check). - systemPromptMutex sync.RWMutex + + systemPromptMutex sync.RWMutex + cachedSystemPrompt string - cachedAt time.Time // max observed mtime across tracked paths at cache build time + + cachedAt time.Time // max observed mtime across tracked paths at cache build time // existedAtCache tracks which source file paths existed the last time the + // cache was built. This lets sourceFilesChanged detect files that are newly + // created (didn't exist at cache time, now exist) or deleted (existed at + // cache time, now gone) — both of which should trigger a cache rebuild. + existedAtCache map[string]bool } @@ -92,40 +176,52 @@ func getGlobalConfigDir() string { if err != nil { return "" } + return filepath.Join(home, ".picoclaw") } func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project + // Use the skills/ directory under the current working directory + wd, _ := os.Getwd() + builtinSkillsDir := filepath.Join(wd, "skills") + globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ - workspace: workspace, + workspace: workspace, + skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - memory: NewMemoryStore(workspace), + + memory: NewMemoryStore(workspace), } } // SetToolsRegistry sets the tools registry for dynamic tool summary generation. + func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) { cb.tools = registry } // SetWorkDir sets the session-specific working directory (e.g., worktree path + // or project subdirectory). Bootstrap files found here take priority over workspace. + func (cb *ContextBuilder) SetWorkDir(dir string) { cb.workDir = dir } // SetPeerNote sets the peer session awareness note for the current call. + func (cb *ContextBuilder) SetPeerNote(note string) { cb.peerNote = note } // SetOrchestrationEnabled sets whether orchestration is enabled. + func (cb *ContextBuilder) SetOrchestrationEnabled(enabled bool) { cb.orchestrationEnabled = enabled } @@ -134,83 +230,153 @@ func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) // Build tools section dynamically + toolsSection := cb.buildToolsSection() // Build prompt with optional orchestration banner + var prompt string + if cb.orchestrationEnabled { prompt = ` /_/_/_/_/_/_/_/_/_/_/_/_/_/_/ + O R C H E S T R A M O D E + /_/_/_/_/_/_/_/_/_/_/_/_/_/_/ + + ` } + // Conditional identity and plan executing rule for orchestration mode + identity := "a helpful AI assistant" + executingRule := `Work through the current Phase's steps. + Mark each "- [x]" via edit_file. The system will auto-advance phases.` + if cb.orchestrationEnabled { identity = "a conductor AI agent that orchestrates subagents" + executingRule = `Delegate the current Phase's steps to subagents using spawn. + For each step: spawn a subagent with the appropriate preset (scout for investigation, + coder for implementation, analyst for review). Spawn multiple independent steps in parallel. + When a subagent completes, mark "- [x]" via edit_file and record findings in + ## Orchestration > Findings in MEMORY.md. + Only do a step inline if it's a single quick tool call (e.g., reading one file).` } return fmt.Sprintf(prompt+`# picoclaw 🦞 + + You are picoclaw, %s. + + ## Workspace + Your workspace is at: %s + - Memory: %s/memory/MEMORY.md + - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md + - Skills: %s/skills/{skill-name}/SKILL.md + + %s + + ## Important Rules + + 1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. + + 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. + + 3. **Memory & Plans** + - Use memory/MEMORY.md for structured plans. + - NEVER remove or overwrite the header block (# Active Plan, > Task:, > Status:, > Phase:). The system parses these lines to track plan state. + - If Status is "interviewing": Ask clarifying questions. + After each answer, use edit_file to save findings to ## Context in memory/MEMORY.md. + When you have enough information, add ## Phase sections with "- [ ]" checkbox steps, and ## Commands section below the header. Then change > Status: to "review". + - If Status is "review": The plan is awaiting user approval. Do NOT change Status yourself. + - If Status is "executing": %s + - Plan format (header is written by the system — do NOT delete it): + # Active Plan + > Task: + > Status: interviewing | review | executing + > Phase: + ## Phase 1: + - [ ] Step 1 + - [ ] Step 2 + ## Phase 2: <title> + - [ ] Step 1 + ## Commands + build: <build command> + test: <test command> + lint: <lint command> + ## Context + <requirements, decisions, environment> + - Keep each phase to 3-5 steps. Do NOT create plans without /plan. + - Always ask about build/test/lint commands during interview. + + 4. **Response Formatting** + - NEVER use ASCII box-drawing characters (┌─┐│└─┘╔═╗║╚═╝ etc.) or ASCII art diagrams. + - Use markdown headings, bold, lists, and indentation for structure. + - Keep lines short — most users read on mobile. + - For architecture/flow, use arrow text: CLI → Pipeline → Adapters + + 5. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, + identity, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, executingRule) } @@ -220,18 +386,24 @@ func (cb *ContextBuilder) buildToolsSection() string { } summaries := cb.tools.GetSummaries() + if len(summaries) == 0 { return "" } var sb strings.Builder + sb.WriteString("## Available Tools\n\n") + sb.WriteString( "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n", ) + sb.WriteString("You have access to the following tools:\n\n") + for _, s := range summaries { sb.WriteString(s) + sb.WriteString("\n") } @@ -242,9 +414,11 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { parts := []string{} // Core identity section + parts = append(parts, cb.getIdentity()) // Orchestration guidance — injected only when spawn tool is registered + if cb.tools != nil { if _, hasSpawn := cb.tools.Get("spawn"); hasSpawn { parts = append(parts, orchestrationGuidance) @@ -252,22 +426,31 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Bootstrap files + bootstrapContent := cb.LoadBootstrapFiles() + if bootstrapContent != "" { parts = append(parts, bootstrapContent) } // Skills - show summary, AI can read full content with read_file tool + skillsSummary := cb.skillsLoader.BuildSkillsSummary() + if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills + + The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. + + %s`, skillsSummary)) } // Runtime status from tools (e.g., background processes) + if cb.tools != nil { if status := cb.tools.GetRuntimeStatus(); status != "" { parts = append(parts, status) @@ -275,55 +458,81 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md } // Peer session coordination + if cb.peerNote != "" { parts = append(parts, "## Active Sessions\n\n"+cb.peerNote) } // Memory context + memoryContext := cb.memory.GetMemoryContext() + if memoryContext != "" { parts = append(parts, "# Memory\n\n"+memoryContext) } // Join with "---" separator + return strings.Join(parts, "\n\n---\n\n") } // BuildSystemPromptWithCache returns the cached system prompt if available + // and source files haven't changed, otherwise builds and caches it. + // Source file changes are detected via mtime checks (cheap stat calls). + func (cb *ContextBuilder) BuildSystemPromptWithCache() string { // Try read lock first — fast path when cache is valid + cb.systemPromptMutex.RLock() + if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { result := cb.cachedSystemPrompt + cb.systemPromptMutex.RUnlock() + return result } + cb.systemPromptMutex.RUnlock() // Acquire write lock for building + cb.systemPromptMutex.Lock() + defer cb.systemPromptMutex.Unlock() // Double-check: another goroutine may have rebuilt while we waited + if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { return cb.cachedSystemPrompt } // Snapshot the baseline (existence + max mtime) BEFORE building the prompt. + // This way cachedAt reflects the pre-build state: if a file is modified + // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime, + // so the next sourceFilesChangedLocked check will correctly trigger a + // rebuild. The alternative (baseline after build) risks caching stale + // content with a too-new baseline, making the staleness invisible. + baseline := cb.buildCacheBaseline() + prompt := cb.BuildSystemPrompt() + cb.cachedSystemPrompt = prompt + cb.cachedAt = baseline.maxMtime + cb.existedAtCache = baseline.existed logger.DebugCF("agent", "System prompt cached", + map[string]any{ "length": len(prompt), }) @@ -332,95 +541,136 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { } // InvalidateCache clears the cached system prompt. + // Normally not needed because the cache auto-invalidates via mtime checks, + // but this is useful for tests or explicit reload commands. + func (cb *ContextBuilder) InvalidateCache() { cb.systemPromptMutex.Lock() + defer cb.systemPromptMutex.Unlock() cb.cachedSystemPrompt = "" + cb.cachedAt = time.Time{} + cb.existedAtCache = nil logger.DebugCF("agent", "System prompt cache invalidated", nil) } // sourcePaths returns the workspace source file paths tracked for cache + // invalidation (bootstrap files + memory). The skills directory is handled + // separately in sourceFilesChangedLocked because it requires both directory- + // level and recursive file-level mtime checks. + func (cb *ContextBuilder) sourcePaths() []string { // Include bootstrap files from all search directories (workDir, planWorkDir, workspace). + seen := map[string]bool{} + var paths []string + for _, spec := range bootstrapSpecs { var dirs []string + if spec.Scope == "global" { dirs = []string{cb.workspace} } else { dirs = cb.bootstrapProjectDirs() } + for _, dir := range dirs { p := filepath.Join(dir, spec.Name) + if !seen[p] { seen[p] = true + paths = append(paths, p) } } } + // Always track memory file. + memPath := filepath.Join(cb.workspace, "memory", "MEMORY.md") + if !seen[memPath] { paths = append(paths, memPath) } + return paths } // cacheBaseline holds the file existence snapshot and the latest observed + // mtime across all tracked paths. Used as the cache reference point. + type cacheBaseline struct { - existed map[string]bool + existed map[string]bool + maxMtime time.Time } // buildCacheBaseline records which tracked paths currently exist and computes + // the latest mtime across all tracked files + skills directory contents. + // Called under write lock when the cache is built. + func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { skillsDir := filepath.Join(cb.workspace, "skills") // All paths whose existence we track: source files + skills dir. + allPaths := append(cb.sourcePaths(), skillsDir) existed := make(map[string]bool, len(allPaths)) + var maxMtime time.Time for _, p := range allPaths { info, err := os.Stat(p) + existed[p] = err == nil + if err == nil && info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } } // Walk skills files to capture their mtimes too. + // Use os.Stat (not d.Info) to match the stat method used in + // fileChangedSince / skillFilesModifiedSince for consistency. + _ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr == nil && !d.IsDir() { if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } } + return nil }) // If no tracked files exist yet (empty workspace), maxMtime is zero. + // Use a very old non-zero time so that: + // 1. cachedAt.IsZero() won't trigger perpetual rebuilds. + // 2. Any real file created afterwards has mtime > cachedAt, so it + // will be detected by fileChangedSince (unlike time.Now() which + // could race with a file whose mtime <= Now). + if maxMtime.IsZero() { maxMtime = time.Unix(1, 0) } @@ -429,18 +679,26 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { } // sourceFilesChangedLocked checks whether any workspace source file has been + // modified, created, or deleted since the cache was last built. + // + // IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex. + // Go's sync.RWMutex is not reentrant, so this function must NOT acquire the + // lock itself (it would deadlock when called from BuildSystemPromptWithCache + // which already holds RLock or Lock). + func (cb *ContextBuilder) sourceFilesChangedLocked() bool { if cb.cachedAt.IsZero() { return true } // Check tracked source files (bootstrap + memory). + for _, p := range cb.sourcePaths() { if cb.fileChangedSince(p) { return true @@ -448,19 +706,29 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { } // --- Skills directory (handled separately from sourcePaths) --- + // + // 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files. + skillsDir := filepath.Join(cb.workspace, "skills") + if cb.fileChangedSince(skillsDir) { return true } // 2. Structural changes (add/remove entries inside the dir) are reflected + // in the directory's own mtime, which fileChangedSince already checks. + // + // 3. Content-only edits to files inside skills/ do NOT update the parent + // directory mtime on most filesystems, so we recursively walk to check + // individual file mtimes at any nesting depth. + if skillFilesModifiedSince(skillsDir, cb.cachedAt) { return true } @@ -469,93 +737,136 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { } // fileChangedSince returns true if a tracked source file has been modified, + // newly created, or deleted since the cache was built. + // + // Four cases: + // - existed at cache time, exists now -> check mtime + // - existed at cache time, gone now -> changed (deleted) + // - absent at cache time, exists now -> changed (created) + // - absent at cache time, gone now -> no change + func (cb *ContextBuilder) fileChangedSince(path string) bool { // Defensive: if existedAtCache was never initialized, treat as changed + // so the cache rebuilds rather than silently serving stale data. + if cb.existedAtCache == nil { return true } existedBefore := cb.existedAtCache[path] + info, err := os.Stat(path) + existsNow := err == nil if existedBefore != existsNow { return true // file was created or deleted } + if !existsNow { return false // didn't exist before, doesn't exist now } + return info.ModTime().After(cb.cachedAt) } // errWalkStop is a sentinel error used to stop filepath.WalkDir early. + // Using a dedicated error (instead of fs.SkipAll) makes the early-exit + // intent explicit and avoids the nilerr linter warning that would fire + // if the callback returned nil when its err parameter is non-nil. + var errWalkStop = errors.New("walk stop") // skillFilesModifiedSince recursively walks the skills directory and checks + // whether any file was modified after t. This catches content-only edits at + // any nesting depth (e.g. skills/name/docs/extra.md) that don't update + // parent directory mtimes. + func skillFilesModifiedSince(skillsDir string, t time.Time) bool { changed := false + err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr == nil && !d.IsDir() { if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) { changed = true + return errWalkStop // stop walking } } + return nil }) + // errWalkStop is expected (early exit on first changed file). + // os.IsNotExist means the skills dir doesn't exist yet — not an error. + // Any other error is unexpected and worth logging. + if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) } + return changed } // BootstrapFileInfo describes a resolved bootstrap file. + type BootstrapFileInfo struct { - Name string `json:"name"` - Path string `json:"path"` // empty = not found + Name string `json:"name"` + + Path string `json:"path"` // empty = not found + Scope string `json:"scope"` // "project" or "global" } // bootstrapFileSpec defines the search scope for each bootstrap file. + type bootstrapFileSpec struct { - Name string + Name string + Scope string // "project" = workDir→planWorkDir→workspace, "global" = workspace only } var bootstrapSpecs = []bootstrapFileSpec{ {Name: "AGENTS.md", Scope: "project"}, + {Name: "IDENTITY.md", Scope: "project"}, + {Name: "SOUL.md", Scope: "global"}, + {Name: "USER.md", Scope: "global"}, } // bootstrapProjectDirs returns de-duplicated search directories for project-scoped files. + func (cb *ContextBuilder) bootstrapProjectDirs() []string { seen := map[string]bool{} + var dirs []string + for _, d := range []string{cb.workDir, cb.memory.GetPlanWorkDir(), cb.workspace} { if d != "" && !seen[d] { seen[d] = true + dirs = append(dirs, d) } } + return dirs } @@ -563,17 +874,22 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { projectDirs := cb.bootstrapProjectDirs() var sb strings.Builder + for _, spec := range bootstrapSpecs { var dirs []string + if spec.Scope == "global" { dirs = []string{cb.workspace} } else { dirs = projectDirs } + for _, dir := range dirs { filePath := filepath.Join(dir, spec.Name) + if data, err := os.ReadFile(filePath); err == nil { fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data) + break } } @@ -583,44 +899,64 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { } // ResolveBootstrapPaths returns path resolution info for each bootstrap file + // using the same search logic as LoadBootstrapFiles. + func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo { projectDirs := cb.bootstrapProjectDirs() result := make([]BootstrapFileInfo, 0, len(bootstrapSpecs)) + for _, spec := range bootstrapSpecs { info := BootstrapFileInfo{Name: spec.Name, Scope: spec.Scope} + var dirs []string + if spec.Scope == "global" { dirs = []string{cb.workspace} } else { dirs = projectDirs } + for _, dir := range dirs { filePath := filepath.Join(dir, spec.Name) + if _, err := os.Stat(filePath); err == nil { info.Path = filePath + break } } + result = append(result, info) } + return result } // buildDynamicContext returns a short dynamic context string with per-request info. + // This changes every request (time, session) so it is NOT part of the cached prompt. + // LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: + // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block + // - OpenAI / Codex: prompt_cache_key for prefix-based caching + // + // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching + // See: https://platform.openai.com/docs/guides/prompt-caching + func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") + rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) var sb strings.Builder + fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) if channel != "" && chatID != "" { @@ -632,76 +968,119 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { func (cb *ContextBuilder) BuildMessages( history []providers.Message, + summary string, + currentMessage string, + media []string, + channel, chatID string, ) []providers.Message { messages := []providers.Message{} // The static part (identity, bootstrap, skills, memory) is cached locally to + // avoid repeated file I/O and string building on every call (fixes issue #607). + // Dynamic parts (time, session, summary) are appended per request. + // Everything is sent as a single system message for provider compatibility: + // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content + // to the top-level "system" parameter in the Messages API request. A single + // contiguous system block makes this extraction straightforward. + // - Codex maps only the first system message to its instructions field. + // - OpenAI-compat passes messages through as-is. + staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request + dynamicCtx := cb.buildDynamicContext(channel, chatID) // Compose a single system message: static (cached) + dynamic + optional summary. + // Keeping all system content in one message ensures every provider adapter can + // extract it correctly (Anthropic adapter -> top-level system param, + // Codex -> instructions field). + // + // SystemParts carries the same content as structured blocks so that + // cache-aware adapters (Anthropic) can set per-block cache_control. + // The static block is marked "ephemeral" — its prefix hash is stable + // across requests, enabling LLM-side KV cache reuse. + stringParts := []string{staticPrompt, dynamicCtx} contentBlocks := []providers.ContentBlock{ {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, + {Type: "text", Text: dynamicCtx}, } if summary != "" { summaryText := fmt.Sprintf( + "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", + summary) + stringParts = append(stringParts, summaryText) + contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") // Log system prompt summary for debugging (debug mode only). + // Read cachedSystemPrompt under lock to avoid a data race with + // concurrent InvalidateCache / BuildSystemPromptWithCache writes. + cb.systemPromptMutex.RLock() + isCached := cb.cachedSystemPrompt != "" + cb.systemPromptMutex.RUnlock() logger.DebugCF("agent", "System prompt built", + map[string]any{ - "static_chars": len(staticPrompt), + "static_chars": len(staticPrompt), + "dynamic_chars": len(dynamicCtx), - "total_chars": len(fullSystemPrompt), - "has_summary": summary != "", - "cached": isCached, + + "total_chars": len(fullSystemPrompt), + + "has_summary": summary != "", + + "cached": isCached, }) // Log preview of system prompt (avoid logging huge content) + preview := fullSystemPrompt + if len(preview) > 500 { preview = preview[:500] + "... (truncated)" } + logger.DebugCF("agent", "System prompt preview", + map[string]any{ "preview": preview, }) @@ -709,21 +1088,29 @@ func (cb *ContextBuilder) BuildMessages( history = sanitizeHistoryForProvider(history) // Single system message containing all context — compatible with all providers. + // SystemParts enables cache-aware adapters to set per-block cache_control; + // Content is the concatenated fallback for adapters that don't read SystemParts. + messages = append(messages, providers.Message{ - Role: "system", - Content: fullSystemPrompt, + Role: "system", + + Content: fullSystemPrompt, + SystemParts: contentBlocks, }) // Add conversation history + messages = append(messages, history...) // Add current user message + if strings.TrimSpace(currentMessage) != "" { messages = append(messages, providers.Message{ - Role: "user", + Role: "user", + Content: currentMessage, }) } @@ -737,58 +1124,86 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } sanitized := make([]providers.Message, 0, len(history)) + for _, msg := range history { switch msg.Role { case "system": + // Drop system messages from history. BuildMessages always + // constructs its own single system message (static + dynamic + + // summary); extra system messages would break providers that + // only accept one (Anthropic, Codex). + logger.DebugCF("agent", "Dropping system message from history", map[string]any{}) + continue case "tool": + if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) + continue } + // Walk backwards to find the nearest assistant message, + // skipping over any preceding tool messages (multi-tool-call case). + foundAssistant := false + for i := len(sanitized) - 1; i >= 0; i-- { if sanitized[i].Role == "tool" { continue } + if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 { foundAssistant = true } + break } + if !foundAssistant { logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) + continue } + sanitized = append(sanitized, msg) case "assistant": + if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) + continue } + prev := sanitized[len(sanitized)-1] + if prev.Role != "user" && prev.Role != "tool" { logger.DebugCF( + "agent", + "Dropping assistant tool-call turn with invalid predecessor", + map[string]any{"prev_role": prev.Role}, ) + continue } } + sanitized = append(sanitized, msg) default: + sanitized = append(sanitized, msg) } } @@ -798,41 +1213,54 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message func (cb *ContextBuilder) AddToolResult( messages []providers.Message, + toolCallID, toolName, result string, ) []providers.Message { messages = append(messages, providers.Message{ - Role: "tool", - Content: result, + Role: "tool", + + Content: result, + ToolCallID: toolCallID, }) + return messages } func (cb *ContextBuilder) AddAssistantMessage( messages []providers.Message, + content string, + toolCalls []map[string]any, ) []providers.Message { msg := providers.Message{ - Role: "assistant", + Role: "assistant", + Content: content, } + // Always add assistant message, whether or not it has tool calls + messages = append(messages, msg) + return messages } // LoadSkill loads a skill by name, returning its content (with frontmatter stripped) and whether it was found. + func (cb *ContextBuilder) LoadSkill(name string) (string, bool) { return cb.skillsLoader.LoadSkill(name) } // ListSkills returns all available skills from all tiers. + func (cb *ContextBuilder) ListSkills() []skills.SkillInfo { return cb.skillsLoader.ListSkills() } // Memory returns the underlying MemoryStore for direct plan queries. + func (cb *ContextBuilder) Memory() *MemoryStore { return cb.memory } @@ -840,105 +1268,129 @@ func (cb *ContextBuilder) Memory() *MemoryStore { // ---------- Plan passthrough methods ---------- // ReadMemory reads the long-term memory (MEMORY.md). + func (cb *ContextBuilder) ReadMemory() string { return cb.memory.ReadLongTerm() } // WriteMemory writes content to the long-term memory file. + func (cb *ContextBuilder) WriteMemory(content string) error { return cb.memory.WriteLongTerm(content) } // ClearMemory removes the long-term memory file. + func (cb *ContextBuilder) ClearMemory() error { return cb.memory.ClearLongTerm() } // HasActivePlan returns true if MEMORY.md contains an active plan. + func (cb *ContextBuilder) HasActivePlan() bool { return cb.memory.HasActivePlan() } // GetPlanStatus returns the plan status: "interviewing", "executing", or "". + func (cb *ContextBuilder) GetPlanStatus() string { return cb.memory.GetPlanStatus() } // IsPlanComplete returns true if all steps in all phases are [x]. + func (cb *ContextBuilder) IsPlanComplete() bool { return cb.memory.IsPlanComplete() } // IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. + func (cb *ContextBuilder) IsCurrentPhaseComplete() bool { return cb.memory.IsCurrentPhaseComplete() } // AdvancePhase increments the current phase number by 1. + func (cb *ContextBuilder) AdvancePhase() error { return cb.memory.AdvancePhase() } // SetCurrentPhase sets the current phase number to n. + func (cb *ContextBuilder) SetCurrentPhase(n int) error { return cb.memory.SetPhase(n) } // GetCurrentPhase returns the current phase number. + func (cb *ContextBuilder) GetCurrentPhase() int { return cb.memory.GetCurrentPhase() } // GetTotalPhases returns the total number of phases in the plan. + func (cb *ContextBuilder) GetTotalPhases() int { return cb.memory.GetTotalPhases() } // FormatPlanDisplay returns a user-facing display of the full plan. + func (cb *ContextBuilder) FormatPlanDisplay() string { return cb.memory.FormatPlanDisplay() } // MarkStep marks a step as done in the specified phase. + func (cb *ContextBuilder) MarkStep(phase, step int) error { return cb.memory.MarkStep(phase, step) } // AddStep appends a new step to the given phase. + func (cb *ContextBuilder) AddStep(phase int, desc string) error { return cb.memory.AddStep(phase, desc) } // ValidatePlanStructure validates plan structure for interview->review transition. + func (cb *ContextBuilder) ValidatePlanStructure() error { return cb.memory.ValidatePlanStructure() } // SetPlanStatus sets the plan status. + func (cb *ContextBuilder) SetPlanStatus(status string) error { return cb.memory.SetStatus(status) } // GetPlanWorkDir returns the WorkDir from the plan metadata, or "". + func (cb *ContextBuilder) GetPlanWorkDir() string { return cb.memory.GetPlanWorkDir() } // GetPlanTaskName returns the task description from the plan metadata, or "". + func (cb *ContextBuilder) GetPlanTaskName() string { return cb.memory.GetPlanTaskName() } // GetSkillsInfo returns information about loaded skills. + func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() + skillNames := make([]string, 0, len(allSkills)) + for _, s := range allSkills { skillNames = append(skillNames, s.Name) } + return map[string]any{ - "total": len(allSkills), + "total": len(allSkills), + "available": len(allSkills), - "names": skillNames, + + "names": skillNames, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index afdaabc3b..73c825ae8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1,7 +1,11 @@ // PicoClaw - Ultra-lightweight personal AI agent + // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot + // License: MIT + // + // Copyright (c) 2026 PicoClaw contributors package agent @@ -41,107 +45,174 @@ import ( ) // activeTask tracks a running agent task for live status and intervention. + type activeTask struct { - Description string - Result string // LLM response summary for completion notification - Iteration int - MaxIter int - StartedAt time.Time - cancel context.CancelFunc - interrupt chan string // buffered 1, for user message injection - toolLog []toolLogEntry - lastError *toolLogEntry // sticky: most recent error, persists across iterations - projectDir string // detected from exec cd target (authoritative) - fileCommonDir string // LCP of file paths relative to workspace (fallback) - streamedChunks bool // true after onChunk fires at least once - messageContent string // last content sent by the message tool (for inclusion in completion) - mu sync.Mutex + Description string + + Result string // LLM response summary for completion notification + + Iteration int + + MaxIter int + + StartedAt time.Time + + cancel context.CancelFunc + + interrupt chan string // buffered 1, for user message injection + + toolLog []toolLogEntry + + lastError *toolLogEntry // sticky: most recent error, persists across iterations + + projectDir string // detected from exec cd target (authoritative) + + fileCommonDir string // LCP of file paths relative to workspace (fallback) + + streamedChunks bool // true after onChunk fires at least once + + messageContent string // last content sent by the message tool (for inclusion in completion) + + mu sync.Mutex } // toolLogEntry records a single tool call for the live terminal view. + type toolLogEntry struct { - Name string - ArgsSnip string // first ~80 chars of args - Result string // "✓ 4.9s" or "✗ 3.2s" + Name string + + ArgsSnip string // first ~80 chars of args + + Result string // "✓ 4.9s" or "✗ 3.2s" + ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1" } // maxToolLogEntries limits the sliding window of tool log entries + // kept in memory and displayed in status messages. + const maxToolLogEntries = 5 // sessionSemaphore is a per-session mutex using a buffered channel. + type sessionSemaphore struct { ch chan struct{} } func newSessionSemaphore() *sessionSemaphore { s := &sessionSemaphore{ch: make(chan struct{}, 1)} + s.ch <- struct{}{} // initially unlocked + return s } type AgentLoop struct { - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - stats *stats.Tracker // nil when --stats not passed - running atomic.Bool - summarizing sync.Map - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - providerCache map[string]providers.LLMProvider - planStartPending bool // set by /plan start to trigger LLM execution - planClearHistory bool // set by /plan start clear to wipe history on transition - sessionLocks sync.Map // sessionKey → *sessionSemaphore - activeTasks sync.Map // sessionKey → *activeTask - sessions *SessionTracker - lastSystemPrompt atomic.Value // string — last system prompt sent to LLM - promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read - OnStateChange func() // called on plan/session/skills mutations - OnUserMessage func() // called when a real user message is processed - saveConfig func(*config.Config) error + bus *bus.MessageBus + + cfg *config.Config + + registry *AgentRegistry + + state *state.Manager + + stats *stats.Tracker // nil when --stats not passed + + running atomic.Bool + + summarizing sync.Map + + fallback *providers.FallbackChain + + channelManager *channels.Manager + + mediaStore media.MediaStore + + providerCache map[string]providers.LLMProvider + + planStartPending bool // set by /plan start to trigger LLM execution + + planClearHistory bool // set by /plan start clear to wipe history on transition + + sessionLocks sync.Map // sessionKey → *sessionSemaphore + + activeTasks sync.Map // sessionKey → *activeTask + + sessions *SessionTracker + + lastSystemPrompt atomic.Value // string — last system prompt sent to LLM + + promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read + + OnStateChange func() // called on plan/session/skills mutations + + OnUserMessage func() // called when a real user message is processed + + saveConfig func(*config.Config) error + onHeartbeatThreadUpdate func(int) - orchBroadcaster *orch.Broadcaster // nil when --orchestration not set - orchReporter orch.AgentReporter // always non-nil (Noop when disabled) - done chan struct{} // closed by Close() to stop background goroutines + + orchBroadcaster *orch.Broadcaster // nil when --orchestration not set + + orchReporter orch.AgentReporter // always non-nil (Noop when disabled) + + done chan struct{} // closed by Close() to stop background goroutines } // processOptions configures how a message is processed + type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction) + SessionKey string // Session identifier for history/context + + Channel string // Target channel for tool execution + + ChatID string // Target chat ID for tool execution + + UserMessage string // User message content (may include prefix) + + HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction) + DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - NoHistory bool // If true, don't load session history (for heartbeat) - TaskID string // Unique task ID for background task status tracking - Background bool // If true, this is a background task (cron/heartbeat) — enables live task notifications - SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge + + EnableSummary bool // Whether to trigger summarization + + SendResponse bool // Whether to send response via bus + + NoHistory bool // If true, don't load session history (for heartbeat) + + TaskID string // Unique task ID for background task status tracking + + Background bool // If true, this is a background task (cron/heartbeat) — enables live task notifications + + SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge } const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." func NewAgentLoop( cfg *config.Config, + msgBus *bus.MessageBus, + provider providers.LLMProvider, + enableStats ...bool, ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) // Set up shared fallback chain + cooldown := providers.NewCooldownTracker() + fallbackChain := providers.NewFallbackChain(cooldown) // Create state manager using default agent's workspace for channel recording + defaultAgent := registry.GetDefaultAgent() + var stateManager *state.Manager + if defaultAgent != nil { stateManager = state.NewManager(defaultAgent.Workspace) } @@ -149,40 +220,61 @@ func NewAgentLoop( providerCache := make(map[string]providers.LLMProvider) // Create stats tracker if enabled + var statsTracker *stats.Tracker + if len(enableStats) > 0 && enableStats[0] && defaultAgent != nil { statsTracker = stats.NewTracker(defaultAgent.Workspace) } // Determine if orchestration broadcaster is needed (any agent has subagents enabled). + // Note: instance.go maps defaults.Orchestration → Subagents.Enabled, so --orchestration + // is automatically reflected here. + var orchBroadcaster *orch.Broadcaster + var orchReporter orch.AgentReporter = orch.Noop + for _, id := range registry.ListAgentIDs() { if a, ok := registry.GetAgent(id); ok && a.Subagents != nil && a.Subagents.Enabled { orchBroadcaster = orch.NewBroadcaster() + orchReporter = orchBroadcaster + break } } al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - stats: statsTracker, - summarizing: sync.Map{}, - fallback: fallbackChain, - providerCache: providerCache, - sessions: NewSessionTracker(), + bus: msgBus, + + cfg: cfg, + + registry: registry, + + state: stateManager, + + stats: statsTracker, + + summarizing: sync.Map{}, + + fallback: fallbackChain, + + providerCache: providerCache, + + sessions: NewSessionTracker(), + orchBroadcaster: orchBroadcaster, - orchReporter: orchReporter, - done: make(chan struct{}), + + orchReporter: orchReporter, + + done: make(chan struct{}), } // Register shared tools to all agents (needs al for reporter injection). + registerSharedTools(cfg, msgBus, registry, provider, al) go al.gcLoop() @@ -191,83 +283,117 @@ func NewAgentLoop( } // reporter returns the active AgentReporter (never nil). + func (al *AgentLoop) reporter() orch.AgentReporter { if al.orchReporter == nil { return orch.Noop } + return al.orchReporter } // SetOrchReporter wires a Broadcaster as the active reporter. + // Called from cmd_gateway.go when --orchestration is set. + // --orchestration なし → 呼ばれない → reporter() は Noop を返す。 + func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) { al.orchBroadcaster = b + al.orchReporter = b } // GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring. + // Returns nil when orchestration is disabled. + func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster { return al.orchBroadcaster } func (al *AgentLoop) notifyStateChange() { al.promptDirty.Store(true) + if al.OnStateChange != nil { al.OnStateChange() } } // SetConfigSaver registers a callback used by slash commands that persist runtime config changes. + func (al *AgentLoop) SetConfigSaver(fn func(*config.Config) error) { al.saveConfig = fn } // SetHeartbeatThreadUpdater registers a callback to apply runtime heartbeat thread updates. + func (al *AgentLoop) SetHeartbeatThreadUpdater(fn func(int)) { al.onHeartbeatThreadUpdate = fn } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). + func registerSharedTools( cfg *config.Config, + msgBus *bus.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, + al *AgentLoop, ) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) + if !ok { continue } // Web tools + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + + TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + + PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - Proxy: cfg.Tools.Web.Proxy, + + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + + Proxy: cfg.Tools.Web.Proxy, }) + if err != nil { logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{ "agent_id": agentID, - "error": err.Error(), + + "error": err.Error(), }) } else if searchTool != nil { agent.Tools.Register(searchTool) + logger.InfoCF("agent", "Web search provider registered", map[string]any{ "agent_id": agentID, + "provider": searchTool.ProviderName(), }) } else { @@ -275,90 +401,149 @@ func registerSharedTools( "agent_id": agentID, }) } + fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy) + if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{ "agent_id": agentID, - "error": err.Error(), + + "error": err.Error(), }) } else { agent.Tools.Register(fetchTool) } // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + agent.Tools.Register(tools.NewI2CTool()) + agent.Tools.Register(tools.NewSPITool()) // Message tool + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func(channel, chatID, content string) error { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, - ChatID: chatID, + + ChatID: chatID, + Content: content, }) }) + agent.Tools.Register(messageTool) // Skill discovery and installation tools + registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + + ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), }) + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, ) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) // Spawn tool — only registered when orchestration is explicitly enabled. + if agent.Subagents != nil && agent.Subagents.Enabled { webSearchOpts := tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + + TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + + PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, } + subagentManager := tools.NewSubagentManager( + provider, + agent.Model, + agent.Workspace, + msgBus, + al.reporter(), + webSearchOpts, ) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + // Wire session recorder for DAG persistence. + recorder := newSessionRecorder(agent.Sessions) + conductorKey := routing.BuildAgentMainSessionKey(agent.ID) + subagentManager.SetSessionRecorder(recorder, conductorKey) + agent.SubagentMgr = subagentManager + spawnTool := tools.NewSpawnTool(subagentManager) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) + agent.Tools.Register(spawnTool) + // Register blocking subagent tool alongside spawn + subagentTool := tools.NewSubagentTool(subagentManager) + agent.Tools.Register(subagentTool) + + // Register conductor-side escalation tools (answer questions, review plans) + + agent.Tools.Register(tools.NewAnswerSubagentTool(subagentManager)) + + agent.Tools.Register(tools.NewReviewSubagentPlanTool(subagentManager)) } // Update context builder with the complete tools registry + agent.ContextBuilder.SetToolsRegistry(agent.Tools) // Set orchestration mode if enabled + if agent.Subagents != nil && agent.Subagents.Enabled { agent.ContextBuilder.SetOrchestrationEnabled(true) } @@ -369,73 +554,103 @@ func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) // LLM work is dispatched to a background worker so the main loop + // stays free to handle slash commands (/skills, …) instantly, + // even while a long tool-call chain is running. + llmQueue := make(chan bus.InboundMessage, 10) + workerDone := make(chan struct{}) + go func() { defer close(workerDone) + al.llmWorker(ctx, llmQueue) }() + defer func() { close(llmQueue) + <-workerDone }() for al.running.Load() { select { case <-ctx.Done(): + return nil + default: } msg, ok := al.bus.ConsumeInbound(ctx) + if !ok { continue } // Echo commands sent from the Mini App so the user can see what was sent. + if msg.Metadata["source"] == "webapp" && msg.Metadata["echoed"] == "" && msg.Content != "" { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: "via MiniApp: " + msg.Content, + Channel: msg.Channel, + + ChatID: msg.ChatID, + + Content: "via MiniApp: " + msg.Content, + SkipPlaceholder: true, }) } // Fast path: handle slash commands immediately without blocking the LLM worker. + if response, handled := al.handleCommand(ctx, msg); handled { if response != "" { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, + Channel: msg.Channel, + + ChatID: msg.ChatID, + + Content: response, + SkipPlaceholder: true, }) } + // /plan start sets the flag — enqueue a synthetic message so + // the LLM worker actually begins executing the plan. + if al.planStartPending { al.planStartPending = false + clearHistory := al.planClearHistory + al.planClearHistory = false if clearHistory { if agent := al.registry.GetDefaultAgent(); agent != nil { agent.Sessions.SetHistory(msg.SessionKey, nil) + agent.Sessions.SetSummary(msg.SessionKey, "") + _ = agent.Sessions.Save(msg.SessionKey) } } // Activate worktree for the session's plan execution + if agent := al.registry.GetDefaultAgent(); agent != nil { taskName := agent.ContextBuilder.Memory().GetPlanTaskName() + if taskName == "" { taskName = "plan-execution" } + planDir := agent.ContextBuilder.GetPlanWorkDir() + if wt, err := agent.ActivateWorktree(msg.SessionKey, taskName, planDir); err != nil { logger.WarnCF("agent", "Worktree activation skipped", map[string]any{"error": err.Error()}) } else { @@ -444,31 +659,44 @@ func (al *AgentLoop) Run(ctx context.Context) error { } syntheticMeta := map[string]string{"echoed": "1"} + for k, v := range msg.Metadata { if k != "source" { syntheticMeta[k] = v } } + select { case llmQueue <- bus.InboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - SenderID: msg.SenderID, + Channel: msg.Channel, + + ChatID: msg.ChatID, + + SenderID: msg.SenderID, + SessionKey: msg.SessionKey, - Content: "The plan has been approved. Begin executing.", - Metadata: syntheticMeta, + + Content: "The plan has been approved. Begin executing.", + + Metadata: syntheticMeta, }: + case <-ctx.Done(): + return nil } } + continue } // Dispatch to LLM worker + select { case llmQueue <- msg: + case <-ctx.Done(): + return nil } } @@ -477,6 +705,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } // llmWorker processes LLM messages sequentially in a background goroutine. + func (al *AgentLoop) llmWorker(ctx context.Context, queue <-chan bus.InboundMessage) { for msg := range queue { if ctx.Err() != nil { @@ -490,7 +719,9 @@ func (al *AgentLoop) llmWorker(ctx context.Context, queue <-chan bus.InboundMess if response != "" { alreadySent := false + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent != nil { if tool, ok := defaultAgent.Tools.Get("message"); ok { if mt, ok := tool.(*tools.MessageTool); ok { @@ -502,7 +733,9 @@ func (al *AgentLoop) llmWorker(ctx context.Context, queue <-chan bus.InboundMess if !alreadySent { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: msg.Channel, - ChatID: msg.ChatID, + + ChatID: msg.ChatID, + Content: response, }) } @@ -515,17 +748,24 @@ func (al *AgentLoop) Stop() { } // Close releases resources held by the loop (e.g. flushes write-behind stats + // and dirty session data). Should be called during graceful shutdown. + func (al *AgentLoop) Close() { select { case <-al.done: + // already closed + default: + close(al.done) } + if al.stats != nil { al.stats.Close() } + for _, agentID := range al.registry.ListAgentIDs() { if agent, ok := al.registry.GetAgent(agentID); ok { agent.Sessions.Close() @@ -534,30 +774,43 @@ func (al *AgentLoop) Close() { } // gcLoop periodically cleans up stale sessionLock entries. + func (al *AgentLoop) gcLoop() { ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + for { select { case <-ticker.C: + al.gcSessionLocks() + case <-al.done: + return } } } // gcSessionLocks removes unlocked (idle) sessionSemaphore entries from the map. + func (al *AgentLoop) gcSessionLocks() { al.sessionLocks.Range(func(key, val any) bool { sem := val.(*sessionSemaphore) + select { case <-sem.ch: + // Was unlocked — safe to remove + al.sessionLocks.Delete(key) + default: + // Currently locked — in use, keep } + return true }) } @@ -575,72 +828,102 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { } // resolveProvider returns the LLMProvider for the given provider/model pair. + // It caches created providers by "provider/model" key so each combination is + // only resolved once. Looks up model_list first (new format), then falls back + // to the legacy providers section via CreateProviderByName. + func (al *AgentLoop) resolveProvider( providerName, modelName string, + fallback providers.LLMProvider, ) providers.LLMProvider { key := strings.ToLower(providerName + "/" + modelName) + if key == "/" { return fallback } + if p, ok := al.providerCache[key]; ok { return p } // Try model_list first (new config format). + if mc := al.cfg.FindModelConfigByRef(providerName, modelName); mc != nil { p, _, err := providers.CreateProviderFromConfig(mc) + if err == nil { al.providerCache[key] = p + return p } + logger.WarnCF("agent", "Failed to create provider from model_list, trying legacy", + map[string]any{"provider": providerName, "model": modelName, "error": err.Error()}) } // Fall back to legacy providers section. + p, err := providers.CreateProviderByName(al.cfg, providerName) if err != nil { logger.WarnCF("agent", "Failed to create provider for fallback, using primary", + map[string]any{"provider": providerName, "error": err.Error()}) + return fallback } + al.providerCache[key] = p + return p } // SetMediaStore injects a MediaStore for media lifecycle management. + func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s } // inferMediaType determines the media type ("image", "audio", "video", "file") + // from a filename and MIME content type. + func inferMediaType(filename, contentType string) string { ct := strings.ToLower(contentType) + fn := strings.ToLower(filename) if strings.HasPrefix(ct, "image/") { return "image" } + if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { return "audio" } + if strings.HasPrefix(ct, "video/") { return "video" } // Fallback: infer from extension + ext := filepath.Ext(fn) + switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" } @@ -648,30 +931,40 @@ func inferMediaType(filename, contentType string) string { } // RecordLastChannel records the last active channel for this workspace. + // This uses the atomic state save mechanism to prevent data loss on crash. + func (al *AgentLoop) RecordLastChannel(channel string) error { if al.state == nil { return nil } + return al.state.SetLastChannel(channel) } // RecordLastChatID records the last active chat ID for this workspace. + // This uses the atomic state save mechanism to prevent data loss on crash. + func (al *AgentLoop) RecordLastChatID(chatID string) error { if al.state == nil { return nil } + return al.state.SetLastChatID(chatID) } // RecordLastHeartbeatTarget records the latest heartbeat-safe destination. + // This is intentionally separate from LastChannel so heartbeat routing can be + // reasoned about and evolved without breaking generic last-activity tracking. + func (al *AgentLoop) RecordLastHeartbeatTarget(target string) error { if al.state == nil { return nil } + return al.state.SetLastHeartbeatTarget(target) } @@ -681,14 +974,20 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, + content, sessionKey, channel, chatID string, ) (string, error) { msg := bus.InboundMessage{ - Channel: channel, - SenderID: "cron", - ChatID: chatID, - Content: content, + Channel: channel, + + SenderID: "cron", + + ChatID: chatID, + + Content: content, + SessionKey: sessionKey, + Metadata: map[string]string{ "background": "true", }, @@ -698,134 +997,194 @@ func (al *AgentLoop) ProcessDirectWithChannel( } // ProcessHeartbeat processes a heartbeat request without session history. + // Each heartbeat is independent and doesn't accumulate context. + func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() + if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } + heartbeatThreadID := 0 + if al.cfg != nil { heartbeatThreadID = al.cfg.Channels.Telegram.HeartbeatThreadID } + heartbeatChatID := al.withTelegramThread(channel, chatID, heartbeatThreadID) + return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: "heartbeat", - Channel: channel, - ChatID: heartbeatChatID, - UserMessage: content, + SessionKey: "heartbeat", + + Channel: channel, + + ChatID: heartbeatChatID, + + UserMessage: content, + DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - NoHistory: true, // Don't load session history for heartbeat - Background: true, // Enable live task notifications on Telegram + + EnableSummary: false, + + SendResponse: false, + + NoHistory: true, // Don't load session history for heartbeat + + Background: true, // Enable live task notifications on Telegram + }) } func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { // Add message preview to log (show full content for error messages) + var logContent string + if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { logContent = msg.Content // Full content for errors } else { logContent = utils.Truncate(msg.Content, 80) } + logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), + map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "sender_id": msg.SenderID, + "channel": msg.Channel, + + "chat_id": msg.ChatID, + + "sender_id": msg.SenderID, + "session_key": msg.SessionKey, }) // Handle reply-based intervention for active tasks + if taskID, ok := msg.Metadata["task_id"]; ok && taskID != "" { if val, found := al.activeTasks.Load(taskID); found { task := val.(*activeTask) + content := strings.TrimSpace(msg.Content) + lower := strings.ToLower(content) // Check for stop keywords + stopKeywords := []string{ "stop", "cancel", "abort", + "停止", "中止", "やめて", //nolint:gosmopolitan // intentional CJK stop words + } + isStop := false + for _, kw := range stopKeywords { if lower == kw { isStop = true + break } } if isStop { task.cancel() + logger.InfoCF("agent", "Task canceled by user intervention", + map[string]any{"task_id": taskID}) + return "Task canceled.", nil } // Inject message into interrupt channel for the tool loop + select { case task.interrupt <- content: + logger.InfoCF("agent", "User intervention queued", + map[string]any{"task_id": taskID, "content": utils.Truncate(content, 80)}) + default: + logger.WarnCF("agent", "Interrupt channel full, message dropped", + map[string]any{"task_id": taskID}) } + return "Intervention sent to running task.", nil } + // Task not found — fall through to normal processing } // Route system messages to processSystemMessage + if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } // Notify listeners that a real user message arrived (e.g. reset heartbeat suppression) + if al.OnUserMessage != nil { al.OnUserMessage() } // Expand /skill command: inject SKILL.md content into message, then continue to LLM + var expansionCompact string + if expanded, compact, ok := al.expandSkillCommand(msg); ok { msg.Content = expanded + expansionCompact = compact } // Expand /plan <task>: write interview seed, rewrite for LLM interview + if expanded, compact, ok := al.expandPlanCommand(msg); ok { msg.Content = expanded + expansionCompact = compact } // Check for commands + if response, handled := al.handleCommand(ctx, msg); handled { return response, nil } // Route to determine agent and session key + route := al.registry.ResolveRoute(routing.RouteInput{ - Channel: msg.Channel, - AccountID: msg.Metadata["account_id"], - Peer: extractPeer(msg), + Channel: msg.Channel, + + AccountID: msg.Metadata["account_id"], + + Peer: extractPeer(msg), + ParentPeer: extractParentPeer(msg), - GuildID: msg.Metadata["guild_id"], - TeamID: msg.Metadata["team_id"], + + GuildID: msg.Metadata["guild_id"], + + TeamID: msg.Metadata["team_id"], }) agent, ok := al.registry.GetAgent(route.AgentID) + if !ok { agent = al.registry.GetDefaultAgent() } + if agent == nil { return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) } // Reset message-tool state for this round so we don't skip publishing due to a previous round. + if tool, ok := agent.Tools.Get("message"); ok { if mt, ok := tool.(tools.ContextualTool); ok { mt.SetContext(msg.Channel, msg.ChatID) @@ -833,28 +1192,41 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } // Use routed session key, but honor ANY pre-set session key (for ProcessDirect/cron) + sessionKey := route.SessionKey + if msg.SessionKey != "" { sessionKey = msg.SessionKey } logger.InfoCF("agent", "Routed message", + map[string]any{ - "agent_id": agent.ID, + "agent_id": agent.ID, + "session_key": sessionKey, - "matched_by": route.MatchedBy, + + "matched_by": route.MatchedBy, }) return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: sessionKey, - Channel: msg.Channel, - ChatID: msg.ChatID, - UserMessage: msg.Content, - HistoryMessage: expansionCompact, + SessionKey: sessionKey, + + Channel: msg.Channel, + + ChatID: msg.ChatID, + + UserMessage: msg.Content, + + HistoryMessage: expansionCompact, + DefaultResponse: defaultResponse, - EnableSummary: true, - SendResponse: false, - Background: msg.Metadata["background"] == "true", + + EnableSummary: true, + + SendResponse: false, + + Background: msg.Metadata["background"] == "true", }) } @@ -864,93 +1236,140 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } logger.InfoCF("agent", "Processing system message", + map[string]any{ "sender_id": msg.SenderID, - "chat_id": msg.ChatID, + + "chat_id": msg.ChatID, }) // Parse origin channel from chat_id (format: "channel:chat_id") + var originChannel, originChatID string + if idx := strings.Index(msg.ChatID, ":"); idx > 0 { originChannel = msg.ChatID[:idx] + originChatID = msg.ChatID[idx+1:] } else { originChannel = "cli" + originChatID = msg.ChatID } // Extract subagent result from message content + // Format: "Task 'label' completed.\n\nResult:\n<actual content>" + content := msg.Content + if idx := strings.Index(content, "Result:\n"); idx >= 0 { content = content[idx+8:] // Extract just the result part } // Skip internal channels - only log, don't send to user + if constants.IsInternalChannel(originChannel) { logger.InfoCF("agent", "Subagent completed (internal channel)", + map[string]any{ - "sender_id": msg.SenderID, + "sender_id": msg.SenderID, + "content_len": len(content), - "channel": originChannel, + + "channel": originChannel, }) + return "", nil } // Inject subagent result into session history without running a full LLM loop. + // The conductor will see the result on its next turn. This avoids: + // - Flooding the chat with a response for every subagent completion + // - Consuming the Telegram "Thinking..." placeholder + // - Wasting LLM tokens on processing each result individually + agent := al.registry.GetDefaultAgent() + if agent == nil { return "", fmt.Errorf("no default agent for system message") } sessionKey := routing.BuildAgentMainSessionKey(agent.ID) + historyMsg := fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content) + // Write as TurnReport to the store for DAG tracking, with legacy fallback. + subagentSessionKey := routing.BuildSubagentSessionKey(extractTaskID(msg.SenderID)) + store := agent.Sessions.Store() + reportTurn := &session.Turn{ - Kind: session.TurnReport, + Kind: session.TurnReport, + OriginKey: subagentSessionKey, - Author: msg.SenderID, - Messages: []providers.Message{{Role: "user", Content: historyMsg}}, + + Author: msg.SenderID, + + Messages: []providers.Message{{Role: "user", Content: historyMsg}}, } + if err := store.Append(sessionKey, reportTurn); err != nil { logger.ErrorCF("agent", "Failed to record report turn, falling back to legacy", + map[string]any{"error": err.Error()}) + agent.Sessions.AddMessage(sessionKey, "user", historyMsg) + agent.Sessions.MarkDirty(sessionKey) } else { // Update in-memory cache so conductor sees the message on next turn. + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: historyMsg}) + agent.Sessions.AdvanceStored(sessionKey, 1) } // Send a brief notification (SkipPlaceholder to avoid corrupting status messages) + label := msg.SenderID + if idx := strings.LastIndex(label, ":"); idx >= 0 { label = label[idx+1:] } + notification := formatSubagentCompletion(label, msg.Metadata) + subagentThreadID := 0 + if al.cfg != nil { subagentThreadID = al.cfg.Channels.Telegram.SubagentThreadID } + notifyChatID := al.withTelegramThread(originChannel, originChatID, subagentThreadID) + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: originChannel, - ChatID: notifyChatID, - Content: notification, + Channel: originChannel, + + ChatID: notifyChatID, + + Content: notification, + SkipPlaceholder: true, }) logger.InfoCF("agent", "Subagent result injected into session history", + map[string]any{ - "sender_id": msg.SenderID, + "sender_id": msg.SenderID, + "session_key": sessionKey, + "content_len": len(content), }) @@ -958,24 +1377,34 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } // extractTaskID extracts the task ID from a sender ID like "subagent:subagent-1". + func extractTaskID(senderID string) string { if idx := strings.LastIndex(senderID, ":"); idx >= 0 { return senderID[idx+1:] } + return senderID } // formatSubagentCompletion builds the user-facing notification for a completed subagent. + // If metadata contains duration_ms and tool_calls it produces e.g.: + // + // "📋 scout-1 completed (3.2s, 5 tool calls)." + // + // Without metadata it falls back to the plain "📋 scout-1 completed." format. + func formatSubagentCompletion(label string, metadata map[string]string) string { if len(metadata) == 0 { return fmt.Sprintf("📋 %s completed.", label) } + durationMs, _ := strconv.ParseInt(metadata["duration_ms"], 10, 64) + toolCalls, _ := strconv.Atoi(metadata["tool_calls"]) if durationMs <= 0 && toolCalls <= 0 { @@ -983,9 +1412,11 @@ func formatSubagentCompletion(label string, metadata map[string]string) string { } parts := make([]string, 0, 2) + if durationMs > 0 { parts = append(parts, formatDurationMs(durationMs)) } + if toolCalls > 0 { if toolCalls == 1 { parts = append(parts, "1 tool call") @@ -993,25 +1424,35 @@ func formatSubagentCompletion(label string, metadata map[string]string) string { parts = append(parts, fmt.Sprintf("%d tool calls", toolCalls)) } } + return fmt.Sprintf("📋 %s completed (%s).", label, strings.Join(parts, ", ")) } // formatDurationMs converts milliseconds to a human-readable duration string. + // Examples: 800 → "0.8s", 1200 → "1.2s", 65000 → "1m5s", 3661000 → "61m1s". + func formatDurationMs(ms int64) string { if ms < 1000 { return fmt.Sprintf("%dms", ms) } + totalSec := ms / 1000 + if totalSec < 60 { tenths := (ms % 1000) / 100 + return fmt.Sprintf("%d.%ds", totalSec, tenths) } + mins := totalSec / 60 + sec := totalSec % 60 + if sec == 0 { return fmt.Sprintf("%dm", mins) } + return fmt.Sprintf("%dm%ds", mins, sec) } @@ -1021,9 +1462,11 @@ func (al *AgentLoop) withTelegramThread(channel, chatID string, threadID int) st } baseChatID := chatID + if slash := strings.Index(baseChatID, "/"); slash >= 0 { baseChatID = baseChatID[:slash] } + if baseChatID == "" { return chatID } @@ -1032,114 +1475,160 @@ func (al *AgentLoop) withTelegramThread(channel, chatID string, threadID int) st } // acquireSessionLock gets or creates a per-session semaphore and acquires it. + // Returns false if the context is canceled before the lock is acquired. + func (al *AgentLoop) acquireSessionLock(ctx context.Context, sessionKey string) bool { val, _ := al.sessionLocks.LoadOrStore(sessionKey, newSessionSemaphore()) + sem := val.(*sessionSemaphore) + select { case <-sem.ch: + return true + case <-ctx.Done(): + return false } } // releaseSessionLock releases the per-session semaphore. + func (al *AgentLoop) releaseSessionLock(sessionKey string) { if val, ok := al.sessionLocks.Load(sessionKey); ok { sem := val.(*sessionSemaphore) + sem.ch <- struct{}{} } } // runAgentLoop is the core message processing logic. + func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { // -1. Acquire per-session lock to prevent concurrent access on the same session + if !al.acquireSessionLock(ctx, opts.SessionKey) { return "", fmt.Errorf("context canceled while waiting for session lock") } + defer al.releaseSessionLock(opts.SessionKey) // Report session lifecycle to canvas. + al.reporter().ReportSpawn(opts.SessionKey, opts.Channel, opts.UserMessage) + defer al.reporter().ReportGC(opts.SessionKey, "completed") // -0. Create cancelable child context and register active task + taskCtx, taskCancel := context.WithCancel(ctx) + defer taskCancel() task := &activeTask{ Description: utils.Truncate(opts.UserMessage, 80), - MaxIter: agent.MaxIterations, - StartedAt: time.Now(), - cancel: taskCancel, - interrupt: make(chan string, 1), + + MaxIter: agent.MaxIterations, + + StartedAt: time.Now(), + + cancel: taskCancel, + + interrupt: make(chan string, 1), } // Guarantee heartbeat worktree cleanup on ALL exit paths (error, panic, normal). + // Wait for spawned subagents first so they aren't killed mid-flight. + // After auto-commit, attempt to merge the worktree branch into main. + defer func() { if opts.Background { if agent.SubagentMgr != nil { agent.SubagentMgr.WaitAll(35 * time.Minute) // slightly above spawnTimeout } + wt := agent.GetWorktree(opts.SessionKey) + if wt != nil { // 1. Auto-commit uncommitted changes in worktree + if git.HasUncommittedChanges(wt.Path) { _ = git.AutoCommit(wt.Path, "heartbeat: auto-save") } // 2. Check if there are unique commits worth merging + repoRoot := git.FindRepoRoot(agent.Workspace) + ahead := git.CommitsAhead(repoRoot, wt.BaseBranch, wt.Branch) if ahead > 0 && repoRoot != "" { // 3. Try fast-forward merge into base branch + mr := git.MergeWorktreeBranch(repoRoot, wt) // 4. Notify based on merge result + if !constants.IsInternalChannel(opts.Channel) { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) + if mr.Merged { _ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, + + ChatID: opts.ChatID, + Content: fmt.Sprintf("Heartbeat: merged %d commit(s) to %s.", + ahead, wt.BaseBranch), }) } else if mr.Conflict { _ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, + + ChatID: opts.ChatID, + Content: fmt.Sprintf("Heartbeat: merge conflict on branch `%s` — manual merge needed.", + mr.Branch), }) } + cleanupCancel() } } // 5. Dispose worktree (branch auto-deleted if merged, kept if conflict) + agent.DeactivateWorktree(opts.SessionKey, "", false) } } }() // For background tasks (cron/heartbeat), generate a TaskID and send notification + isBackgroundTask := opts.Background && al.state != nil + if isBackgroundTask && opts.TaskID == "" { opts.TaskID = fmt.Sprintf("task-%s-%d", opts.SessionKey, time.Now().UnixMilli()) // Determine notification channel: use opts.Channel if already a real channel, + // otherwise resolve from last active channel + notifyChannel := opts.Channel + notifyChatID := opts.ChatID + if constants.IsInternalChannel(notifyChannel) || notifyChannel == "" { if lastChannel := al.state.GetLastChannel(); lastChannel != "" { if idx := strings.Index(lastChannel, ":"); idx > 0 { notifyChannel = lastChannel[:idx] + notifyChatID = lastChannel[idx+1:] } } @@ -1147,111 +1636,169 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if notifyChannel != "" && notifyChatID != "" && !constants.IsInternalChannel(notifyChannel) { // Override opts channel/chatID for status updates + opts.Channel = notifyChannel + opts.ChatID = notifyChatID // Send initial task notification + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: notifyChannel, - ChatID: notifyChatID, - Content: fmt.Sprintf("\U0001F916 Background task started\n%s", task.Description), + Channel: notifyChannel, + + ChatID: notifyChatID, + + Content: fmt.Sprintf("\U0001F916 Background task started\n%s", task.Description), + IsTaskStatus: true, - TaskID: opts.TaskID, + + TaskID: opts.TaskID, }) } } // Shared variable for capturing LLM's final response. The defer below reads it + // to include the response in the task completion message. + var finalContent string // Use TaskID as key if available (for background tasks), else sessionKey + taskKey := opts.SessionKey + if opts.TaskID != "" { taskKey = opts.TaskID } + al.activeTasks.Store(taskKey, task) + defer func() { al.activeTasks.Delete(taskKey) // Publish final task status on completion for background tasks. + // Include finalContent so the LLM response appears in the same bubble + // as the completion status, avoiding duplicate messages. + if opts.TaskID != "" { elapsed := time.Since(task.StartedAt) + completionMsg := fmt.Sprintf("\u2705 Task completed (%.1fs)", elapsed.Seconds()) // Determine the best content to show in the completion bubble. + // Priority: message tool content > finalContent > task.Result + task.mu.Lock() + msgContent := task.messageContent + task.mu.Unlock() var resultContent string + switch { case msgContent != "": + // The message tool already sent this to the user via the + // task bubble; re-include it so the completion doesn't erase it. + resultContent = msgContent + case finalContent != "" && finalContent != defaultResponse && finalContent != "HEARTBEAT_OK": + resultContent = finalContent + default: + summary := task.Result + if summary == "" { summary = task.Description } + resultContent = summary } if resultContent != "" { combined := completionMsg + "\n\n" + resultContent + if len([]rune(combined)) <= 4096 { completionMsg = combined } else { // Too long for one bubble: send header as task status, + // body as regular message (auto-split by SplitMessage). + doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: completionMsg, - IsTaskStatus: true, - TaskID: opts.TaskID, - Final: true, - }) + _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, + + ChatID: opts.ChatID, + + Content: completionMsg, + + IsTaskStatus: true, + + TaskID: opts.TaskID, + + Final: true, + }) + + _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ + Channel: opts.Channel, + + ChatID: opts.ChatID, + Content: resultContent, }) + doneCancel() + return } } + doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: completionMsg, + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: completionMsg, + IsTaskStatus: true, - TaskID: opts.TaskID, - Final: true, + + TaskID: opts.TaskID, + + Final: true, }) + doneCancel() } }() // Replace ctx with the cancelable child context + ctx = taskCtx // 0. Record last channel for heartbeat notifications (skip internal channels) + if opts.Channel != "" && opts.ChatID != "" { // Don't record internal channels (cli, system, subagent) + if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) + if err := al.RecordLastChannel(channelKey); err != nil { logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } + if err := al.RecordLastHeartbeatTarget(channelKey); err != nil { logger.WarnCF("agent", "Failed to record last heartbeat target", map[string]any{"error": err.Error()}) } @@ -1259,31 +1806,47 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // 1. Update tool contexts + al.updateToolContexts(agent, opts.Channel, opts.ChatID) // 1-bis. For background tasks that don't send a final response (e.g. heartbeat), + // redirect the message tool to publish as IsTaskStatus so its output lands in + // the same bubble as the task status instead of creating a separate message. + if opts.Background && !opts.SendResponse && opts.TaskID != "" { if tool, ok := agent.Tools.Get("message"); ok { if mt, ok := tool.(*tools.MessageTool); ok { taskID := opts.TaskID + mt.SetSendCallback(func(channel, chatID, content string) error { // Capture the message tool's content so the completion + // defer can include it instead of losing it to an overwrite. + if task != nil { task.mu.Lock() + task.messageContent = content + task.mu.Unlock() } + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, + Channel: channel, + + ChatID: chatID, + + Content: content, + IsTaskStatus: true, - TaskID: taskID, + + TaskID: taskID, }) }) } @@ -1291,9 +1854,13 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // 1a. Set session-specific working directory for bootstrap file lookup. + // Prefer the tool-detected project directory (touch_dir) from the session tracker, + // resolved as an absolute path under workspace. Fall back to worktree or workspace. + if active := al.sessions.ListActive(); len(active) > 0 && active[0].SessionKey == opts.SessionKey && + active[0].TouchDir != "" { agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, active[0].TouchDir)) } else { @@ -1301,227 +1868,334 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // 1b. Inject peer session awareness into system prompt + projectPath := agent.ContextBuilder.GetPlanWorkDir() + if projectPath == "" { projectPath = agent.Workspace } + peers := al.sessions.GetPeerPurposes(opts.SessionKey, projectPath) + if len(peers) > 0 { var peerNote strings.Builder + peerNote.WriteString("Other sessions working on this project:\n") + for _, p := range peers { peerNote.WriteString(fmt.Sprintf("- %s: %s (branch: %s)\n", p.SessionKey, p.Purpose, p.Branch)) } + peerNote.WriteString("\nAvoid conflicting changes with these sessions.") + agent.ContextBuilder.SetPeerNote(peerNote.String()) } else { agent.ContextBuilder.SetPeerNote("") } // 2. Build messages (skip history for heartbeat) + var history []providers.Message + var summary string + if !opts.NoHistory { history = agent.Sessions.GetHistory(opts.SessionKey) + summary = agent.Sessions.GetSummary(opts.SessionKey) // Sanitize history to remove orphaned tool calls (from crashes/session collisions) + var removedCount int + history, removedCount = session.SanitizeHistory(history) + if removedCount > 0 { logger.WarnCF("agent", "Sanitized session history: removed orphaned messages", + map[string]any{ - "session_key": opts.SessionKey, + "session_key": opts.SessionKey, + "removed_count": removedCount, }) + // Persist the sanitized history + agent.Sessions.SetHistory(opts.SessionKey, history) + _ = agent.Sessions.Save(opts.SessionKey) } } + messages := agent.ContextBuilder.BuildMessages( + history, + summary, + opts.UserMessage, + nil, + opts.Channel, + opts.ChatID, ) // 2b. Interview staleness nudge: if MEMORY.md hasn't been updated for + // several consecutive turns, inject a reminder so the AI writes its findings. + const interviewStaleThreshold = 2 + if agent.ContextBuilder.GetPlanStatus() == "interviewing" && agent.interviewStaleCount >= interviewStaleThreshold { messages = append(messages, providers.Message{ - Role: "user", + Role: "user", + Content: "[System] You have been interviewing for several turns without updating memory/MEMORY.md. Please use edit_file now to save your findings to the ## Context section, or organize the plan into ## Phase sections with `- [ ]` checkbox steps if you have enough information.", }) } // 2c. Background plan preamble: append to system prompt (high attention) + // so the LLM knows from the start that it must mark steps [x]. + // Skip if a chat session is actively working on the plan directory. + if opts.Background && agent.ContextBuilder.HasActivePlan() && agent.ContextBuilder.GetPlanStatus() == "executing" { planDir := agent.ContextBuilder.GetPlanWorkDir() + skipPreamble := planDir != "" && al.sessions.IsActiveInDir(planDir, "heartbeat") + if !skipPreamble && len(messages) > 0 && messages[0].Role == "system" { var sb strings.Builder + sb.WriteString(messages[0].Content) + sb.WriteString("\n\n## Background Execution\n") + sb.WriteString("You are running as a background heartbeat with no conversation history. ") + sb.WriteString("MEMORY.md is the only shared state between heartbeats. ") + sb.WriteString( + "After completing each plan step, immediately use edit_file to mark it [x] in memory/MEMORY.md.", ) + messages[0].Content = sb.String() } } // 2d. Snapshot plan status and MEMORY.md size before LLM iteration. + preStatus := agent.ContextBuilder.GetPlanStatus() + var preMemoryLen int + if preStatus == "interviewing" { preMemoryLen = len(agent.ContextBuilder.ReadMemory()) } // 3. Save user message to session (use compact form if available) + historyMsg := opts.UserMessage + if opts.HistoryMessage != "" { historyMsg = opts.HistoryMessage } + agent.Sessions.AddMessage(opts.SessionKey, "user", historyMsg) // 4. Record user prompt for stats + if al.stats != nil { al.stats.RecordPrompt() } // Capture the finalized system prompt for Mini App inspection + if len(messages) > 0 { al.lastSystemPrompt.Store(messages[0].Content) + al.promptDirty.Store(false) } // 5. Run LLM iteration loop (with automatic phase transitions) + var iteration int + const maxPhaseTransitions = 10 for phaseLoop := 0; ; phaseLoop++ { // On phase transition: rebuild system prompt with new phase context + nudge + if phaseLoop > 0 { messages = agent.ContextBuilder.BuildMessages( + agent.Sessions.GetHistory(opts.SessionKey), + agent.Sessions.GetSummary(opts.SessionKey), + "", nil, opts.Channel, opts.ChatID, ) + messages = append(messages, providers.Message{ Role: "user", + Content: fmt.Sprintf( + "[System] Phase %d is now active. Continue working on the next steps.", + agent.ContextBuilder.GetCurrentPhase(), ), }) + if len(messages) > 0 { al.lastSystemPrompt.Store(messages[0].Content) } } curPlanStatus := preStatus + if phaseLoop > 0 { curPlanStatus = agent.ContextBuilder.GetPlanStatus() } var err error + finalContent, iteration, err = al.runLLMIteration(ctx, agent, messages, opts, task, curPlanStatus) if err != nil { return "", err } // 5a. Auto-advance plan phases after LLM iteration + postStatus := agent.ContextBuilder.GetPlanStatus() + if !agent.ContextBuilder.HasActivePlan() || + !(postStatus == "executing" || postStatus == "review" || postStatus == "completed") { break } // Intercept: if AI changed status to executing or review without user approval + // (from interviewing or review), validate and hold at "review". + if preStatus == "interviewing" || (preStatus == "review" && postStatus == "executing") { if err := agent.ContextBuilder.ValidatePlanStructure(); err != nil { _ = agent.ContextBuilder.SetPlanStatus("interviewing") + logger.WarnCF("agent", "Reverted plan to interviewing: "+err.Error(), + map[string]any{"agent_id": agent.ID}) + rejectionMsg := "[System] Plan rejected: " + err.Error() + ". Fix and try again." + agent.Sessions.AddMessage(opts.SessionKey, "user", rejectionMsg) } else { _ = agent.ContextBuilder.SetPlanStatus("review") + al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStatePlanReview, "") + if !constants.IsInternalChannel(opts.Channel) { planDisplay := agent.ContextBuilder.FormatPlanDisplay() + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: planDisplay + "\n\nUse /plan start to approve, or continue chatting to refine.", + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: planDisplay + "\n\nUse /plan start to approve, or continue chatting to refine.", + SkipPlaceholder: true, }) } } + break } if postStatus == "executing" && agent.ContextBuilder.GetTotalPhases() == 0 { _ = agent.ContextBuilder.SetPlanStatus("interviewing") + logger.WarnCF("agent", "Reverted plan to interviewing: no phases defined", + map[string]any{"agent_id": agent.ID}) + break } if agent.ContextBuilder.IsPlanComplete() { total := agent.ContextBuilder.GetTotalPhases() + _ = agent.ContextBuilder.SetCurrentPhase(total) + if preStatus != "completed" { _ = agent.ContextBuilder.SetPlanStatus("completed") + al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStatePlanCompleted, "") // Deactivate worktree on plan completion + commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName() + wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false) if !constants.IsInternalChannel(opts.Channel) { msg := "\u2705 Plan completed!" + if wtResult != nil && wtResult.CommitsAhead > 0 { msg += fmt.Sprintf("\nBranch `%s` retained (%d commits). To merge: `git merge %s`", + wtResult.Branch, wtResult.CommitsAhead, wtResult.Branch) } + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: msg, + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: msg, + SkipPlaceholder: true, }) } } + break } if agent.ContextBuilder.IsCurrentPhaseComplete() { if phaseLoop >= maxPhaseTransitions { logger.WarnCF("agent", "Max phase transitions reached, stopping", + map[string]any{"agent_id": agent.ID, "transitions": phaseLoop}) + break } + prev := agent.ContextBuilder.GetCurrentPhase() + _ = agent.ContextBuilder.AdvancePhase() + next := agent.ContextBuilder.GetCurrentPhase() + if !constants.IsInternalChannel(opts.Channel) { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: fmt.Sprintf("Phase %d complete. Moving to Phase %d.", prev, next), + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: fmt.Sprintf("Phase %d complete. Moving to Phase %d.", prev, next), + SkipPlaceholder: true, }) } + al.notifyStateChange() + continue } @@ -1531,55 +2205,75 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt al.notifyStateChange() // 5b. Interview staleness detection: compare MEMORY.md size after iteration. + if agent.ContextBuilder.GetPlanStatus() == "interviewing" { postMemoryLen := len(agent.ContextBuilder.ReadMemory()) + if postMemoryLen == preMemoryLen { agent.interviewStaleCount++ } else { agent.interviewStaleCount = 0 } + agent.interviewMemoryLen = postMemoryLen } else { // Reset counter when not interviewing. + agent.interviewStaleCount = 0 } // 5c. Handle empty response + if finalContent == "" { finalContent = opts.DefaultResponse } // 5d. Store result summary for task completion notification + if task != nil { task.Result = utils.Truncate(finalContent, 280) } // 6. Save final assistant message to session (deferred write-behind) + agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) + agent.Sessions.MarkDirty(opts.SessionKey) // 7. Optional: summarization + if opts.EnableSummary { al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) } // 8. Optional: send response via bus + if opts.SendResponse { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: finalContent, + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: finalContent, + SkipPlaceholder: opts.SystemMessage, // suppress Telegram "Thinking..." for system messages + }) } // 9. Log response + responsePreview := utils.Truncate(finalContent, 120) + logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), + map[string]any{ - "agent_id": agent.ID, - "session_key": opts.SessionKey, - "iterations": iteration, + "agent_id": agent.ID, + + "session_key": opts.SessionKey, + + "iterations": iteration, + "final_length": len(finalContent), }) @@ -1587,15 +2281,18 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // Task reminder constants and helpers. + const ( taskReminderMaxChars = 500 - blockerMaxChars = 200 + + blockerMaxChars = 200 ) func shouldInjectReminder(iteration, interval int) bool { if interval <= 0 { return false } + return iteration > 1 && iteration%interval == 0 } @@ -1603,8 +2300,10 @@ func buildTaskReminder(userMessage string, lastBlocker string) providers.Message truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars) var content string + if lastBlocker != "" { truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars) + content = fmt.Sprintf( "[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nFix the blocker if essential, or find an alternative. If all steps are complete, move on.", truncatedTask, @@ -1618,141 +2317,213 @@ func buildTaskReminder(userMessage string, lastBlocker string) providers.Message } return providers.Message{ - Role: "user", + Role: "user", + Content: content, } } // interviewRejectMessage is the fixed rejection text injected when tool calls + // are blocked during the interview phase. It is deliberately short to avoid + // wasting tokens, and ends with a purpose reminder to steer the LLM back. + const interviewRejectMessage = "[System] Tool call rejected. " + + "You are in interview mode — ask the user questions and update MEMORY.md. " + + "Do not execute, edit, or write project files." // buildPlanReminder returns a reminder message for plan pre-execution states + // (interviewing / review) to keep the AI focused on the interview workflow + // during tool-call iterations. + func buildPlanReminder(planStatus string) (providers.Message, bool) { var content string + switch planStatus { case "interviewing": + content = "[System] You are interviewing the user to build a plan. " + + "Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " + + "When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " + + "Then change > Status: to review. Do NOT set it to executing." + case "review": + content = "[System] The plan is under review. " + + "Wait for the user to approve or request changes. Do not proceed with execution." + default: + return providers.Message{}, false } + return providers.Message{Role: "user", Content: content}, true } // buildOrchReminder returns a reminder to use spawn/subagent during plan execution. + // Fires on first iteration and every 3rd iteration to reinforce delegation behavior. + func buildOrchReminder(iteration int) (providers.Message, bool) { if iteration != 1 && iteration%3 != 0 { return providers.Message{}, false } + content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents. + Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result). + Do NOT implement steps inline unless they are a single trivial tool call. + + To delegate, call the tool with JSON arguments: + Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."} + Tool: subagent Arguments: {"task": "...", "label": "..."} + + Spawn multiple independent steps in parallel for maximum throughput.` + return providers.Message{Role: "user", Content: content}, true } // cdPrefixPattern matches "cd /some/path && " at the start of a shell command. + // Group 1 captures the target directory path. + var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`) // optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q. + // Only standalone flags are removed; flags whose value is the next positional + // argument (e.g. "-A 20") are kept because removing them would lose context. + var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`) // extractExecProjectDir extracts the basename of an exec cd target. + // Returns "" if the command has no cd prefix. + func extractExecProjectDir(args map[string]any) string { cmd, _ := args["command"].(string) + if cmd == "" { return "" } + m := cdPrefixPattern.FindStringSubmatch(cmd) + if len(m) < 2 { return "" } + cdPath := strings.TrimRight(m[1], "/\\") + if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { return cdPath[idx+1:] } + if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 { return cdPath[idx+1:] } + return cdPath } // fileParentRelDir returns the parent directory of a file path, relative to + // workspace. Returns "" if the path is not under workspace or has no parent. + func fileParentRelDir(filePath, workspace string) string { ws := strings.TrimRight(workspace, "/\\") + if ws == "" { return "" } + rest := strings.TrimPrefix(filePath, ws) + if rest == filePath { return "" // not under workspace } + rest = strings.TrimLeft(rest, "/\\") + // Remove the filename — keep only the directory part + if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 { return rest[:idx] } + return "" // file is directly under workspace, no meaningful dir } // commonDirPrefix computes the longest common directory prefix of two + // slash-separated paths. Returns "" if there is no common component. + func commonDirPrefix(a, b string) string { partsA := strings.Split(a, "/") + partsB := strings.Split(b, "/") + n := len(partsA) + if len(partsB) < n { n = len(partsB) } + common := 0 + for i := 0; i < n; i++ { if partsA[i] != partsB[i] { break } + common = i + 1 } + if common == 0 { return "" } + return strings.Join(partsA[:common], "/") } // displayProjectDir returns the project directory name for status display. + // Prefers the authoritative exec-based projectDir; falls back to the + // basename of the file-based common directory. + func displayProjectDir(task *activeTask) string { if task.projectDir != "" { return task.projectDir } + if task.fileCommonDir != "" { dir := task.fileCommonDir + if idx := strings.LastIndex(dir, "/"); idx >= 0 { return dir[idx+1:] } + return dir } + return "" } @@ -1760,48 +2531,71 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string if al.channelManager == nil { return "" } + if ch, ok := al.channelManager.GetChannel(channelName); ok { return ch.ReasoningChannelID() } + return "" } // buildArgsSnippet produces a human-friendly snippet for the tool log. + // For exec: extracts the command and strips the leading "cd <workspace> && ". + // For file tools: extracts the path and strips the workspace prefix. + // Falls back to raw JSON truncation. + func buildArgsSnippet(toolName string, args map[string]any, workspace string) string { switch toolName { case "exec": + cmd, _ := args["command"].(string) + if cmd == "" { break } + cmd = cdPrefixPattern.ReplaceAllString(cmd, "") + cmd = optFlagPattern.ReplaceAllString(cmd, "") + return utils.Truncate(cmd, 80) case "read_file", "write_file", "edit_file", "append_file", "list_dir": + path, _ := args["path"].(string) + if path == "" { break } + if workspace != "" { path = strings.TrimPrefix(path, workspace) + path = strings.TrimPrefix(path, "/") } + // Prioritize filename: if path is too long, show "…/filename" + const maxPath = 60 + if runes := []rune(path); len(runes) > maxPath { // Find last slash to extract filename + if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 { - filename := path[lastSlash:] // includes "/" + filename := path[lastSlash:] // includes "/" + dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…" + if dirBudget > 0 { dir := []rune(path[:lastSlash]) + if len(dir) > dirBudget { dir = dir[:dirBudget] } + path = string(dir) + "\u2026" + filename } else { path = "\u2026" + filename @@ -1810,40 +2604,54 @@ func buildArgsSnippet(toolName string, args map[string]any, workspace string) st path = utils.Truncate(path, maxPath) } } + return path } // Default: raw JSON truncated + argsJSON, _ := json.Marshal(args) + return utils.Truncate(string(argsJSON), 80) } // maxEntryLineWidth is the max rune count for a single-line log entry. + // Telegram chat bubbles on mobile are roughly 40-45 chars wide. + const maxEntryLineWidth = 42 // isFileToolEntry returns true if the entry name contains a file-operation tool. + func isFileToolEntry(name string) bool { for _, t := range []string{"read_file", "write_file", "edit_file", "append_file", "list_dir"} { if strings.Contains(name, t) { return true } } + return false } // formatCompactEntry formats a finished tool log entry as a fixed single line. + // The result marker (✓/✗) is always shown at the end regardless of truncation. + // File tools omit duration (always near-instant); paths truncate from the + // start so the filename is always visible. + func formatCompactEntry(entry toolLogEntry) string { result := entry.Result + if result == "" { result = "\u23F3" // ⏳ } // File tools: strip duration, keep only marker (✓/✗/⏳) + isFile := isFileToolEntry(entry.Name) + if isFile { if r := []rune(result); len(r) > 0 { result = string(r[0:1]) // just the symbol @@ -1851,49 +2659,74 @@ func formatCompactEntry(entry toolLogEntry) string { } // Budget for ArgsSnip: total - name - " " - " " - result + nameLen := utf8.RuneCountInString(entry.Name) + resultLen := utf8.RuneCountInString(result) + argsBudget := maxEntryLineWidth - nameLen - 1 - 1 - resultLen args := entry.ArgsSnip + if args != "" && argsBudget > 3 { argsRunes := []rune(args) + if len(argsRunes) > argsBudget { // Paths: truncate from the start, keeping the filename visible + if strings.Contains(args, "/") { args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:]) } else { args = string(argsRunes[:argsBudget-1]) + "\u2026" } } + var sb strings.Builder + sb.Grow(len(entry.Name) + 1 + len(args) + 1 + len(result)) + sb.WriteString(entry.Name) + sb.WriteByte(' ') + sb.WriteString(args) + sb.WriteByte(' ') + sb.WriteString(result) + return sb.String() } // No room for args or args empty + var sb strings.Builder + sb.Grow(len(entry.Name) + 1 + len(result)) + sb.WriteString(entry.Name) + sb.WriteByte(' ') + sb.WriteString(result) + return sb.String() } // formatLatestEntry formats the latest entry command without its result marker. + // Since the result goes on the next line, the full width is available for the command. + func formatLatestEntry(entry toolLogEntry) string { nameLen := utf8.RuneCountInString(entry.Name) + argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result) args := entry.ArgsSnip + if args != "" && argsBudget > 3 { argsRunes := []rune(args) + if len(argsRunes) > argsBudget { if strings.Contains(args, "/") { args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:]) @@ -1901,175 +2734,260 @@ func formatLatestEntry(entry toolLogEntry) string { args = string(argsRunes[:argsBudget-1]) + "\u2026" } } + var sb strings.Builder + sb.Grow(len(entry.Name) + 1 + len(args)) + sb.WriteString(entry.Name) + sb.WriteByte(' ') + sb.WriteString(args) + return sb.String() } + return entry.Name } // compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space + // characters to just 2. e.g. "======" → "==", "---" → "--". + func compressRepeats(s string) string { runes := []rune(s) + if len(runes) < 3 { return s } + var sb strings.Builder + sb.Grow(len(s)) + i := 0 + for i < len(runes) { r := runes[i] + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) { j := i + 1 + for j < len(runes) && runes[j] == r { j++ } + if j-i >= 3 { sb.WriteRune(r) + sb.WriteRune(r) + i = j + continue } } + sb.WriteRune(r) + i++ } + return sb.String() } // Display layout constants. + const ( - displayPastEntries = 4 // number of compact 1-line past entries - displayErrorLines = 5 // content lines inside the error code block - statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" + displayPastEntries = 4 // number of compact 1-line past entries + + displayErrorLines = 5 // content lines inside the error code block + + statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" + streamingDisplayLines = 17 // line count matching buildRichStatus output + ) // buildRichStatus builds a fixed-height terminal-like status display. + // + // Layout (always the same number of lines): + // + // 🔄 Task in progress (N/M) header + // 📁 workspace-path header + // ━━━━━━━━━━ separator + // [N] compact-past-1 ✓ Xs past (1 line each) + // [N] compact-past-2 ✗ Xs past + // [N] compact-past-3 ✓ Xs past + // [N] compact-past-4 ✓ Xs past + // [N] latest-command latest (no result, wider args) + // ⏳ latest result + // reserved + // ``` error fence + // err-line / placeholder error body (5 lines) + // ``` error fence + // ↩️ Reply to intervene footer (background only) + func buildRichStatus(task *activeTask, isBackground bool, workspace string) string { task.mu.Lock() + defer task.mu.Unlock() var sb strings.Builder // --- Header --- + sb.WriteString("\U0001F504 Task in progress (") + sb.WriteString(strconv.Itoa(task.Iteration)) + sb.WriteByte('/') + sb.WriteString(strconv.Itoa(task.MaxIter)) + sb.WriteString(")\n") + // Project directory: exec cd (authoritative) → file LCP → workspace basename + sb.WriteString("\U0001F4C1 ") + if dir := displayProjectDir(task); dir != "" { sb.WriteString(dir) } else if workspace != "" { project := strings.TrimRight(workspace, "/\\") + if idx := strings.LastIndex(project, "/"); idx >= 0 { project = project[idx+1:] } else if idx := strings.LastIndex(project, "\\"); idx >= 0 { project = project[idx+1:] } + sb.WriteString(project) } + sb.WriteByte('\n') + sb.WriteString(statusSeparator) // --- Task entries (displayPastEntries + 2 lines for latest) --- + entries := task.toolLog + if len(entries) > maxToolLogEntries { entries = entries[len(entries)-maxToolLogEntries:] } var pastEntries []toolLogEntry + var latest *toolLogEntry + if len(entries) > 0 { latest = &entries[len(entries)-1] + if len(entries) > 1 { start := len(entries) - 1 - displayPastEntries + if start < 0 { start = 0 } + pastEntries = entries[start : len(entries)-1] } } // Past entries: exactly displayPastEntries lines (pad if fewer) + for i := 0; i < displayPastEntries; i++ { if i < len(pastEntries) { sb.WriteString(formatCompactEntry(pastEntries[i])) } else { sb.WriteString("\u2800") } + sb.WriteByte('\n') } // Latest entry: command on one line, result on next + if latest != nil { sb.WriteString(formatLatestEntry(*latest)) + sb.WriteByte('\n') + sb.WriteString(" ") + if latest.Result != "" { sb.WriteString(latest.Result) } else { sb.WriteString("\u23F3") } + sb.WriteByte('\n') } else { sb.WriteString("\u23F3 waiting...\n") + sb.WriteString("\u2800\n") } // Reserved (1 line) + sb.WriteString("\u2800\n") // --- Error region (code fence, no separator) --- + sb.WriteString("```\n") errEntry := task.lastError + if errEntry != nil { sb.WriteString("\u274C ") + sb.WriteString(formatCompactEntry(*errEntry)) + sb.WriteByte('\n') var detailLines []string + if errEntry.ErrDetail != "" { detailLines = strings.Split(errEntry.ErrDetail, "\n") } + for i := 0; i < displayErrorLines-1; i++ { if i < len(detailLines) { line := compressRepeats(detailLines[i]) + if runes := []rune(line); len(runes) > maxEntryLineWidth { line = string(runes[:maxEntryLineWidth-1]) + "\u2026" } + sb.WriteString(line) } else { sb.WriteString("\u2800") } + sb.WriteByte('\n') } } else { sb.WriteString("\u2714 No errors\n") + for i := 0; i < displayErrorLines-1; i++ { sb.WriteString("\u2800\n") } @@ -2080,6 +2998,7 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri if isBackground { sb.WriteString("\u21A9\uFE0F Reply to intervene") } + return sb.String() } @@ -2089,347 +3008,517 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan } // Check context cancellation before attempting to publish, + // since PublishOutbound's select may race between send and ctx.Done(). + if ctx.Err() != nil { return } // Use a short timeout so the goroutine does not block indefinitely when + // the outbound bus is full. Reasoning output is best-effort; dropping it + // is acceptable to avoid goroutine accumulation. + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channelName, - ChatID: channelID, + + ChatID: channelID, + Content: reasoningContent, }); err != nil { // Treat context.DeadlineExceeded / context.Canceled as expected + // (bus full under load, or parent canceled). Check the error + // itself rather than ctx.Err(), because pubCtx may time out + // (5 s) while the parent ctx is still active. + // Also treat ErrBusClosed as expected — it occurs during normal + // shutdown when the bus is closed before all goroutines finish. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ "channel": channelName, - "error": err.Error(), + + "error": err.Error(), }) } else { logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ "channel": channelName, - "error": err.Error(), + + "error": err.Error(), }) } } } // streamingReasoningLines is the number of lines reserved for reasoning + // in the streaming display. The remaining lines go to content. + const streamingReasoningLines = 6 // buildStreamingDisplay builds a fixed-height status bubble for streaming. + // + // Layout when reasoning is active (reasoning only or both): + // + // 🧠 Thinking... + // ━━━━━━━━━━ + // <reasoning tail — streamingReasoningLines lines> + // ━━━━━━━━━━ + // <content tail — remaining lines> (or blank if content is empty) + // █ + // + // Layout when no reasoning (content only): + // + // <content tail — streamingDisplayLines lines> + // █ + func buildStreamingDisplay(content, reasoning string) string { if reasoning == "" { // No reasoning — full window for content. + return utils.TailPad(content, streamingDisplayLines, maxEntryLineWidth) + " \u2589" } var sb strings.Builder // Header + if content == "" { sb.WriteString("\U0001f9e0 Thinking...\n") } else { sb.WriteString("\U0001f9e0 Thought, now responding...\n") } + sb.WriteString(statusSeparator) // Reasoning window + headerLines := 2 // header + separator + footerLines := 1 // separator before content + contentLines := streamingDisplayLines - headerLines - footerLines - streamingReasoningLines + if contentLines < 3 { contentLines = 3 } + rLines := streamingDisplayLines - headerLines - footerLines - contentLines sb.WriteString(utils.TailPad(reasoning, rLines, maxEntryLineWidth)) + sb.WriteByte('\n') + sb.WriteString(statusSeparator) // Content window (may be blank padding if content hasn't started) + sb.WriteString(utils.TailPad(content, contentLines, maxEntryLineWidth)) + sb.WriteString(" \u2589") return sb.String() } // runLLMIteration executes the LLM call loop with tool handling. + // consumeStreamWithRepetitionDetection reads StreamEvents from ch, accumulates + // content and tool calls, and runs repetition detection every checkInterval runes. + // If repetition is detected, cancelFn is called to abort the HTTP request and + // the function returns the partial response with detected=true. + func consumeStreamWithRepetitionDetection( ch <-chan protocoltypes.StreamEvent, + cancelFn context.CancelFunc, + checkInterval int, + onChunk func(content, reasoning string), ) (*providers.LLMResponse, bool, error) { var content strings.Builder + var reasoning strings.Builder + var toolCalls []streamToolCallAcc + var finishReason string + var usage *providers.UsageInfo + runesSinceLastCheck := 0 for ev := range ch { if ev.Err != nil { return nil, false, ev.Err } + updated := false + if ev.ContentDelta != "" { content.WriteString(ev.ContentDelta) + runesSinceLastCheck += utf8.RuneCountInString(ev.ContentDelta) + updated = true } + if ev.ReasoningDelta != "" { reasoning.WriteString(ev.ReasoningDelta) + updated = true } + if updated && onChunk != nil { onChunk(content.String(), reasoning.String()) } + if ev.FinishReason != "" { finishReason = ev.FinishReason } + if ev.Usage != nil { usage = ev.Usage } + for _, tc := range ev.ToolCallDeltas { for len(toolCalls) <= tc.Index { toolCalls = append(toolCalls, streamToolCallAcc{}) } + if tc.ID != "" { toolCalls[tc.Index].id = tc.ID } + if tc.Name != "" { toolCalls[tc.Index].name = tc.Name } + toolCalls[tc.Index].args.WriteString(tc.ArgumentsDelta) } // Run repetition detection periodically on accumulated content. + if runesSinceLastCheck >= checkInterval && content.Len() > 2000 { runesSinceLastCheck = 0 + if utils.DetectRepetitionLoop(content.String()) { cancelFn() + // Drain remaining events so the producer goroutine can exit. + for range ch { } + resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage) + return resp, true, nil } } } resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage) + return resp, false, nil } // streamToolCallAcc accumulates streamed tool call fragments. + type streamToolCallAcc struct { - id string + id string + name string + args strings.Builder } // buildAccumulatedResponse constructs an LLMResponse from accumulated stream data. + func buildAccumulatedResponse( content, reasoning string, + toolCalls []streamToolCallAcc, + finishReason string, + usage *providers.UsageInfo, ) *providers.LLMResponse { resp := &providers.LLMResponse{ - Content: content, - Reasoning: reasoning, + Content: content, + + Reasoning: reasoning, + FinishReason: finishReason, - Usage: usage, + + Usage: usage, } + for _, tc := range toolCalls { arguments := make(map[string]any) + argStr := tc.args.String() + if argStr != "" { if err := json.Unmarshal([]byte(argStr), &arguments); err != nil { arguments["raw"] = argStr } } + resp.ToolCalls = append(resp.ToolCalls, providers.ToolCall{ - ID: tc.id, - Name: tc.name, + ID: tc.id, + + Name: tc.name, + Arguments: arguments, }) } + return resp } func (al *AgentLoop) runLLMIteration( ctx context.Context, + agent *AgentInstance, + messages []providers.Message, + opts processOptions, + task *activeTask, + planSnapshot string, ) (string, int, error) { iteration := 0 + var finalContent string + lastReminderIdx := -1 + planMarkNudged := false // true after we've already nudged once for [x] marking maxIter := agent.MaxIterations // Snapshot unchecked step count before tool loop so we can detect progress. + preUnchecked := -1 // -1 = not tracking + if planSnapshot == "executing" { preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") } // Determine if this is a background task (cron, heartbeat, etc.) + isBackground := opts.TaskID != "" for iteration < maxIter { iteration++ // Update active task iteration + if task != nil { task.mu.Lock() + task.Iteration = iteration + task.mu.Unlock() } // Check for user intervention via interrupt channel + if task != nil { select { case msg := <-task.interrupt: + messages = append(messages, providers.Message{ - Role: "user", + Role: "user", + Content: "[User Intervention] " + msg, }) + logger.InfoCF("agent", "User intervention injected", + map[string]any{"agent_id": agent.ID, "iteration": iteration}) + default: } } logger.DebugCF("agent", "LLM iteration", + map[string]any{ - "agent_id": agent.ID, + "agent_id": agent.ID, + "iteration": iteration, - "max": maxIter, + + "max": maxIter, }) // Build tool definitions + providerToolDefs := agent.Tools.ToProviderDefs() // Interview mode: strip tool definitions the LLM must not use, + // reducing token cost and preventing wasted reject-retry cycles. + if isPlanPreExecution(planSnapshot) { providerToolDefs = filterInterviewTools(providerToolDefs) } // Log LLM request details + logger.DebugCF("agent", "LLM request", + map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "model": agent.Model, - "messages_count": len(messages), - "tools_count": len(providerToolDefs), - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, + "agent_id": agent.ID, + + "iteration": iteration, + + "model": agent.Model, + + "messages_count": len(messages), + + "tools_count": len(providerToolDefs), + + "max_tokens": agent.MaxTokens, + + "temperature": agent.Temperature, + "system_prompt_len": len(messages[0].Content), }) // Log full messages (detailed) + logger.DebugCF("agent", "Full LLM request", + map[string]any{ - "iteration": iteration, + "iteration": iteration, + "messages_json": formatMessagesForLog(messages), - "tools_json": formatToolsForLog(providerToolDefs), + + "tools_json": formatToolsForLog(providerToolDefs), }) // Call LLM with fallback chain if candidates are configured. + var response *providers.LLMResponse + var err error // Build onChunk callback for streaming preview. + // Instead of a fixed-interval throttle, use a Go channel with + // latest-value semantics: a consumer goroutine publishes status + // updates as fast as the bus → manager → channel pipeline allows. + // Backpressure is provided naturally by the per-channel rate limiter + // (e.g. 20 msg/s for Telegram's SendDraft, 1 msg/s for Discord's EditMessage). + type streamUpdate struct{ accumulated, reasoning string } + var onChunk func(string, string) + var streamCh chan streamUpdate + var streamDone chan struct{} + if !constants.IsInternalChannel(opts.Channel) { streamCh = make(chan streamUpdate, 1) + streamDone = make(chan struct{}) + go func() { defer close(streamDone) + for up := range streamCh { display := buildStreamingDisplay(up.accumulated, up.reasoning) + outMsg := bus.OutboundMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, + + ChatID: opts.ChatID, + Content: display, } + // For background tasks, publish streaming preview as + // IsTaskStatus so it shares the same bubble as task + // progress/completion (avoids a second bubble). + if opts.Background && opts.TaskID != "" { outMsg.IsTaskStatus = true + outMsg.TaskID = opts.TaskID } else { outMsg.IsStatus = true } + _ = al.bus.PublishOutbound(ctx, outMsg) } }() + onChunk = func(accumulated, reasoning string) { if task != nil { task.streamedChunks = true } + up := streamUpdate{accumulated, reasoning} + // Non-blocking latest-value send: if the consumer hasn't + // drained the previous update, replace it with the latest. + select { case streamCh <- up: + default: + // Channel full — drain stale value, then send latest. + select { case <-streamCh: + default: } + select { case streamCh <- up: + default: } } @@ -2437,75 +3526,109 @@ func (al *AgentLoop) runLLMIteration( } // doCall invokes a single LLM provider, using streaming with + // early repetition detection when the provider supports it. + opts_ := map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, + "max_tokens": agent.MaxTokens, + + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, } + doCall := func(ctx context.Context, p providers.LLMProvider, model string) (*providers.LLMResponse, error) { if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() { streamCtx, streamCancel := context.WithCancel(ctx) + defer streamCancel() + ch, sErr := sp.ChatStream(streamCtx, messages, providerToolDefs, model, opts_) + if sErr != nil { return nil, sErr } + resp, repetition, sErr := consumeStreamWithRepetitionDetection(ch, streamCancel, 1000, onChunk) + if sErr != nil { return nil, sErr } + if repetition { resp.FinishReason = "repetition_detected" } + return resp, nil } + return p.Chat(ctx, messages, providerToolDefs, model, opts_) } callLLM := func() (*providers.LLMResponse, error) { // Plan model switching: use plan model during interviewing/review phases + candidates := agent.Candidates + primaryModel := agent.Model + if isPlanPreExecution(planSnapshot) && agent.PlanModel != "" { candidates = agent.PlanCandidates + primaryModel = agent.PlanModel + logger.InfoCF("agent", "Using plan model", + map[string]any{"agent_id": agent.ID, "plan_model": agent.PlanModel}) } if len(candidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute(ctx, candidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { p := al.resolveProvider(provider, model, agent.Provider) + return doCall(ctx, p, model) }, ) + if fbErr != nil { return nil, fbErr } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": agent.ID, "iteration": iteration}) } + return fbResult.Response, nil } + if len(candidates) > 0 { c := candidates[0] + p := al.resolveProvider(c.Provider, c.Model, agent.Provider) + return doCall(ctx, p, c.Model) } + return doCall(ctx, agent.Provider, primaryModel) } // Report waiting state to canvas before each LLM call. + al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "") // Retry loop for context/token errors + maxRetries := 2 + for retry := 0; retry <= maxRetries; retry++ { response, err = callLLM() + if err == nil { break } @@ -2513,127 +3636,195 @@ func (al *AgentLoop) runLLMIteration( errMsg := strings.ToLower(err.Error()) // Check if this is a network/HTTP timeout — not a context window error. + isTimeoutError := errors.Is(err, context.DeadlineExceeded) || + strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") // Detect real context window / token limit errors, excluding network timeouts. + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) if isTimeoutError && retry < maxRetries { backoff := time.Duration(retry+1) * 5 * time.Second + logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ - "error": err.Error(), - "retry": retry, + "error": err.Error(), + + "retry": retry, + "backoff": backoff.String(), }) + time.Sleep(backoff) + continue } if isContextError && retry < maxRetries { logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ "error": err.Error(), + "retry": retry, }) if retry == 0 && !constants.IsInternalChannel(opts.Channel) { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, + + ChatID: opts.ChatID, + Content: "Context window exceeded. Compressing history and retrying...", }) } al.forceCompression(agent, opts.SessionKey) + newHistory := agent.Sessions.GetHistory(opts.SessionKey) + newSummary := agent.Sessions.GetSummary(opts.SessionKey) + messages = agent.ContextBuilder.BuildMessages( + newHistory, newSummary, "", + nil, opts.Channel, opts.ChatID, ) + continue } + break } // Streaming finished — close the stream goroutine so it flushes + // the last update and exits cleanly before we process the response. + if streamDone != nil { // onChunk is captured by doCall closures; nil it to avoid + // writes after the channel is closed during retries. + onChunk = nil + close(streamCh) + <-streamDone + streamDone = nil } if err != nil { logger.ErrorCF("agent", "LLM call failed", + map[string]any{ - "agent_id": agent.ID, + "agent_id": agent.ID, + "iteration": iteration, - "error": err.Error(), + + "error": err.Error(), }) + return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } // Record token usage + if response.Usage != nil && al.stats != nil { al.stats.RecordUsage( + response.Usage.PromptTokens, + response.Usage.CompletionTokens, + response.Usage.TotalTokens, ) } // Handle reasoning output (best-effort, non-blocking) + go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) logger.DebugCF("agent", "LLM response", + map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "content_chars": len(response.Content), - "tool_calls": len(response.ToolCalls), - "reasoning": response.Reasoning, + "agent_id": agent.ID, + + "iteration": iteration, + + "content_chars": len(response.Content), + + "tool_calls": len(response.ToolCalls), + + "reasoning": response.Reasoning, + "target_channel": al.targetReasoningChannelID(opts.Channel), - "channel": opts.Channel, + + "channel": opts.Channel, }) // Detect repetition loop on raw text (before stripping think + // blocks so loops inside <think> are caught). Skip when the + // provider already returned native tool calls. + // Streaming providers may have already flagged repetition via + // FinishReason="repetition_detected" — honor that too. + if response.FinishReason == "repetition_detected" || + (len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content)) { logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying", + map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "finish_reason": response.FinishReason, + "agent_id": agent.ID, + + "iteration": iteration, + + "finish_reason": response.FinishReason, + "content_length": len(response.Content), }) // Retry once: inject nudge message and re-call + savedMsgs := messages + messages = append(append([]providers.Message(nil), messages...), + providers.Message{ - Role: "user", + Role: "user", + Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.", }) + response, err = callLLM() + messages = savedMsgs // restore original messages if err != nil { @@ -2641,84 +3832,125 @@ func (al *AgentLoop) runLLMIteration( } // Re-check on raw text; if still repeating give up + if utils.DetectRepetitionLoop(response.Content) { logger.ErrorCF("agent", "Repetition persists after retry, returning empty", + map[string]any{"agent_id": agent.ID}) + response.Content = "" } } // Strip think blocks before extracting XML tool calls so + // extraction operates on clean content. + response.Content = utils.StripThinkBlocks(response.Content) // Recover XML tool calls emitted as plain text by some providers. + if len(response.ToolCalls) == 0 { if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 { response.ToolCalls = xmlCalls } } + response.Content = providers.StripXMLToolCalls(response.Content) // Check if no tool calls - we're done + if len(response.ToolCalls) == 0 { // Plan continuation: if unchecked steps remain, nudge the LLM to + // either mark completed steps or continue working on them. + // This fires for both foreground and background plan execution, + // ensuring the loop doesn't exit prematurely after marking a step. + curUnchecked := 0 + if preUnchecked > 0 { curUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") } + if curUnchecked > 0 && !planMarkNudged && + planSnapshot == "executing" { planMarkNudged = true + messages = append(messages, providers.Message{ - Role: "assistant", + Role: "assistant", + Content: response.Content, }) + var nudgeMsg string + if curUnchecked == preUnchecked { nudgeMsg = fmt.Sprintf("[System] %d unchecked steps remain in MEMORY.md and "+ + "none were marked [x] during this session. "+ + "If you completed any steps, use edit_file to mark them [x] now. "+ + "If steps are still in progress, continue working on them.", curUnchecked) } else { nudgeMsg = fmt.Sprintf("[System] Progress recorded. %d unchecked steps remain. "+ + "Continue working on the next step.", curUnchecked) } + messages = append(messages, providers.Message{ - Role: "user", + Role: "user", + Content: nudgeMsg, }) + logger.InfoCF("agent", "Nudging plan execution: continue plan steps", + map[string]any{"agent_id": agent.ID, "iteration": iteration, "unchecked": curUnchecked}) + continue } finalContent = response.Content + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, + "agent_id": agent.ID, + + "iteration": iteration, + "content_chars": len(finalContent), }) + break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) + for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } // --- Interview mode: reject disallowed tool calls before they + // enter messages or session history. Rejected calls are stripped + // from normalizedToolCalls so they never reach the assistant + // message, the tool-result list, or the session store. + // A single compact rejection message is injected instead. + var interviewRejected []string + if isPlanPreExecution(planSnapshot) { allowed := normalizedToolCalls[:0] // reuse backing array + for _, tc := range normalizedToolCalls { if isToolAllowedDuringInterview(tc.Name, tc.Arguments) { allowed = append(allowed, tc) @@ -2726,53 +3958,77 @@ func (al *AgentLoop) runLLMIteration( interviewRejected = append(interviewRejected, tc.Name) } } + normalizedToolCalls = allowed + if len(interviewRejected) > 0 { logger.InfoCF("agent", "Interview mode: rejected tool calls", + map[string]any{ "agent_id": agent.ID, + "rejected": interviewRejected, }) + messages = append(messages, providers.Message{ - Role: "user", + Role: "user", + Content: interviewRejectMessage, }) } + // If all tool calls were rejected, skip to next iteration. + if len(normalizedToolCalls) == 0 { continue } } // Log tool calls + toolNames := make([]string, 0, len(normalizedToolCalls)) + for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } + logger.InfoCF("agent", "LLM requested tool calls", + map[string]any{ - "agent_id": agent.ID, - "tools": toolNames, - "count": len(normalizedToolCalls), + "agent_id": agent.ID, + + "tools": toolNames, + + "count": len(normalizedToolCalls), + "iteration": iteration, }) // Publish rich status update + if !constants.IsInternalChannel(opts.Channel) && task != nil { // Add pending entries to tool log for the current tool calls + task.mu.Lock() + for _, tc := range normalizedToolCalls { task.toolLog = append(task.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] %s", iteration, tc.Name), + Name: fmt.Sprintf("[%d] %s", iteration, tc.Name), + ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace), - Result: "\u23F3", + + Result: "\u23F3", }) + // Detect project directory + if task.projectDir == "" && tc.Name == "exec" { task.projectDir = extractExecProjectDir(tc.Arguments) } + switch tc.Name { case "read_file", "write_file", "edit_file", "append_file", "list_dir": + if p, _ := tc.Arguments["path"].(string); p != "" { if rel := fileParentRelDir(p, agent.Workspace); rel != "" { if task.fileCommonDir == "" { @@ -2784,333 +4040,519 @@ func (al *AgentLoop) runLLMIteration( } } } + task.mu.Unlock() statusContent := buildRichStatus(task, isBackground, agent.Workspace) + if isBackground { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: statusContent, + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: statusContent, + IsTaskStatus: true, - TaskID: opts.TaskID, + + TaskID: opts.TaskID, }) } else { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: statusContent, + Channel: opts.Channel, + + ChatID: opts.ChatID, + + Content: statusContent, + IsStatus: true, }) } } // Record session activity for heartbeat/plan coordination + for _, tc := range normalizedToolCalls { var detectedDir string + if tc.Name == "exec" { detectedDir = extractExecProjectDir(tc.Arguments) } + if detectedDir == "" { switch tc.Name { case "read_file", "write_file", "edit_file", "append_file", "list_dir": + if p, _ := tc.Arguments["path"].(string); p != "" { detectedDir = fileParentRelDir(p, agent.Workspace) } } } + if detectedDir != "" { meta := &TouchMeta{ ProjectPath: agent.ContextBuilder.GetPlanWorkDir(), - Purpose: utils.Truncate(opts.UserMessage, 80), - Branch: agent.GetWorktreeBranch(opts.SessionKey), + + Purpose: utils.Truncate(opts.UserMessage, 80), + + Branch: agent.GetWorktreeBranch(opts.SessionKey), } + if meta.ProjectPath == "" { meta.ProjectPath = agent.Workspace } + al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta) } } // Build assistant message with tool calls + assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, + Role: "assistant", + + Content: response.Content, + ReasoningContent: response.ReasoningContent, } + for _, tc := range normalizedToolCalls { // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 + extraContent := tc.ExtraContent + thoughtSignature := "" + if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature } assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Name: tc.Name, + ID: tc.ID, + + Type: "function", + + Name: tc.Name, + Arguments: tc.Arguments, + Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: tc.Arguments, + Name: tc.Name, + + Arguments: tc.Arguments, + ThoughtSignature: thoughtSignature, }, - ExtraContent: extraContent, + + ExtraContent: extraContent, + ThoughtSignature: thoughtSignature, }) } + messages = append(messages, assistantMsg) // Save assistant message with tool calls to session + agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) // Execute tool calls + var lastBlocker string + for tcIdx, tc := range normalizedToolCalls { argsJSON, _ := json.Marshal(tc.Arguments) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + map[string]any{ - "agent_id": agent.ID, - "tool": tc.Name, + "agent_id": agent.ID, + + "tool": tc.Name, + "iteration": iteration, }) // Heartbeat lazy worktree: create worktree on first write-tool call + if opts.Background && isWriteTool(tc.Name) && !agent.IsInWorktree(opts.SessionKey) { taskName := "heartbeat-" + time.Now().Format("20060102") + hbDir := agent.ContextBuilder.GetPlanWorkDir() + if wt, err := agent.ActivateWorktree(opts.SessionKey, taskName, hbDir); err == nil { logger.InfoCF("agent", "Heartbeat worktree created", map[string]any{"branch": wt.Branch}) } } // Create async callback for tools that implement AsyncTool. + // The callback publishes a system inbound message so processSystemMessage + // injects the result into the conductor's session history. The conductor + // sees it on its next turn and decides whether to notify the user. + toolName := tc.Name // capture for goroutine + asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { content := result.ForLLM + if content == "" { content = result.ForUser } + if content == "" { return } logger.InfoCF("agent", "Async tool completed, publishing to conductor", + map[string]any{ - "tool": toolName, + "tool": toolName, + "content_len": len(content), - "is_error": result.IsError, + + "is_error": result.IsError, }) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Channel: "system", + Channel: "system", + SenderID: fmt.Sprintf("async:%s", toolName), - ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID), - Content: fmt.Sprintf("Async tool '%s' completed.\n\nResult:\n%s", toolName, content), + + ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID), + + Content: fmt.Sprintf("Async tool '%s' completed.\n\nResult:\n%s", toolName, content), }) } // Report toolcall state to canvas. + al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name) toolStart := time.Now() + toolCtx := ctx + if wt := agent.GetWorktree(opts.SessionKey); wt != nil { toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path) + toolCtx = tools.WithWorktreeInfo(toolCtx, wt) } + toolResult := agent.Tools.ExecuteWithContext( + toolCtx, + tc.Name, + tc.Arguments, + opts.Channel, + opts.ChatID, + asyncCallback, ) + toolDuration := time.Since(toolStart) // Update tool log entry with result + if task != nil { task.mu.Lock() + // Find the matching pending entry (added earlier in this iteration) + logIdx := len(task.toolLog) - len(normalizedToolCalls) + tcIdx + if logIdx >= 0 && logIdx < len(task.toolLog) { if toolResult.IsError || toolResult.Err != nil { task.toolLog[logIdx].Result = fmt.Sprintf("\u2717 %.1fs", toolDuration.Seconds()) + // Extract error detail for block display + if toolResult.Err != nil { task.toolLog[logIdx].ErrDetail = utils.Truncate(toolResult.Err.Error(), 300) } else if toolResult.ForLLM != "" { // exec returns IsError with exit info in ForLLM, not Err + // Show last few lines (stderr / exit code) + lines := strings.Split(strings.TrimSpace(toolResult.ForLLM), "\n") + start := len(lines) - 3 + if start < 0 { start = 0 } + task.toolLog[logIdx].ErrDetail = utils.Truncate( + strings.Join(lines[start:], "\n"), 300) } + // Sticky error: remember most recent error for persistent display + entry := task.toolLog[logIdx] + task.lastError = &entry } else { task.toolLog[logIdx].Result = fmt.Sprintf("\u2713 %.1fs", toolDuration.Seconds()) } } + task.mu.Unlock() } // Send ForUser content to user immediately if not Silent + if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, + + ChatID: opts.ChatID, + Content: toolResult.ForUser, }) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{ - "tool": tc.Name, + "tool": tc.Name, + "content_len": len(toolResult.ForUser), }) } // If tool returned media refs, publish them as outbound media + if len(toolResult.Media) > 0 && opts.SendResponse { parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { part := bus.MediaPart{Ref: ref} + // Populate metadata from MediaStore when available + if al.mediaStore != nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) } } + parts = append(parts, part) } + al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ Channel: opts.Channel, - ChatID: opts.ChatID, - Parts: parts, + + ChatID: opts.ChatID, + + Parts: parts, }) } // Determine content for LLM based on tool result + contentForLLM := toolResult.ForLLM + if contentForLLM == "" && toolResult.Err != nil { contentForLLM = toolResult.Err.Error() } // Track blockers for task reminder + if toolResult.IsError || toolResult.Err != nil { lastBlocker = contentForLLM } toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, + Role: "tool", + + Content: contentForLLM, + ToolCallID: tc.ID, } + messages = append(messages, toolResultMsg) // Save tool result message to session + agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) } // Trim tool log sliding window to prevent unbounded growth + if task != nil { task.mu.Lock() + if len(task.toolLog) > maxToolLogEntries { task.toolLog = task.toolLog[len(task.toolLog)-maxToolLogEntries:] } + task.mu.Unlock() } // Inject ephemeral task reminder to prevent focus drift. + // Remove previous reminder and re-append at the tail so it stays + // close to the LLM's attention window. + if shouldInjectReminder(iteration, agent.TaskReminderInterval) && !opts.NoHistory { if lastReminderIdx >= 0 && lastReminderIdx < len(messages) { messages = append(messages[:lastReminderIdx], messages[lastReminderIdx+1:]...) } + reminderMsg := buildTaskReminder(opts.UserMessage, lastBlocker) + messages = append(messages, reminderMsg) + lastReminderIdx = len(messages) - 1 + logger.DebugCF("agent", "Injected task reminder", + map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, + "agent_id": agent.ID, + + "iteration": iteration, + "has_blocker": lastBlocker != "", }) } // Inject plan-mode reminder to keep AI focused on interview/review workflow. + if iteration > 1 && isPlanPreExecution(planSnapshot) { if reminder, ok := buildPlanReminder(planSnapshot); ok { messages = append(messages, reminder) + logger.DebugCF("agent", "Injected plan reminder", + map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, + "agent_id": agent.ID, + + "iteration": iteration, + "plan_status": planSnapshot, }) } } // Inject orchestration nudge during plan execution to encourage spawn usage. + if planSnapshot == "executing" && agent.Subagents != nil && agent.Subagents.Enabled { if reminder, ok := buildOrchReminder(iteration); ok { messages = append(messages, reminder) + logger.DebugCF("agent", "Injected orchestration nudge", + map[string]any{ - "agent_id": agent.ID, + "agent_id": agent.ID, + "iteration": iteration, }) } } + // Inject pending subagent questions/plan reviews for the conductor to answer. + + if agent.SubagentMgr != nil { + for _, q := range agent.SubagentMgr.PendingQuestions() { + var content string + + switch q.Type { + case "plan_review": + + content = fmt.Sprintf( + "[Subagent %s submitted a plan for review]:\n%s\nRespond using the review_subagent_plan tool with task_id=%q.", + q.TaskID, + q.Content, + q.TaskID, + ) + + default: + + content = fmt.Sprintf( + "[Subagent %s asks]: %s\nRespond using the answer_subagent tool with task_id=%q.", + q.TaskID, + q.Content, + q.TaskID, + ) + } + + messages = append(messages, providers.Message{ + Role: "user", + + Content: content, + }) + } + } + // Refresh system prompt: tool execution may have changed workDir, + // memory, plan status, etc. Update messages[0] so the next LLM + // call sees the current state. + if touchDir := al.sessions.GetTouchDir(opts.SessionKey); touchDir != "" { agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, touchDir)) } + if newPrompt := agent.ContextBuilder.BuildSystemPrompt(); len(messages) > 0 && + messages[0].Content != newPrompt { messages[0].Content = newPrompt + al.lastSystemPrompt.Store(newPrompt) + al.promptDirty.Store(false) } } // If max iterations exhausted with tool calls still pending, + // make one final LLM call without tools to force a text response. + if finalContent == "" && iteration >= maxIter { logger.WarnCF("agent", "Max iterations reached, forcing final response without tools", + map[string]any{ - "agent_id": agent.ID, + "agent_id": agent.ID, + "iteration": iteration, }) + forceResp, forceErr := agent.Provider.Chat(ctx, messages, nil, agent.Model, map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, + "max_tokens": agent.MaxTokens, + + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, }) + if forceErr == nil && forceResp.Content != "" { finalContent = utils.StripThinkBlocks(forceResp.Content) + if forceResp.Usage != nil && al.stats != nil { al.stats.RecordUsage( + forceResp.Usage.PromptTokens, + forceResp.Usage.CompletionTokens, + forceResp.Usage.TotalTokens, ) } @@ -3121,18 +4563,22 @@ func (al *AgentLoop) runLLMIteration( } // updateToolContexts updates the context for tools that need channel/chatID info. + func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) { // Use ContextualTool interface instead of type assertions + if tool, ok := agent.Tools.Get("message"); ok { if mt, ok := tool.(tools.ContextualTool); ok { mt.SetContext(channel, chatID) } } + if tool, ok := agent.Tools.Get("spawn"); ok { if st, ok := tool.(tools.ContextualTool); ok { st.SetContext(channel, chatID) } } + if tool, ok := agent.Tools.Get("subagent"); ok { if st, ok := tool.(tools.ContextualTool); ok { st.SetContext(channel, chatID) @@ -3141,22 +4587,31 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st } // maybeSummarize triggers summarization if the session history exceeds thresholds. + func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) + tokenEstimate := al.estimateTokens(newHistory) + threshold := agent.ContextWindow * 75 / 100 if len(newHistory) > 20 || tokenEstimate > threshold { summarizeKey := agent.ID + ":" + sessionKey + if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { defer al.summarizing.Delete(summarizeKey) + logger.InfoCF("agent", "Memory threshold reached, optimizing conversation history", + map[string]any{ - "session_key": sessionKey, - "history_len": len(newHistory), + "session_key": sessionKey, + + "history_len": len(newHistory), + "token_estimate": tokenEstimate, }) + al.summarizeSession(agent, sessionKey) }() } @@ -3164,260 +4619,365 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c } // forceCompression aggressively reduces context when the limit is hit. + // It drops the oldest 50% of messages (keeping system prompt and last user message). + func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { history := agent.Sessions.GetHistory(sessionKey) + if len(history) <= 4 { return } // Keep system prompt (usually [0]) and the very last message (user's trigger) + // We want to drop the oldest half of the *conversation* + // Assuming [0] is system, [1:] is conversation + conversation := history[1 : len(history)-1] + if len(conversation) == 0 { return } // Helper to find the mid-point of the conversation + mid := len(conversation) / 2 // New history structure: + // 1. System Prompt (with compression note appended) + // 2. Second half of conversation + // 3. Last message droppedCount := mid + keptConversation := conversation[mid:] newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1) // Append compression note to the original system prompt instead of adding a new system message + // This avoids having two consecutive system messages which some APIs (like Zhipu) reject + compressionNote := fmt.Sprintf( + "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, ) + enhancedSystemPrompt := history[0] + enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote + newHistory = append(newHistory, enhancedSystemPrompt) newHistory = append(newHistory, keptConversation...) + newHistory = append(newHistory, history[len(history)-1]) // Last message // Update session + agent.Sessions.SetHistory(sessionKey, newHistory) + agent.Sessions.Save(sessionKey) logger.WarnCF("agent", "Forced compression executed", map[string]any{ - "session_key": sessionKey, + "session_key": sessionKey, + "dropped_msgs": droppedCount, - "new_count": len(newHistory), + + "new_count": len(newHistory), }) } // GetStartupInfo returns information about loaded tools and skills for logging. + func (al *AgentLoop) GetStartupInfo() map[string]any { info := make(map[string]any) agent := al.registry.GetDefaultAgent() + if agent == nil { return info } // Tools info + toolsList := agent.Tools.List() + toolsMap := map[string]any{ "count": len(toolsList), + "names": toolsList, } + // Report web search provider if registered + if t, ok := agent.Tools.Get("web_search"); ok { if wst, ok := t.(*tools.WebSearchTool); ok { toolsMap["web_search_provider"] = wst.ProviderName() } } + info["tools"] = toolsMap // Skills info + info["skills"] = agent.ContextBuilder.GetSkillsInfo() // Agents info + info["agents"] = map[string]any{ "count": len(al.registry.ListAgentIDs()), - "ids": al.registry.ListAgentIDs(), + + "ids": al.registry.ListAgentIDs(), } return info } // ListSkills returns all available skills from the default agent. + func (al *AgentLoop) ListSkills() []skills.SkillInfo { agent := al.registry.GetDefaultAgent() + if agent == nil { return nil } + return agent.ContextBuilder.ListSkills() } // GetPlanInfo returns plan state from the default agent's memory store. + func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) { agent := al.registry.GetDefaultAgent() + if agent == nil { return false, "", 0, 0, "No agent available.", "" } + mem := agent.ContextBuilder.Memory() + if mem == nil { return false, "", 0, 0, "No memory store.", "" } + hasPlan = mem.HasActivePlan() + status = mem.GetPlanStatus() + currentPhase = mem.GetCurrentPhase() + totalPhases = mem.GetTotalPhases() + display = mem.FormatPlanDisplay() + memory = mem.ReadLongTerm() + return hasPlan, status, currentPhase, totalPhases, display, memory } // GetPlanStatus returns the current plan status ("interviewing", "executing", "review", etc.) or "". + func (al *AgentLoop) GetPlanStatus() string { agent := al.registry.GetDefaultAgent() + if agent == nil { return "" } + return agent.ContextBuilder.GetPlanStatus() } // GetPlanPhases returns structured phase/step data from the default agent's plan. + func (al *AgentLoop) GetPlanPhases() []PlanPhase { agent := al.registry.GetDefaultAgent() + if agent == nil { return nil } + mem := agent.ContextBuilder.Memory() + if mem == nil { return nil } + return mem.GetPlanPhases() } // GetActiveSessions returns currently active sessions for the mini app API. + func (al *AgentLoop) GetActiveSessions() []SessionEntry { return al.sessions.ListActive() } // GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled. + func (al *AgentLoop) GetSessionStats() *stats.Stats { if al.stats == nil { return nil } + s := al.stats.GetStats() + return &s } // GetContextInfo returns the bootstrap file resolution and directory context for the default agent. + func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) { agent := al.registry.GetDefaultAgent() + if agent == nil { return "", "", "", nil } + workspace = agent.Workspace + planWorkDir = agent.ContextBuilder.GetPlanWorkDir() + // Use the most recent active session's touch_dir (tool-detected project directory) + if active := al.sessions.ListActive(); len(active) > 0 && active[0].TouchDir != "" { workDir = active[0].TouchDir } else { workDir = agent.ContextBuilder.workDir } + bootstrap = agent.ContextBuilder.ResolveBootstrapPaths() + return workDir, planWorkDir, workspace, bootstrap } // GetSystemPrompt returns the system prompt last sent to the LLM. + // If the prompt is dirty (state changed since last capture), it rebuilds + // from current state. Falls back to building if no LLM call has occurred yet. + func (al *AgentLoop) GetSystemPrompt() string { if !al.promptDirty.Load() { if v := al.lastSystemPrompt.Load(); v != nil { return v.(string) } } + // Rebuild from current state + agent := al.registry.GetDefaultAgent() + if agent == nil { return "" } + prompt := agent.ContextBuilder.BuildSystemPrompt() + al.lastSystemPrompt.Store(prompt) + al.promptDirty.Store(false) + return prompt } // formatMessagesForLog formats messages for logging + func formatMessagesForLog(messages []providers.Message) string { if len(messages) == 0 { return "[]" } var sb strings.Builder + sb.WriteString("[\n") + for i, msg := range messages { fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) + if len(msg.ToolCalls) > 0 { sb.WriteString(" ToolCalls:\n") + for _, tc := range msg.ToolCalls { fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) + args := tc.Arguments + if len(args) == 0 && tc.Function != nil { args = tc.Function.Arguments } + if len(args) > 0 { argsJSON, _ := json.Marshal(args) + fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 200)) } } } + if msg.Content != "" { content := utils.Truncate(msg.Content, 200) + fmt.Fprintf(&sb, " Content: %s\n", content) } + if msg.ToolCallID != "" { fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) } + sb.WriteString("\n") } + sb.WriteString("]") + return sb.String() } // formatToolsForLog formats tool definitions for logging + func formatToolsForLog(toolDefs []providers.ToolDefinition) string { if len(toolDefs) == 0 { return "[]" } var sb strings.Builder + sb.WriteString("[\n") + for i, tool := range toolDefs { fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) + fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) + if len(tool.Function.Parameters) > 0 { fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(string(tool.Function.Parameters), 200)) } } + sb.WriteString("]") + return sb.String() } // summarizeSession summarizes the conversation history for a session. + func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() history := agent.Sessions.GetHistory(sessionKey) + summary := agent.Sessions.GetSummary(sessionKey) // Keep last 4 messages for continuity + if len(history) <= 4 { return } @@ -3425,19 +4985,26 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { toSummarize := history[:len(history)-4] // Oversized Message Guard + maxMessageTokens := agent.ContextWindow / 2 + validMessages := make([]providers.Message, 0) + omitted := false for _, m := range toSummarize { if m.Role != "user" && m.Role != "assistant" { continue } + msgTokens := len(m.Content) / 2 + if msgTokens > maxMessageTokens { omitted = true + continue } + validMessages = append(validMessages, m) } @@ -3446,31 +5013,48 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } // Multi-Part Summarization + var finalSummary string + if len(validMessages) > 10 { mid := len(validMessages) / 2 + part1 := validMessages[:mid] + part2 := validMessages[mid:] s1, _ := al.summarizeBatch(ctx, agent, part1, "") + s2, _ := al.summarizeBatch(ctx, agent, part2, "") mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, + s2, ) + resp, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: mergePrompt}}, + nil, + agent.Model, + map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, + "max_tokens": 1024, + + "temperature": 0.3, + "prompt_cache_key": agent.ID, }, ) + if err == nil { finalSummary = resp.Content } else { @@ -3487,170 +5071,250 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { if finalSummary != "" { if err := agent.Sessions.CompactOldTurns(sessionKey, 4, finalSummary); err != nil { logger.ErrorCF("agent", "CompactOldTurns failed, falling back", + map[string]any{"error": err.Error()}) + agent.Sessions.SetSummary(sessionKey, finalSummary) + agent.Sessions.TruncateHistory(sessionKey, 4) + agent.Sessions.Save(sessionKey) } } } // summarizeBatch summarizes a batch of messages. + func (al *AgentLoop) summarizeBatch( ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + existingSummary string, ) (string, error) { var sb strings.Builder + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") + if agent.ContextBuilder.HasActivePlan() { sb.WriteString("Note: Active plan in MEMORY.md. Preserve plan progress references.\n") } + if existingSummary != "" { sb.WriteString("Existing context: ") + sb.WriteString(existingSummary) + sb.WriteString("\n") } + sb.WriteString("\nCONVERSATION:\n") + for _, m := range batch { fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) } + prompt := sb.String() response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, + "max_tokens": 1024, + + "temperature": 0.3, + "prompt_cache_key": agent.ID, }, ) if err != nil { return "", err } + return response.Content, nil } // estimateTokens estimates the number of tokens in a message list. + // Uses a safe heuristic of 2.5 characters per token to account for CJK and other + // overheads better than the previous 3 chars/token. + func (al *AgentLoop) estimateTokens(messages []providers.Message) int { totalChars := 0 + for _, m := range messages { totalChars += utf8.RuneCountInString(m.Content) } + // 2.5 chars per token = totalChars * 2 / 5 + return totalChars * 2 / 5 } func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "/") { return "", false } parts := strings.Fields(content) + if len(parts) == 0 { return "", false } cmd := parts[0] + args := parts[1:] switch cmd { case "/show": + if len(args) < 1 { return "Usage: /show [model|channel|agents]", true } + switch args[0] { case "model": + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { return "No default agent configured", true } + return fmt.Sprintf("Current model: %s", defaultAgent.Model), true + case "channel": + return fmt.Sprintf("Current channel: %s", msg.Channel), true + case "agents": + agentIDs := al.registry.ListAgentIDs() + return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true + default: + return fmt.Sprintf("Unknown show target: %s", args[0]), true } case "/list": + if len(args) < 1 { return "Usage: /list [models|channels|agents]", true } + switch args[0] { case "models": + return "Available models: configured in config.json per agent", true + case "channels": + if al.channelManager == nil { return "Channel manager not initialized", true } + channels := al.channelManager.GetEnabledChannels() + if len(channels) == 0 { return "No channels enabled", true } + return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true + case "agents": + agentIDs := al.registry.ListAgentIDs() + return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true + default: + return fmt.Sprintf("Unknown list target: %s", args[0]), true } case "/switch": + if len(args) < 3 || args[1] != "to" { return "Usage: /switch [model|channel] to <name>", true } + target := args[0] + value := args[2] switch target { case "model": + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { return "No default agent configured", true } + oldModel := defaultAgent.Model + defaultAgent.Model = value + return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true + case "channel": + if al.channelManager == nil { return "Channel manager not initialized", true } + if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { return fmt.Sprintf("Channel '%s' not found or not enabled", value), true } + return fmt.Sprintf("Switched target channel to %s", value), true + default: + return fmt.Sprintf("Unknown switch target: %s", target), true } case "/session": + return al.handleSessionCommand(args, msg.SessionKey), true case "/skills": + return al.handleSkillsCommand(), true case "/plan": + resp, handled := al.handlePlanCommand(args, msg.SessionKey) + if handled { al.notifyStateChange() } + return resp, handled case "/heartbeat": + resp, handled := al.handleHeartbeatCommand(args, msg) + if handled { al.notifyStateChange() } + return resp, handled } @@ -3675,33 +5339,45 @@ func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessag } baseChatID, currentThreadID := splitChatAndThread(msg.ChatID) + if baseChatID == "" { return "Unable to detect Telegram chat ID for heartbeat routing.", true } arg := strings.ToLower(strings.TrimSpace(args[1])) + var threadID int + var err error switch arg { case "off", "disable", "clear": + threadID = 0 + case "here", "this": + if currentThreadID <= 0 { return "Current Telegram message is not in a thread. Usage: /heartbeat thread <thread_id>", true } + threadID = currentThreadID + default: + threadID, err = strconv.Atoi(arg) + if err != nil || threadID < 0 { return "Usage: /heartbeat thread [here|off|<thread_id>]", true } } al.cfg.Channels.Telegram.HeartbeatThreadID = threadID + if al.state != nil { _ = al.state.SetHeartbeatTarget(fmt.Sprintf("telegram:%s", baseChatID)) } + if al.onHeartbeatThreadUpdate != nil { al.onHeartbeatThreadUpdate(threadID) } @@ -3715,148 +5391,219 @@ func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessag if threadID == 0 { return fmt.Sprintf("Heartbeat thread routing disabled for chat %s and saved to config.json.", baseChatID), true } + return fmt.Sprintf("Heartbeat thread set to %d for chat %s and saved to config.json.", threadID, baseChatID), true } func splitChatAndThread(chatID string) (baseChatID string, threadID int) { baseChatID = strings.TrimSpace(chatID) + if baseChatID == "" { return "", 0 } + if slash := strings.Index(baseChatID, "/"); slash >= 0 { threadPart := strings.TrimSpace(baseChatID[slash+1:]) + baseChatID = strings.TrimSpace(baseChatID[:slash]) + if tid, err := strconv.Atoi(threadPart); err == nil && tid > 0 { threadID = tid } } + return baseChatID, threadID } // handleSessionCommand dispatches /session subcommands. + func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string { sub := "" + if len(args) > 0 { sub = strings.ToLower(strings.TrimSpace(args[0])) } + switch sub { case "list": + return al.handleSessionList() + case "graph": + return al.handleSessionGraph() + case "fork": + return al.handleSessionFork(args[1:], sessionKey) + case "reset": + if al.stats == nil { return "Stats tracking is disabled." } + al.stats.Reset() + return "Session statistics have been reset." + default: + return al.handleSessionStats() } } func (al *AgentLoop) handleSessionStats() string { agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() // Session DAG summary + sessions, _ := store.List(nil) + var sb strings.Builder + fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions)) + if len(sessions) > 0 { active, completed := 0, 0 + for _, s := range sessions { switch s.Status { case "active": + active++ + case "completed": + completed++ } } + fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed) } + sb.WriteString("\nUse: /session list | graph | fork [label]\n") // Token stats if available + if al.stats != nil { s := al.stats.GetStats() + fmt.Fprintf(&sb, + "\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+ + "All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)", + s.Today.Date, + s.Today.Prompts, + s.Today.Requests, + stats.FormatTokenCount(s.Today.TotalTokens), + stats.FormatTokenCount(s.Today.PromptTokens), + stats.FormatTokenCount(s.Today.CompletionTokens), + s.Since.Format("2006-01-02"), + s.TotalPrompts, + s.TotalRequests, + stats.FormatTokenCount(s.TotalTokens), + stats.FormatTokenCount(s.TotalPromptTokens), + stats.FormatTokenCount(s.TotalCompletionTokens), ) } + return sb.String() } // shortSessionKey truncates long session keys for display. + func shortSessionKey(key string) string { parts := strings.Split(key, ":") + if len(parts) > 2 { return strings.Join(parts[2:], ":") } + return key } func (al *AgentLoop) handleSessionList() string { agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + sessions, err := store.List(nil) if err != nil { return fmt.Sprintf("Error listing sessions: %v", err) } + if len(sessions) == 0 { return "No sessions in store." } var sb strings.Builder + fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions)) + for _, s := range sessions { age := time.Since(s.UpdatedAt).Truncate(time.Second) + label := s.Label + if label == "" { label = shortSessionKey(s.Key) } + parent := "" + if s.ParentKey != "" { parent = " parent=" + shortSessionKey(s.ParentKey) } + fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n", + label, s.Status, age, s.TurnCount, parent) } + return sb.String() } func (al *AgentLoop) handleSessionGraph() string { agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + sessions, err := store.List(nil) if err != nil { return fmt.Sprintf("Error listing sessions: %v", err) } + if len(sessions) == 0 { return "No sessions in store." } // Build parent→children map and find roots + byKey := make(map[string]*session.SessionInfo, len(sessions)) + children := make(map[string][]string) + var roots []string + for _, s := range sessions { byKey[s.Key] = s + if s.ParentKey == "" { roots = append(roots, s.Key) } else { @@ -3865,39 +5612,60 @@ func (al *AgentLoop) handleSessionGraph() string { } var sb strings.Builder + sb.WriteString("Session Graph\n") + for i, root := range roots { last := i == len(roots)-1 + printSessionTree(&sb, root, byKey, children, "", last) } + return sb.String() } -func printSessionTree(sb *strings.Builder, key string, byKey map[string]*session.SessionInfo, children map[string][]string, prefix string, last bool) { +func printSessionTree( + sb *strings.Builder, + key string, + byKey map[string]*session.SessionInfo, + children map[string][]string, + prefix string, + last bool, +) { s := byKey[key] + if s == nil { return } connector := "├── " + if last { connector = "└── " } + icon := "●" + if s.Status == "completed" { icon = "✓" } + label := s.Label + if label == "" { label = shortSessionKey(s.Key) } + fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount) childPrefix := prefix + "│ " + if last { childPrefix = prefix + " " } + kids := children[key] + for i, childKey := range kids { printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1) } @@ -3909,88 +5677,131 @@ func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string } agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() label := "fork" + if len(args) > 0 { label = strings.Join(args, " ") } childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405") + err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label}) if err != nil { return fmt.Sprintf("Fork failed: %v", err) } - return fmt.Sprintf("Forked session\n parent: %s\n child: %s", shortSessionKey(sessionKey), shortSessionKey(childKey)) + + return fmt.Sprintf( + "Forked session\n parent: %s\n child: %s", + shortSessionKey(sessionKey), + shortSessionKey(childKey), + ) } // SessionGraphNode represents a session node for the Mini App graph API. + type SessionGraphNode struct { - Key string `json:"key"` - Label string `json:"label"` - Status string `json:"status"` - Summary string `json:"summary"` - ParentKey string `json:"parent_key"` - ForkTurnID string `json:"fork_turn_id"` - TurnCount int `json:"turn_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Key string `json:"key"` + + Label string `json:"label"` + + Status string `json:"status"` + + Summary string `json:"summary"` + + ParentKey string `json:"parent_key"` + + ForkTurnID string `json:"fork_turn_id"` + + TurnCount int `json:"turn_count"` + + CreatedAt time.Time `json:"created_at"` + + UpdatedAt time.Time `json:"updated_at"` } // GetSessionGraph returns all sessions as a flat list of graph nodes. + func (al *AgentLoop) GetSessionGraph() []SessionGraphNode { agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + sessions, err := store.List(nil) if err != nil { return nil } + nodes := make([]SessionGraphNode, 0, len(sessions)) + for _, s := range sessions { nodes = append(nodes, SessionGraphNode{ - Key: s.Key, - Label: s.Label, - Status: s.Status, - Summary: s.Summary, - ParentKey: s.ParentKey, + Key: s.Key, + + Label: s.Label, + + Status: s.Status, + + Summary: s.Summary, + + ParentKey: s.ParentKey, + ForkTurnID: s.ForkTurnID, - TurnCount: s.TurnCount, - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, + + TurnCount: s.TurnCount, + + CreatedAt: s.CreatedAt, + + UpdatedAt: s.UpdatedAt, }) } + return nodes } // expandSkillCommand detects "/skill <name> [message]" and returns: + // - expanded: full content with SKILL.md injected (for LLM) + // - compact: skill name tag + user message only (for history) + // - ok: whether expansion happened + func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "/skill ") { return "", "", false } // Parse: /skill <name> [message] + rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7 + parts := strings.SplitN(rest, " ", 2) + if len(parts) == 0 || parts[0] == "" { return "", "", false } skillName := parts[0] + userMessage := "" + if len(parts) > 1 { userMessage = strings.TrimSpace(parts[1]) } agent := al.registry.GetDefaultAgent() + if agent == nil { return "", "", false } skillContent, found := agent.ContextBuilder.LoadSkill(skillName) + if !found { return "", "", false } @@ -3998,17 +5809,25 @@ func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string tag := fmt.Sprintf("[Skill: %s]", skillName) // Build expanded message: skill instructions + user message (for LLM) + var sb strings.Builder + sb.WriteString(tag) + sb.WriteString("\n\n") + sb.WriteString(skillContent) + if userMessage != "" { sb.WriteString("\n\n---\n\n") + sb.WriteString(userMessage) } // Build compact form: skill name tag + user message only (for history) + compactForm := tag + if userMessage != "" { compactForm = tag + "\n" + userMessage } @@ -4017,173 +5836,241 @@ func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string } // handleSkillsCommand lists all available skills. + func (al *AgentLoop) handleSkillsCommand() string { agent := al.registry.GetDefaultAgent() + if agent == nil { return "No agent configured." } skillsList := agent.ContextBuilder.ListSkills() + if len(skillsList) == 0 { return "No skills available.\nAdd skills to your workspace/skills/ directory." } var sb strings.Builder + sb.WriteString("Available Skills\n\n") + for _, s := range skillsList { fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source) + if s.Description != "" { fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description) } } + sb.WriteString("\nUse: /skill <name> [message]") + return sb.String() } // handlePlanCommand handles /plan subcommands that can be resolved instantly. + // Returns (response, handled). For "/plan <task>" (new plan), it returns + // ("", false) so the message falls through to the LLM queue, where + // expandPlanCommand writes the seed and rewrites the content. + func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) { agent := al.registry.GetDefaultAgent() + if agent == nil { return "No agent configured.", true } if len(args) == 0 { // /plan — show current plan + return agent.ContextBuilder.FormatPlanDisplay(), true } sub := args[0] + switch sub { case "clear": + if agent.ContextBuilder.ReadMemory() == "" { return "No active plan to clear.", true } + // Deactivate worktree on plan clear + if sessionKey != "" { agent.DeactivateWorktree(sessionKey, "", true) } + if err := agent.ContextBuilder.ClearMemory(); err != nil { return fmt.Sprintf("Error clearing plan: %v", err), true } + return "Plan cleared.", true case "done": + if !agent.ContextBuilder.HasActivePlan() { return "No active plan.", true } + if len(args) < 2 { return "Usage: /plan done <step number>", true } + stepNum, err := strconv.Atoi(args[1]) + if err != nil || stepNum < 1 { return "Step number must be a positive integer.", true } + phase := agent.ContextBuilder.GetCurrentPhase() + if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil { return fmt.Sprintf("Error: %v", err), true } + return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true case "add": + if !agent.ContextBuilder.HasActivePlan() { return "No active plan.", true } + if len(args) < 2 { return "Usage: /plan add <step description>", true } + desc := strings.Join(args[1:], " ") + phase := agent.ContextBuilder.GetCurrentPhase() + if err := agent.ContextBuilder.AddStep(phase, desc); err != nil { return fmt.Sprintf("Error: %v", err), true } + return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true case "start": + if !agent.ContextBuilder.HasActivePlan() { return "No active plan.", true } + status := agent.ContextBuilder.GetPlanStatus() + if status == "executing" { return "Plan is already executing.", true } + if status != "interviewing" && status != "review" { return fmt.Sprintf("Cannot start from status %q.", status), true } + if agent.ContextBuilder.GetTotalPhases() == 0 { return "Cannot start: no phases defined yet. Complete the interview first.", true } + if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil { return fmt.Sprintf("Error: %v", err), true } + al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "") + al.planStartPending = true + clearHistory := len(args) > 1 && args[1] == "clear" + al.planClearHistory = clearHistory + if clearHistory { return "Plan approved. Executing with clean history.", true } + return "Plan approved. Executing.", true case "next": + if !agent.ContextBuilder.HasActivePlan() { return "No active plan.", true } + if err := agent.ContextBuilder.AdvancePhase(); err != nil { return fmt.Sprintf("Error: %v", err), true } + phase := agent.ContextBuilder.GetCurrentPhase() + return fmt.Sprintf("Advanced to phase %d.", phase), true case "worktrees": + return al.handlePlanWorktreesCommand(agent, args[1:]), true default: + // /plan <task description> — start new plan + // Block if a plan is already active (fast-path error). + if agent.ContextBuilder.HasActivePlan() { return "A plan is already active. Use /plan clear first.", true } + // Not handled here — let the message flow to the LLM queue. + // expandPlanCommand will write the seed and rewrite the content. + return "", false } } // isPlanPreExecution returns true if the plan is in a pre-execution state + // (interviewing or review) where tool restrictions and iteration caps apply. + func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string { repoRoot := git.FindRepoRoot(agent.Workspace) + if repoRoot == "" { return "Workspace is not a git repository." } + worktreesDir := filepath.Join(agent.Workspace, ".worktrees") sub := "list" + if len(args) > 0 { sub = strings.ToLower(strings.TrimSpace(args[0])) } switch sub { case "", "list": + items, err := git.ListManagedWorktrees(repoRoot, worktreesDir) if err != nil { return fmt.Sprintf("Error listing worktrees: %v", err) } + if len(items) == 0 { return "No active worktrees in workspace/.worktrees." } var sb strings.Builder + sb.WriteString("Active worktrees\n\n") + for _, wt := range items { status := "clean" + if wt.HasUncommitted { status = "dirty" } + last := "(no commits)" + if wt.LastCommitHash != "" { if wt.LastCommitAge != "" { last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge) @@ -4191,120 +6078,173 @@ func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []str last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject) } } + fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last) } + sb.WriteString("\nCommands:\n") + sb.WriteString("/plan worktrees inspect <name>\n") + sb.WriteString("/plan worktrees merge <name>\n") + sb.WriteString("/plan worktrees dispose <name> [force]") + return sb.String() case "inspect": + if len(args) < 2 { return "Usage: /plan worktrees inspect <name>" } + name := args[1] + wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name) if err != nil { if errors.Is(err, git.ErrInvalidWorktreeName) { return "Invalid worktree name." } + if errors.Is(err, git.ErrWorktreeNotFound) { return fmt.Sprintf("Worktree %q not found.", name) } + return fmt.Sprintf("Error inspecting worktree %q: %v", name, err) } + statusOut, _ := git.WorktreeStatusShort(wt.Path) + diffOut, _ := git.WorktreeDiffStat(wt.Path) + logOut, _ := git.WorktreeRecentLog(wt.Path, 10) + if statusOut == "" { statusOut = "(clean)" } var sb strings.Builder + fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted) + if wt.LastCommitHash != "" { fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject) + if wt.LastCommitAge != "" { fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge) } + sb.WriteString("\n") } + sb.WriteString("\nStatus:\n```\n") + sb.WriteString(statusOut) + sb.WriteString("\n```\n") + if diffOut != "" { sb.WriteString("\nDiff (stat):\n```\n") + sb.WriteString(diffOut) + sb.WriteString("\n```\n") } + if logOut != "" { sb.WriteString("\nRecent commits:\n```\n") + sb.WriteString(logOut) + sb.WriteString("\n```") } + return sb.String() case "merge": + if len(args) < 2 { return "Usage: /plan worktrees merge <name>" } + name := args[1] + res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "") if err != nil { if errors.Is(err, git.ErrInvalidWorktreeName) { return "Invalid worktree name." } + if errors.Is(err, git.ErrWorktreeNotFound) { return fmt.Sprintf("Worktree %q not found.", name) } + return fmt.Sprintf("Error merging worktree %q: %v", name, err) } + if res.Conflict { return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base) } + if res.Merged { return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base) } + return fmt.Sprintf("No merge was performed for `%s`.", name) case "dispose": + if len(args) < 2 { return "Usage: /plan worktrees dispose <name> [force]" } + name := args[1] + force := len(args) > 2 && strings.EqualFold(args[2], "force") + wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name) if err != nil { if errors.Is(err, git.ErrInvalidWorktreeName) { return "Invalid worktree name." } + if errors.Is(err, git.ErrWorktreeNotFound) { return fmt.Sprintf("Worktree %q not found.", name) } + return fmt.Sprintf("Error disposing worktree %q: %v", name, err) } + if wt.HasUncommitted && !force { return fmt.Sprintf( + "Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.", + name, + name, ) } + res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "") if err != nil { return fmt.Sprintf("Error disposing worktree %q: %v", name, err) } + parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)} + if res.AutoCommitted { parts = append(parts, "Uncommitted changes were auto-committed.") } + if res.CommitsAhead > 0 { parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead)) } + if res.BranchDeleted { parts = append(parts, "Branch was deleted (no unique commits).") } + return strings.Join(parts, " ") } @@ -4316,66 +6256,100 @@ func isPlanPreExecution(status string) bool { } // interviewAllowedTools is the single source of truth for tool names that may + // be sent to the LLM (and subsequently invoked) during the interview phase. + // filterInterviewTools uses this to strip tool *definitions* before the LLM call, + // while isToolAllowedDuringInterview adds argument-level checks as a second gate. + var interviewAllowedTools = map[string]bool{ - "readfile": true, - "listdir": true, - "websearch": true, - "webfetch": true, - "message": true, - "editfile": true, + "readfile": true, + + "listdir": true, + + "websearch": true, + + "webfetch": true, + + "message": true, + + "editfile": true, + "appendfile": true, - "writefile": true, - "exec": true, - "logs": true, + + "writefile": true, + + "exec": true, + + "logs": true, } // filterInterviewTools removes tool definitions that are not in the + // interviewAllowedTools whitelist, reducing token usage and preventing the + // LLM from attempting disallowed tool calls during the interview phase. + func filterInterviewTools(defs []providers.ToolDefinition) []providers.ToolDefinition { filtered := make([]providers.ToolDefinition, 0, len(defs)) + for _, d := range defs { if interviewAllowedTools[tools.NormalizeToolName(d.Function.Name)] { filtered = append(filtered, d) } } + return filtered } // isToolAllowedDuringInterview checks whether a tool call is permitted while the + // plan is in a pre-execution state. Uses the shared interviewAllowedTools map for + // name-level gating, then applies argument-level constraints for write-type tools + // (MEMORY.md only) and exec (read-only commands only). + func isToolAllowedDuringInterview(toolName string, args map[string]any) bool { norm := tools.NormalizeToolName(toolName) + if !interviewAllowedTools[norm] { return false } // Argument-level constraints + switch norm { case "editfile", "appendfile", "writefile": + path, _ := args["path"].(string) + return strings.HasSuffix(path, "MEMORY.md") + case "exec": + cmd, _ := args["command"].(string) + return isReadOnlyCommand(cmd) } + return true } // isReadOnlyCommand returns true when cmd is a safe, read-only shell command + // that an LLM may run during the interview phase. + func isReadOnlyCommand(cmd string) bool { cmd = strings.TrimSpace(cmd) + if cmd == "" { return false } // Reject write operators anywhere in the command + for _, op := range []string{">", ">>", "| tee "} { if strings.Contains(cmd, op) { return false @@ -4383,10 +6357,13 @@ func isReadOnlyCommand(cmd string) bool { } // Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction) + if strings.Contains(cmd, "..") { return false } + // Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later) + for _, field := range strings.Fields(cmd) { if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") { return false @@ -4394,6 +6371,7 @@ func isReadOnlyCommand(cmd string) bool { } // Strip "cd /path &&" prefix (LLM habit) + if strings.HasPrefix(cmd, "cd ") { if idx := strings.Index(cmd, "&&"); idx >= 0 { cmd = strings.TrimSpace(cmd[idx+2:]) @@ -4401,86 +6379,123 @@ func isReadOnlyCommand(cmd string) bool { } fields := strings.Fields(cmd) + if len(fields) == 0 { return false } + first := filepath.Base(fields[0]) + switch first { case "find", "ls", "cat", "head", "tail", "grep", "rg", + "tree", "wc", "file", "which", "pwd", + "uname", "df", "du", "stat", "realpath", "dirname", + "basename", "date": + return true } + return false } // isWriteTool returns true if the tool can modify files. + func isWriteTool(name string) bool { switch tools.NormalizeToolName(name) { case "writefile", "editfile", "appendfile", "exec": + return true } + return false } // expandPlanCommand detects "/plan <task>" (new plan start) and: + // - writes the interview seed to MEMORY.md + // - rewrites the message content for the LLM + // - returns a compact form for session history + // + // This follows the same pattern as expandSkillCommand: the message is + // rewritten before reaching the LLM, so the AI sees the task description + // while the system prompt contains the interview guide. + func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "/plan ") { return "", "", false } task := strings.TrimSpace(content[6:]) // len("/plan ") == 6 + if task == "" { return "", "", false } // Known subcommands are handled by handlePlanCommand (fast path). + firstWord := strings.Fields(task)[0] + switch firstWord { case "clear", "done", "add", "start", "next", "worktrees": + return "", "", false } agent := al.registry.GetDefaultAgent() + if agent == nil { return "", "", false } // If a plan is already active, don't expand — handleCommand will + // catch it and return the error on the fast path. + if agent.ContextBuilder.HasActivePlan() { return "", "", false } // Write the interview seed + seed := BuildInterviewSeed(task, agent.Workspace) + if err := agent.ContextBuilder.WriteMemory(seed); err != nil { return "", "", false } + al.notifyStateChange() // Expanded: the task description goes to LLM. + // The system prompt already contains the interview guide. + expanded = task + compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80)) + return expanded, compact, true } // extractPeer extracts the routing peer from the inbound message's structured Peer field. + func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { if msg.Peer.Kind == "" { return nil } + peerID := msg.Peer.ID + if peerID == "" { if msg.Peer.Kind == "direct" { peerID = msg.SenderID @@ -4488,15 +6503,20 @@ func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { peerID = msg.ChatID } } + return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} } // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. + func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { parentKind := msg.Metadata["parent_peer_kind"] + parentID := msg.Metadata["parent_peer_id"] + if parentKind == "" || parentID == "" { return nil } + return &routing.RoutePeer{Kind: parentKind, ID: parentID} } diff --git a/pkg/agent/session_recorder.go b/pkg/agent/session_recorder.go index b654911e3..f17bb81fe 100644 --- a/pkg/agent/session_recorder.go +++ b/pkg/agent/session_recorder.go @@ -7,6 +7,7 @@ import ( ) // sessionRecorderImpl bridges tools.SessionRecorder → session.SessionStore. + type sessionRecorderImpl struct { adapter *session.LegacyAdapter } @@ -19,40 +20,102 @@ func newSessionRecorder(adapter *session.LegacyAdapter) *sessionRecorderImpl { func (r *sessionRecorderImpl) RecordFork(conductorKey, subagentKey, taskID, label string) error { store := r.adapter.Store() + return store.Fork(conductorKey, subagentKey, &session.CreateOpts{ ForkTurnID: taskID, - Label: label, + + Label: label, }) } func (r *sessionRecorderImpl) RecordSubagentTurn(subagentKey string, messages []providers.Message) error { store := r.adapter.Store() + turn := &session.Turn{ - Kind: session.TurnNormal, + Kind: session.TurnNormal, + Messages: messages, } + return store.Append(subagentKey, turn) } func (r *sessionRecorderImpl) RecordCompletion(subagentKey, status, result string) error { store := r.adapter.Store() + return store.SetStatus(subagentKey, status) } func (r *sessionRecorderImpl) RecordReport(conductorKey, subagentKey, senderID, content string) error { store := r.adapter.Store() + turn := &session.Turn{ - Kind: session.TurnReport, + Kind: session.TurnReport, + OriginKey: subagentKey, - Author: senderID, + + Author: senderID, + Messages: []providers.Message{ {Role: "user", Content: content}, }, } + if err := store.Append(conductorKey, turn); err != nil { return err } + // Advance LegacyAdapter's stored counter so flush loop doesn't double-write. + r.adapter.AdvanceStored(conductorKey, 1) + + return nil +} + +func (r *sessionRecorderImpl) RecordQuestion(conductorKey, subagentKey, taskID, question string) error { + store := r.adapter.Store() + + turn := &session.Turn{ + Kind: session.TurnQuestion, + + OriginKey: subagentKey, + + Author: taskID, + + Messages: []providers.Message{ + {Role: "user", Content: question}, + }, + } + + if err := store.Append(conductorKey, turn); err != nil { + return err + } + + r.adapter.AdvanceStored(conductorKey, 1) + + return nil +} + +func (r *sessionRecorderImpl) RecordPlanSubmit(conductorKey, subagentKey, taskID, planText string) error { + store := r.adapter.Store() + + turn := &session.Turn{ + Kind: session.TurnPlanSubmit, + + OriginKey: subagentKey, + + Author: taskID, + + Messages: []providers.Message{ + {Role: "user", Content: planText}, + }, + } + + if err := store.Append(conductorKey, turn); err != nil { + return err + } + + r.adapter.AdvanceStored(conductorKey, 1) + return nil } diff --git a/pkg/agent/session_recorder_test.go b/pkg/agent/session_recorder_test.go index 410eb59dc..083d80b8e 100644 --- a/pkg/agent/session_recorder_test.go +++ b/pkg/agent/session_recorder_test.go @@ -11,15 +11,22 @@ import ( func newTestRecorder(t *testing.T) (*sessionRecorderImpl, *session.LegacyAdapter, session.SessionStore) { t.Helper() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + store, err := session.OpenSQLiteStore(dbPath) if err != nil { t.Fatalf("open store: %v", err) } + adapter := session.NewLegacyAdapter(store) + t.Cleanup(func() { adapter.Close() }) + recorder := newSessionRecorder(adapter) + return recorder, adapter, store } @@ -27,6 +34,7 @@ func TestRecordFork(t *testing.T) { rec, _, store := newTestRecorder(t) // Create conductor session first. + if err := store.Create("conductor:main", nil); err != nil { t.Fatalf("create conductor session: %v", err) } @@ -37,31 +45,39 @@ func TestRecordFork(t *testing.T) { } // Verify child session exists with correct parent. + info, err := store.Get("subagent:subagent-1") if err != nil { t.Fatalf("Get child: %v", err) } + if info == nil { t.Fatal("child session not found") } + if info.ParentKey != "conductor:main" { t.Errorf("ParentKey = %q, want %q", info.ParentKey, "conductor:main") } + if info.ForkTurnID != "subagent-1" { t.Errorf("ForkTurnID = %q, want %q", info.ForkTurnID, "subagent-1") } + if info.Label != "scout" { t.Errorf("Label = %q, want %q", info.Label, "scout") } // Verify parent lists child. + children, err := store.Children("conductor:main") if err != nil { t.Fatalf("Children: %v", err) } + if len(children) != 1 { t.Fatalf("children count = %d, want 1", len(children)) } + if children[0].Key != "subagent:subagent-1" { t.Errorf("child key = %q, want %q", children[0].Key, "subagent:subagent-1") } @@ -71,15 +87,19 @@ func TestRecordSubagentTurn(t *testing.T) { rec, _, store := newTestRecorder(t) // Create subagent session. + if err := store.Create("subagent:subagent-1", nil); err != nil { t.Fatalf("create: %v", err) } msgs := []providers.Message{ {Role: "system", Content: "You are a scout."}, + {Role: "user", Content: "Investigate X."}, + {Role: "assistant", Content: "Found Y."}, } + if err := rec.RecordSubagentTurn("subagent:subagent-1", msgs); err != nil { t.Fatalf("RecordSubagentTurn: %v", err) } @@ -88,15 +108,19 @@ func TestRecordSubagentTurn(t *testing.T) { if err != nil { t.Fatalf("Turns: %v", err) } + if len(turns) != 1 { t.Fatalf("turns count = %d, want 1", len(turns)) } + if turns[0].Kind != session.TurnNormal { t.Errorf("Kind = %d, want TurnNormal", turns[0].Kind) } + if len(turns[0].Messages) != 3 { t.Errorf("messages count = %d, want 3", len(turns[0].Messages)) } + if turns[0].Messages[2].Content != "Found Y." { t.Errorf("last message = %q, want %q", turns[0].Messages[2].Content, "Found Y.") } @@ -117,18 +141,23 @@ func TestRecordCompletion(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + if info.Status != "completed" { t.Errorf("Status = %q, want %q", info.Status, "completed") } // Test failed status. + if err := store.Create("subagent:subagent-2", nil); err != nil { t.Fatalf("create: %v", err) } + if err := rec.RecordCompletion("subagent:subagent-2", "failed", "error"); err != nil { t.Fatalf("RecordCompletion failed: %v", err) } + info2, _ := store.Get("subagent:subagent-2") + if info2.Status != "failed" { t.Errorf("Status = %q, want %q", info2.Status, "failed") } @@ -138,6 +167,7 @@ func TestRecordReport(t *testing.T) { rec, adapter, store := newTestRecorder(t) // Create conductor session via adapter so it's in cache. + _ = adapter.GetOrCreate("conductor:main") if err := store.Create("subagent:subagent-1", nil); err != nil { @@ -145,27 +175,34 @@ func TestRecordReport(t *testing.T) { } content := "[System: subagent:subagent-1] Task 'scout' completed.\n\nResult:\nFound Y." + if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil { t.Fatalf("RecordReport: %v", err) } // Verify TurnReport in store. + turns, err := store.Turns("conductor:main", 0) if err != nil { t.Fatalf("Turns: %v", err) } + if len(turns) != 1 { t.Fatalf("turns count = %d, want 1", len(turns)) } + if turns[0].Kind != session.TurnReport { t.Errorf("Kind = %d, want TurnReport(%d)", turns[0].Kind, session.TurnReport) } + if turns[0].OriginKey != "subagent:subagent-1" { t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1") } + if turns[0].Author != "subagent:subagent-1" { t.Errorf("Author = %q, want %q", turns[0].Author, "subagent:subagent-1") } + if len(turns[0].Messages) != 1 || turns[0].Messages[0].Role != "user" { t.Errorf("unexpected messages: %v", turns[0].Messages) } @@ -175,6 +212,7 @@ func TestAdvanceStoredPreventsDoubleWrite(t *testing.T) { rec, adapter, store := newTestRecorder(t) // Create conductor session via adapter. + _ = adapter.GetOrCreate("conductor:main") if err := store.Create("subagent:subagent-1", nil); err != nil { @@ -182,57 +220,157 @@ func TestAdvanceStoredPreventsDoubleWrite(t *testing.T) { } // Simulate: conductor has 2 messages already flushed. + adapter.AddMessage("conductor:main", "user", "hello") + adapter.AddMessage("conductor:main", "assistant", "hi") + if err := adapter.Save("conductor:main"); err != nil { t.Fatalf("Save: %v", err) } // RecordReport writes directly to store and advances stored counter. + content := "[System: subagent:subagent-1] result" + if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil { t.Fatalf("RecordReport: %v", err) } // The in-memory cache should also be updated (by loop.go calling AddFullMessage). + // Simulate what loop.go does after RecordReport succeeds. + adapter.AddFullMessage("conductor:main", providers.Message{Role: "user", Content: content}) + // AdvanceStored was already called by RecordReport, so stored = 3 + 1 = 4 + // but we added 1 message to cache making it len=4 as well. No double write. // Save should NOT re-write the report turn. + if err := adapter.Save("conductor:main"); err != nil { t.Fatalf("Save after report: %v", err) } // Count all turns in store for conductor session. + turns, err := store.Turns("conductor:main", 0) if err != nil { t.Fatalf("Turns: %v", err) } // Expected: turn 1 (initial 2 msgs), turn 2 (TurnReport from RecordReport) + // NOT turn 3 (duplicate from flush). + if len(turns) != 2 { t.Errorf("turns count = %d, want 2 (no double-write)", len(turns)) + for i, turn := range turns { t.Logf(" turn[%d]: seq=%d kind=%d msgs=%d", i, turn.Seq, turn.Kind, len(turn.Messages)) } } } +func TestRecordQuestion(t *testing.T) { + rec, adapter, store := newTestRecorder(t) + + // Create conductor session via adapter so it's in cache. + + _ = adapter.GetOrCreate("conductor:main") + + if err := store.Create("subagent:subagent-1", nil); err != nil { + t.Fatalf("create subagent: %v", err) + } + + question := "What database schema should I use for the users table?" + + if err := rec.RecordQuestion("conductor:main", "subagent:subagent-1", "subagent-1", question); err != nil { + t.Fatalf("RecordQuestion: %v", err) + } + + turns, err := store.Turns("conductor:main", 0) + if err != nil { + t.Fatalf("Turns: %v", err) + } + + if len(turns) != 1 { + t.Fatalf("turns count = %d, want 1", len(turns)) + } + + if turns[0].Kind != session.TurnQuestion { + t.Errorf("Kind = %d, want TurnQuestion(%d)", turns[0].Kind, session.TurnQuestion) + } + + if turns[0].OriginKey != "subagent:subagent-1" { + t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1") + } + + if turns[0].Author != "subagent-1" { + t.Errorf("Author = %q, want %q", turns[0].Author, "subagent-1") + } + + if len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != question { + t.Errorf("unexpected messages: %v", turns[0].Messages) + } +} + +func TestRecordPlanSubmit(t *testing.T) { + rec, adapter, store := newTestRecorder(t) + + _ = adapter.GetOrCreate("conductor:main") + + if err := store.Create("subagent:subagent-1", nil); err != nil { + t.Fatalf("create subagent: %v", err) + } + + planText := "Goal: Implement auth\nSteps:\n1. Add middleware\n2. Add JWT validation" + + if err := rec.RecordPlanSubmit("conductor:main", "subagent:subagent-1", "subagent-1", planText); err != nil { + t.Fatalf("RecordPlanSubmit: %v", err) + } + + turns, err := store.Turns("conductor:main", 0) + if err != nil { + t.Fatalf("Turns: %v", err) + } + + if len(turns) != 1 { + t.Fatalf("turns count = %d, want 1", len(turns)) + } + + if turns[0].Kind != session.TurnPlanSubmit { + t.Errorf("Kind = %d, want TurnPlanSubmit(%d)", turns[0].Kind, session.TurnPlanSubmit) + } + + if turns[0].OriginKey != "subagent:subagent-1" { + t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1") + } + + if len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != planText { + t.Errorf("unexpected messages: %v", turns[0].Messages) + } +} + func TestExtractTaskID(t *testing.T) { tests := []struct { input string - want string + + want string }{ {"subagent:subagent-1", "subagent-1"}, + {"subagent:subagent-42", "subagent-42"}, + {"plain-id", "plain-id"}, + {"a:b:c", "c"}, } + for _, tt := range tests { got := extractTaskID(tt.input) + if got != tt.want { t.Errorf("extractTaskID(%q) = %q, want %q", tt.input, got, tt.want) } @@ -241,5 +379,6 @@ func TestExtractTaskID(t *testing.T) { func init() { // Suppress log output in tests. + os.Setenv("PICOCLAW_LOG_LEVEL", "error") } diff --git a/pkg/session/types.go b/pkg/session/types.go index 32ecdcf39..395ccfa8c 100644 --- a/pkg/session/types.go +++ b/pkg/session/types.go @@ -19,6 +19,15 @@ const ( ) +// Escalation turn kinds — explicit values to keep stable across versions. + +const ( + TurnQuestion TurnKind = 10 // Subagent → conductor question (escalation) + + TurnPlanSubmit TurnKind = 11 // Subagent plan submission for review + +) + // Turn represents a single conversation turn persisted in the store. type Turn struct { diff --git a/pkg/tools/answer_subagent.go b/pkg/tools/answer_subagent.go new file mode 100644 index 000000000..86a84dfbc --- /dev/null +++ b/pkg/tools/answer_subagent.go @@ -0,0 +1,138 @@ +package tools + +import ( + "context" + "fmt" +) + +// AnswerSubagentTool allows the conductor to answer a subagent's question. + +type AnswerSubagentTool struct { + manager *SubagentManager +} + +func NewAnswerSubagentTool(manager *SubagentManager) *AnswerSubagentTool { + return &AnswerSubagentTool{manager: manager} +} + +func (t *AnswerSubagentTool) Name() string { return "answer_subagent" } + +func (t *AnswerSubagentTool) Description() string { + return "Answer a subagent's question or escalation. The subagent is blocked waiting for your response." +} + +func (t *AnswerSubagentTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + + "description": "The task ID of the subagent (e.g. subagent-1)", + }, + + "answer": map[string]any{ + "type": "string", + + "description": "Your answer to the subagent's question", + }, + }, + + "required": []string{"task_id", "answer"}, + } +} + +func (t *AnswerSubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + taskID, _ := args["task_id"].(string) + + if taskID == "" { + return ErrorResult("required parameter \"task_id\" (string) is missing") + } + + answer, _ := args["answer"].(string) + + if answer == "" { + return ErrorResult("required parameter \"answer\" (string) is missing") + } + + if t.manager == nil { + return ErrorResult("subagent manager not available") + } + + if err := t.manager.AnswerQuestion(taskID, answer); err != nil { + return ErrorResult(fmt.Sprintf("failed to answer subagent: %v", err)) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Answer sent to %s.", taskID), + + ForUser: fmt.Sprintf("Answered %s", taskID), + } +} + +// ReviewSubagentPlanTool allows the conductor to approve/reject a subagent's plan. + +type ReviewSubagentPlanTool struct { + manager *SubagentManager +} + +func NewReviewSubagentPlanTool(manager *SubagentManager) *ReviewSubagentPlanTool { + return &ReviewSubagentPlanTool{manager: manager} +} + +func (t *ReviewSubagentPlanTool) Name() string { return "review_subagent_plan" } + +func (t *ReviewSubagentPlanTool) Description() string { + return "Approve or reject a subagent's execution plan. Use decision 'approved' to approve, or provide rejection feedback." +} + +func (t *ReviewSubagentPlanTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + + "description": "The task ID of the subagent (e.g. subagent-1)", + }, + + "decision": map[string]any{ + "type": "string", + + "description": "Decision: 'approved' to approve, or rejection feedback text", + }, + }, + + "required": []string{"task_id", "decision"}, + } +} + +func (t *ReviewSubagentPlanTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + taskID, _ := args["task_id"].(string) + + if taskID == "" { + return ErrorResult("required parameter \"task_id\" (string) is missing") + } + + decision, _ := args["decision"].(string) + + if decision == "" { + return ErrorResult("required parameter \"decision\" (string) is missing") + } + + if t.manager == nil { + return ErrorResult("subagent manager not available") + } + + if err := t.manager.AnswerQuestion(taskID, decision); err != nil { + return ErrorResult(fmt.Sprintf("failed to send review decision: %v", err)) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Review decision '%s' sent to %s.", decision, taskID), + + ForUser: fmt.Sprintf("Reviewed %s: %s", taskID, decision), + } +} diff --git a/pkg/tools/ask_conductor.go b/pkg/tools/ask_conductor.go new file mode 100644 index 000000000..aa847c645 --- /dev/null +++ b/pkg/tools/ask_conductor.go @@ -0,0 +1,110 @@ +package tools + +import ( + "context" + "fmt" +) + +// AskConductorTool allows a subagent to ask the conductor a question. + +// The subagent blocks until the conductor answers via AnswerSubagentTool. + +type AskConductorTool struct { + taskID string + + conductorKey string + + subagentKey string + + outCh chan<- ContainerMessage + + inCh <-chan string + + recorder SessionRecorder +} + +func NewAskConductorTool( + taskID, conductorKey, subagentKey string, + + outCh chan<- ContainerMessage, + + inCh <-chan string, + + recorder SessionRecorder, +) *AskConductorTool { + return &AskConductorTool{ + taskID: taskID, + + conductorKey: conductorKey, + + subagentKey: subagentKey, + + outCh: outCh, + + inCh: inCh, + + recorder: recorder, + } +} + +func (t *AskConductorTool) Name() string { return "ask_conductor" } + +func (t *AskConductorTool) Description() string { + return "Ask the conductor a clarifying question. Blocks until the conductor responds. Use when you need guidance or a decision before proceeding." +} + +func (t *AskConductorTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + + "properties": map[string]any{ + "question": map[string]any{ + "type": "string", + + "description": "The question to ask the conductor", + }, + }, + + "required": []string{"question"}, + } +} + +func (t *AskConductorTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + question, ok := args["question"].(string) + + if !ok || question == "" { + return ErrorResult("required parameter \"question\" (string) is missing") + } + + // Fire-and-forget: record question in session DAG. + + if t.recorder != nil { + _ = t.recorder.RecordQuestion(t.conductorKey, t.subagentKey, t.taskID, question) + } + + // Send question to conductor (blocking with ctx). + + select { + case t.outCh <- ContainerMessage{Type: "question", Content: question, TaskID: t.taskID}: + + case <-ctx.Done(): + + return ErrorResult(fmt.Sprintf("context canceled while sending question: %v", ctx.Err())) + } + + // Wait for conductor's answer. + + select { + case answer := <-t.inCh: + + return &ToolResult{ + ForLLM: fmt.Sprintf("Conductor answered: %s", answer), + + ForUser: answer, + } + + case <-ctx.Done(): + + return ErrorResult(fmt.Sprintf("context canceled while waiting for answer: %v", ctx.Err())) + } +} diff --git a/pkg/tools/ask_conductor_test.go b/pkg/tools/ask_conductor_test.go new file mode 100644 index 000000000..61b326d9a --- /dev/null +++ b/pkg/tools/ask_conductor_test.go @@ -0,0 +1,77 @@ +package tools + +import ( + "context" + "testing" + "time" +) + +func TestAskConductorTool_Execute(t *testing.T) { + outCh := make(chan ContainerMessage, 4) + + inCh := make(chan string, 1) + + tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil) + + if tool.Name() != "ask_conductor" { + t.Errorf("Name() = %q, want %q", tool.Name(), "ask_conductor") + } + + // Simulate conductor answering in background. + + go func() { + msg := <-outCh + + if msg.Type != "question" { + t.Errorf("msg.Type = %q, want %q", msg.Type, "question") + } + + if msg.Content != "What port?" { + t.Errorf("msg.Content = %q, want %q", msg.Content, "What port?") + } + + inCh <- "Use port 8080" + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + result := tool.Execute(ctx, map[string]any{"question": "What port?"}) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + if result.ForUser != "Use port 8080" { + t.Errorf("ForUser = %q, want %q", result.ForUser, "Use port 8080") + } +} + +func TestAskConductorTool_MissingQuestion(t *testing.T) { + tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", nil, nil, nil) + + result := tool.Execute(context.Background(), map[string]any{}) + + if !result.IsError { + t.Error("expected error for missing question") + } +} + +func TestAskConductorTool_ContextCanceled(t *testing.T) { + outCh := make(chan ContainerMessage) // unbuffered, will block + + inCh := make(chan string) + + tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + cancel() // cancel immediately + + result := tool.Execute(ctx, map[string]any{"question": "test?"}) + + if !result.IsError { + t.Error("expected error on canceled context") + } +} diff --git a/pkg/tools/session_recorder.go b/pkg/tools/session_recorder.go index 1ac339ecb..05104b1df 100644 --- a/pkg/tools/session_recorder.go +++ b/pkg/tools/session_recorder.go @@ -16,4 +16,10 @@ type SessionRecorder interface { // RecordReport injects a TurnReport into the conductor session. RecordReport(conductorSessionKey, subagentSessionKey, senderID, content string) error + + // RecordQuestion injects a TurnQuestion into the conductor session (subagent escalation). + RecordQuestion(conductorKey, subagentKey, taskID, question string) error + + // RecordPlanSubmit injects a TurnPlanSubmit into the conductor session (plan review request). + RecordPlanSubmit(conductorKey, subagentKey, taskID, planText string) error } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index e83157616..af19ca86d 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -7,22 +7,29 @@ import ( ) type SpawnTool struct { - manager *SubagentManager - originChannel string - originChatID string + manager *SubagentManager + + originChannel string + + originChatID string + allowlistCheck func(targetAgentID string) bool - callback AsyncCallback // For async completion notification + + callback AsyncCallback // For async completion notification } func NewSpawnTool(manager *SubagentManager) *SpawnTool { return &SpawnTool{ - manager: manager, + manager: manager, + originChannel: "cli", - originChatID: "direct", + + originChatID: "direct", } } // SetCallback implements AsyncTool interface for async completion notification + func (t *SpawnTool) SetCallback(cb AsyncCallback) { t.callback = cb } @@ -38,31 +45,42 @@ func (t *SpawnTool) Description() string { func (t *SpawnTool) Parameters() map[string]any { return map[string]any{ "type": "object", + "properties": map[string]any{ "task": map[string]any{ - "type": "string", + "type": "string", + "description": "The task for subagent to complete", }, + "label": map[string]any{ - "type": "string", + "type": "string", + "description": "Optional short label for the task (for display)", }, + "agent_id": map[string]any{ - "type": "string", + "type": "string", + "description": "Optional target agent ID to delegate the task to", }, + "preset": map[string]any{ - "type": "string", - "enum": []string{"scout", "analyst", "coder", "worker", "coordinator"}, + "type": "string", + + "enum": []string{"scout", "analyst", "coder", "worker", "coordinator"}, + "description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)", }, }, + "required": []string{"task"}, } } func (t *SpawnTool) SetContext(channel, chatID string) { t.originChannel = channel + t.originChatID = chatID } @@ -72,20 +90,28 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) + if !ok || strings.TrimSpace(task) == "" { return ErrorResult( + `Required parameter "task" (string) is missing. ` + + `Example: {"task": "describe what you need done", "preset": "scout"}`, ) } label, _ := args["label"].(string) + agentID, _ := args["agent_id"].(string) + preset, _ := args["preset"].(string) // Check allowlist if targeting a specific agent ID. + // Presets (scout, analyst, etc.) are NOT agent IDs — they are validated + // separately by IsValidPreset() in the subagent manager. + if agentID != "" && t.allowlistCheck != nil { if !t.allowlistCheck(agentID) { return ErrorResult(fmt.Sprintf("agent %q is not in the allowed agents list", agentID)) @@ -93,9 +119,12 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul } // Validate preset name if provided + if preset != "" && !IsValidPreset(Preset(preset)) { return ErrorResult(fmt.Sprintf( + "preset %q is not valid. Available presets: scout, analyst, coder, worker, coordinator", + preset, )) } @@ -105,11 +134,13 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul } // Pass callback to manager for async completion notification + result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, t.callback) if err != nil { return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) } // Return AsyncResult since the task runs in background + return AsyncResult(result) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index b5266345f..1926e2eb7 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -3,6 +3,8 @@ package tools import ( "context" "fmt" + "os" + "path/filepath" "sort" "strconv" "strings" @@ -16,243 +18,441 @@ import ( ) // spawnTimeout is the hard upper bound for a single spawn goroutine. + // MaxIterations × HTTP timeout provides the soft limit; this is a safety net. + const spawnTimeout = 30 * time.Minute +// ContainerMessage is sent from a subagent to the conductor via outCh. + +type ContainerMessage struct { + Type string // "question" or "plan_review" + + Content string + + TaskID string +} + +// isDeliberatePreset returns true for presets that use the deliberate + +// (clarifying → review → executing) workflow with escalation channels. + +func isDeliberatePreset(p Preset) bool { + switch p { + case PresetCoder, PresetWorker, PresetCoordinator: + + return true + } + + return false +} + +// SubagentPlanState represents the deliberate workflow phase. + +type SubagentPlanState int + +const ( + PlanNone SubagentPlanState = iota // Not a deliberate preset + + PlanClarifying // Gathering info, asking questions + + PlanReview // Plan submitted, awaiting approval + + PlanExecuting // Plan approved, executing + + PlanCompleted // Done + +) + +// String returns a human-readable label for the plan state. + +func (s SubagentPlanState) String() string { + switch s { + case PlanClarifying: + + return "clarifying" + + case PlanReview: + + return "review" + + case PlanExecuting: + + return "executing" + + case PlanCompleted: + + return "completed" + + default: + + return "none" + } +} + type SubagentTask struct { - ID string - Task string - Label string - AgentID string + ID string + + Task string + + Label string + + AgentID string + OriginChannel string - OriginChatID string - Status string - Result string - Created int64 - CompletedAt int64 `json:"-"` - Iterations int `json:"-"` - ToolCalls int `json:"-"` - ToolStats map[string]int `json:"-"` - cancel context.CancelFunc + + OriginChatID string + + Status string + + Result string + + Created int64 + + CompletedAt int64 `json:"-"` + + Iterations int `json:"-"` + + ToolCalls int `json:"-"` + + ToolStats map[string]int `json:"-"` + + cancel context.CancelFunc + + // Escalation channels for deliberate presets (nil for exploratory). + + inCh chan string // conductor → subagent answers + + outCh chan ContainerMessage // subagent → conductor questions/plan reviews + + // Plan mode state (deliberate presets only). + + PlanState SubagentPlanState + + PlanGoal string + + PlanSteps []string } type SubagentManager struct { - tasks map[string]*SubagentTask - mu sync.RWMutex - wg sync.WaitGroup // tracks running spawn goroutines - provider providers.LLMProvider - defaultModel string - bus *bus.MessageBus - workspace string - tools *ToolRegistry - webSearchOpts WebSearchToolOptions - maxIterations int - maxTokens int - temperature float64 - hasMaxTokens bool + tasks map[string]*SubagentTask + + mu sync.RWMutex + + wg sync.WaitGroup // tracks running spawn goroutines + + provider providers.LLMProvider + + defaultModel string + + bus *bus.MessageBus + + workspace string + + tools *ToolRegistry + + webSearchOpts WebSearchToolOptions + + maxIterations int + + maxTokens int + + temperature float64 + + hasMaxTokens bool + hasTemperature bool - nextID int - reporter orch.AgentReporter - recorder SessionRecorder + + nextID int + + reporter orch.AgentReporter + + recorder SessionRecorder + conductorSessionKey string } func NewSubagentManager( provider providers.LLMProvider, + defaultModel, workspace string, + bus *bus.MessageBus, + reporter orch.AgentReporter, + webSearchOpts WebSearchToolOptions, ) *SubagentManager { if reporter == nil { reporter = orch.Noop } + return &SubagentManager{ - tasks: make(map[string]*SubagentTask), - provider: provider, - defaultModel: defaultModel, - bus: bus, - workspace: workspace, - tools: NewToolRegistry(), + tasks: make(map[string]*SubagentTask), + + provider: provider, + + defaultModel: defaultModel, + + bus: bus, + + workspace: workspace, + + tools: NewToolRegistry(), + webSearchOpts: webSearchOpts, + maxIterations: 10, - nextID: 1, - reporter: reporter, + + nextID: 1, + + reporter: reporter, } } // SetLLMOptions sets max tokens and temperature for subagent LLM calls. + func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() + defer sm.mu.Unlock() + sm.maxTokens = maxTokens + sm.hasMaxTokens = true + sm.temperature = temperature + sm.hasTemperature = true } // SetTools sets the tool registry for subagent execution. + // If not set, subagent will have access to the provided tools. + func (sm *SubagentManager) SetTools(tools *ToolRegistry) { sm.mu.Lock() + defer sm.mu.Unlock() + sm.tools = tools } // SetSessionRecorder configures session recording for DAG persistence. + func (sm *SubagentManager) SetSessionRecorder(r SessionRecorder, conductorSessionKey string) { sm.mu.Lock() + defer sm.mu.Unlock() + sm.recorder = r + sm.conductorSessionKey = conductorSessionKey } // RegisterTool registers a tool for subagent execution. + func (sm *SubagentManager) RegisterTool(tool Tool) { sm.mu.Lock() + defer sm.mu.Unlock() + sm.tools.Register(tool) } func (sm *SubagentManager) Spawn( ctx context.Context, + task, label, agentID, originChannel, originChatID, preset string, + callback AsyncCallback, ) (string, error) { sm.mu.Lock() + defer sm.mu.Unlock() taskID := fmt.Sprintf("subagent-%d", sm.nextID) + sm.nextID++ subagentTask := &SubagentTask{ - ID: taskID, - Task: task, - Label: label, - AgentID: agentID, + ID: taskID, + + Task: task, + + Label: label, + + AgentID: agentID, + OriginChannel: originChannel, - OriginChatID: originChatID, - Status: "running", - Created: time.Now().UnixMilli(), + + OriginChatID: originChatID, + + Status: "running", + + Created: time.Now().UnixMilli(), } + + // Create escalation channels for deliberate presets. + + p := Preset(preset) + + if IsValidPreset(p) && isDeliberatePreset(p) { + subagentTask.inCh = make(chan string, 1) + + subagentTask.outCh = make(chan ContainerMessage, 4) + } + sm.tasks[taskID] = subagentTask sm.reporter.ReportSpawn(taskID, label, task) // Record fork in session DAG (conductor → subagent). + if sm.recorder != nil && sm.conductorSessionKey != "" { subSessionKey := routing.BuildSubagentSessionKey(taskID) + _ = sm.recorder.RecordFork(sm.conductorSessionKey, subSessionKey, taskID, label) } // Start task in background with a detached context that has a hard timeout. + // The spawned goroutine must outlive the parent (e.g. heartbeat session) + // which may finish before the subagent completes. + // The cancel func is stored on the task so CancelTask() can stop it. + spawnCtx, spawnCancel := context.WithTimeout(context.Background(), spawnTimeout) + subagentTask.cancel = spawnCancel + sm.wg.Add(1) + go func() { defer sm.wg.Done() + sm.runTask(spawnCtx, subagentTask, preset, callback) }() if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil } + return fmt.Sprintf("Spawned subagent for task: %s", task), nil } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, preset string, callback AsyncCallback) { task.Status = "running" - // Build system prompt based on preset type - systemPrompt := `You are a subagent. Complete the given task independently and report the result. -You have access to tools - use them as needed to complete your task. -After completing the task, provide a clear summary of what was done.` - - // Select prompt based on preset (exploratory vs deliberate) p := Preset(preset) - if IsValidPreset(p) { - switch p { - case PresetScout, PresetAnalyst: - // Exploratory presets - systemPrompt = `You are an exploratory subagent. Investigate the task and report your findings. + + if IsValidPreset(p) && isDeliberatePreset(p) { + sm.runDeliberateTask(ctx, task, p, callback) + } else { + sm.runExploratoryTask(ctx, task, p, callback) + } +} + +// clarifyingSystemPrompt returns the system prompt for the clarifying phase. + +func clarifyingSystemPrompt() string { + return `You are a deliberate subagent in the CLARIFYING phase. + +Your job is to understand the task fully before acting. You MUST: + +1. Read relevant files and gather context using your tools. + +2. If anything is unclear, use ask_conductor to ask the conductor. + +3. When you have a clear plan, use submit_plan with a goal and steps. + + + +Do NOT execute any changes yet. Only investigate and plan. + +Available escalation tools: ask_conductor, submit_plan.` +} + +// executingSystemPrompt returns the system prompt for the executing phase. + +func executingSystemPrompt() string { + return `You are a deliberate subagent in the EXECUTING phase. Your plan was approved. + +Execute the plan steps methodically. Use all available tools to complete the work. + +After completing, provide a clear summary of what was done and how it was verified. + + + +If you encounter a blocker, use ask_conductor to escalate.` +} + +// exploratorySystemPrompt returns the system prompt for exploratory presets. + +func exploratorySystemPrompt(p Preset) string { + switch p { + case PresetScout, PresetAnalyst: + + return `You are an exploratory subagent. Investigate the task and report your findings. + Use your best judgment when encountering ambiguity. Use tools as needed. + Return clear findings and observations.` - case PresetCoder, PresetWorker, PresetCoordinator: - // Deliberate presets - systemPrompt = `You are a deliberate subagent. Complete the task methodically and verify your work. -Before executing significant actions, think through your approach. -After completing, provide a clear summary of what was done and how it was verified.` - } - } - messages := []providers.Message{ - { - Role: "system", - Content: systemPrompt, - }, - { - Role: "user", - Content: task.Task, - }, - } - - // Check if context is already canceled before starting - select { - case <-ctx.Done(): - sm.mu.Lock() - task.Status = "canceled" - task.Result = "Task canceled before execution" - sm.mu.Unlock() - return default: - } - // Run tool loop with access to tools + return `You are a subagent. Complete the given task independently and report the result. + +You have access to tools - use them as needed to complete your task. + +After completing the task, provide a clear summary of what was done.` + } +} + +// getLLMOptions returns the LLM options snapshot under read lock. + +func (sm *SubagentManager) getLLMOptions() map[string]any { sm.mu.RLock() - // Use preset registry if preset is valid, otherwise use default registry - tools := sm.tools - if IsValidPreset(p) { - tools = sm.buildPresetRegistry(p, sm.workspace) - } - maxIter := sm.maxIterations - maxTokens := sm.maxTokens - temperature := sm.temperature - hasMaxTokens := sm.hasMaxTokens - hasTemperature := sm.hasTemperature - sm.mu.RUnlock() - var llmOptions map[string]any - if hasMaxTokens || hasTemperature { - llmOptions = map[string]any{} - if hasMaxTokens { - llmOptions["max_tokens"] = maxTokens + defer sm.mu.RUnlock() + + var opts map[string]any + + if sm.hasMaxTokens || sm.hasTemperature { + opts = map[string]any{} + + if sm.hasMaxTokens { + opts["max_tokens"] = sm.maxTokens } - if hasTemperature { - llmOptions["temperature"] = temperature + + if sm.hasTemperature { + opts["temperature"] = sm.temperature } } - // Notify conductor that the subagent is starting - sm.reporter.ReportConversation("conductor", task.ID, task.Task) + return opts +} - loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ - Provider: sm.provider, - Model: sm.defaultModel, - Tools: tools, - MaxIterations: maxIter, - LLMOptions: llmOptions, - Reporter: sm.reporter, - AgentID: task.ID, - }, messages, task.OriginChannel, task.OriginChatID) +// finishTask records completion, sends bus announcement, and invokes callback. +// Must NOT hold sm.mu on entry. + +func (sm *SubagentManager) finishTask( + ctx context.Context, + task *SubagentTask, + messages []providers.Message, + loopResult *ToolLoopResult, + err error, + callback AsyncCallback, +) { sm.mu.Lock() + var result *ToolResult + defer func() { sm.mu.Unlock() - // Call callback if provided and result is set + if callback != nil && result != nil { callback(ctx, result) } @@ -260,125 +460,372 @@ After completing, provide a clear summary of what was done and how it was verifi if err != nil { task.Status = "failed" + task.Result = fmt.Sprintf("Error: %v", err) - // Check if it was canceled + gcReason := "failed" + if ctx.Err() != nil { task.Status = "canceled" + task.Result = "Task canceled during execution" + gcReason = "canceled" } + sm.reporter.ReportGC(task.ID, gcReason) - // Record failure/cancellation in session DAG. + if sm.recorder != nil { subKey := routing.BuildSubagentSessionKey(task.ID) + _ = sm.recorder.RecordCompletion(subKey, task.Status, task.Result) } + result = &ToolResult{ - ForLLM: task.Result, + ForLLM: task.Result, + ForUser: "", - Silent: false, + IsError: true, - Async: false, - Err: err, + + Err: err, } } else { task.Status = "completed" + task.Result = loopResult.Content + task.CompletedAt = time.Now().UnixMilli() + task.Iterations = loopResult.Iterations + task.ToolCalls = loopResult.ToolCalls + task.ToolStats = loopResult.ToolStats - // Notify conductor of the result + + task.PlanState = PlanCompleted + sm.reporter.ReportConversation(task.ID, "conductor", loopResult.Content) + sm.reporter.ReportGC(task.ID, "completed") - // Record subagent turn and completion in session DAG. + if sm.recorder != nil { subKey := routing.BuildSubagentSessionKey(task.ID) + _ = sm.recorder.RecordSubagentTurn(subKey, messages) + _ = sm.recorder.RecordCompletion(subKey, "completed", loopResult.Content) } + result = &ToolResult{ ForLLM: fmt.Sprintf( + "Subagent '%s' completed (iterations: %d, tool calls: %d): %s", + task.Label, + loopResult.Iterations, + loopResult.ToolCalls, + loopResult.Content, ), + ForUser: loopResult.Content, - Silent: false, - IsError: false, - Async: false, } } // Send announce message back to main agent + if sm.bus != nil { announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result) + metadata := map[string]string{ "duration_ms": strconv.FormatInt(task.CompletedAt-task.Created, 10), - "iterations": strconv.Itoa(task.Iterations), - "tool_calls": strconv.Itoa(task.ToolCalls), + + "iterations": strconv.Itoa(task.Iterations), + + "tool_calls": strconv.Itoa(task.ToolCalls), } + if len(task.ToolStats) > 0 { metadata["tool_stats"] = formatToolStats(task.ToolStats) } + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + sm.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Channel: "system", + Channel: "system", + SenderID: fmt.Sprintf("subagent:%s", task.ID), - // Format: "original_channel:original_chat_id" for routing back - ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID), - Content: announceContent, + + ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID), + + Content: announceContent, + Metadata: metadata, }) } } +// setPlanState updates task's plan state in memory and records status in session DAG. + +func (sm *SubagentManager) setPlanState(task *SubagentTask, state SubagentPlanState) { + task.PlanState = state + + if sm.recorder != nil { + subKey := routing.BuildSubagentSessionKey(task.ID) + + _ = sm.recorder.RecordCompletion(subKey, state.String(), "") + } +} + +// runExploratoryTask runs a single-phase tool loop for exploratory presets. + +func (sm *SubagentManager) runExploratoryTask( + ctx context.Context, + task *SubagentTask, + preset Preset, + callback AsyncCallback, +) { + systemPrompt := buildSubagentSystemPrompt(exploratorySystemPrompt(preset), sm.workspace) + + messages := []providers.Message{ + {Role: "system", Content: systemPrompt}, + + {Role: "user", Content: task.Task}, + } + + select { + case <-ctx.Done(): + + sm.mu.Lock() + + task.Status = "canceled" + + task.Result = "Task canceled before execution" + + sm.mu.Unlock() + + return + + default: + } + + sm.mu.RLock() + + reg := sm.tools + + if IsValidPreset(preset) { + reg = sm.buildPresetRegistry(preset, sm.workspace, task) + } + + maxIter := sm.maxIterations + + sm.mu.RUnlock() + + sm.reporter.ReportConversation("conductor", task.ID, task.Task) + + loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ + Provider: sm.provider, + + Model: sm.defaultModel, + + Tools: reg, + + MaxIterations: maxIter, + + LLMOptions: sm.getLLMOptions(), + + Reporter: sm.reporter, + + AgentID: task.ID, + }, messages, task.OriginChannel, task.OriginChatID) + + sm.finishTask(ctx, task, messages, loopResult, err, callback) +} + +// runDeliberateTask runs the clarifying → review → executing workflow. + +func (sm *SubagentManager) runDeliberateTask( + ctx context.Context, + task *SubagentTask, + preset Preset, + callback AsyncCallback, +) { + select { + case <-ctx.Done(): + + sm.mu.Lock() + + task.Status = "canceled" + + task.Result = "Task canceled before execution" + + sm.mu.Unlock() + + return + + default: + } + + sm.mu.RLock() + + reg := sm.buildPresetRegistry(preset, sm.workspace, task) + + maxIter := sm.maxIterations + + sm.mu.RUnlock() + + sm.reporter.ReportConversation("conductor", task.ID, task.Task) + + sm.setPlanState(task, PlanClarifying) + + // Phase 1: Clarifying — subagent gathers info and submits a plan. + + clarifyMsgs := []providers.Message{ + {Role: "system", Content: buildSubagentSystemPrompt(clarifyingSystemPrompt(), sm.workspace)}, + + {Role: "user", Content: task.Task}, + } + + clarifyResult, err := RunToolLoop(ctx, ToolLoopConfig{ + Provider: sm.provider, + + Model: sm.defaultModel, + + Tools: reg, + + MaxIterations: maxIter, + + LLMOptions: sm.getLLMOptions(), + + Reporter: sm.reporter, + + AgentID: task.ID, + }, clarifyMsgs, task.OriginChannel, task.OriginChatID) + if err != nil { + sm.finishTask(ctx, task, clarifyMsgs, nil, err, callback) + + return + } + + // After clarifying, the subagent should have used submit_plan. + + // If it didn't produce a plan, treat the clarifying result as direct completion. + + if task.PlanGoal == "" { + sm.finishTask(ctx, task, clarifyMsgs, clarifyResult, nil, callback) + + return + } + + // Phase 2: Executing — plan was approved, now execute it. + + sm.setPlanState(task, PlanExecuting) + + executeMsgs := []providers.Message{ + {Role: "system", Content: buildSubagentSystemPrompt(executingSystemPrompt(), sm.workspace)}, + + {Role: "user", Content: fmt.Sprintf("Execute the approved plan:\nGoal: %s\nSteps:\n%s", + + task.PlanGoal, formatPlanSteps(task.PlanSteps))}, + } + + execResult, err := RunToolLoop(ctx, ToolLoopConfig{ + Provider: sm.provider, + + Model: sm.defaultModel, + + Tools: reg, + + MaxIterations: maxIter * 2, // Executing gets more iterations + + LLMOptions: sm.getLLMOptions(), + + Reporter: sm.reporter, + + AgentID: task.ID, + }, executeMsgs, task.OriginChannel, task.OriginChatID) + + sm.finishTask(ctx, task, executeMsgs, execResult, err, callback) +} + +// formatPlanSteps formats plan steps as a numbered list. + +func formatPlanSteps(steps []string) string { + var b strings.Builder + + for i, step := range steps { + fmt.Fprintf(&b, "%d. %s\n", i+1, step) + } + + return b.String() +} + // buildPresetRegistry constructs a ToolRegistry for the given preset with appropriate restrictions. -func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string) *ToolRegistry { + +// If task is non-nil and has escalation channels, ask_conductor and submit_plan are registered. + +func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string, task ...*SubagentTask) *ToolRegistry { registry := NewToolRegistry() + config := SandboxConfigForPreset(preset, writeRoot) readRoot := writeRoot + if readRoot == "" { readRoot = sm.workspace } // Register read_file and list_dir with restrict=true + if config.AllowedTools["read_file"] { registry.Register(NewReadFileTool(readRoot, true)) } + if config.AllowedTools["list_dir"] { registry.Register(NewListDirTool(readRoot, true)) } // Register write tools only if allowed and writeRoot is set + if config.AllowedTools["write_file"] && writeRoot != "" { registry.Register(NewWriteFileTool(writeRoot, true)) + registry.Register(NewEditFileTool(writeRoot, true)) + registry.Register(NewAppendFileTool(writeRoot, true)) } // Register exec and bg_monitor if allowed. + // Each subagent gets its own ExecTool to avoid mutating the shared instance's + // allowRules (which would leak sandbox restrictions to the conductor). + if config.AllowedTools["exec"] { execWorkDir := writeRoot + if execWorkDir == "" { execWorkDir = sm.workspace } + execTool, err := NewExecTool(execWorkDir, true) if err != nil { // exec disabled for this subagent; skip registration + return registry } + if config.ExecPolicy != nil { execTool.SetAllowRules(config.ExecPolicy.AllowRules) + execTool.SetLocalNetOnly(config.ExecPolicy.LocalNetOnly) } + registry.Register(execTool) if config.AllowedTools["bg_monitor"] { @@ -387,58 +834,171 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string) } // Register git tools (worktree-safe push and PR creation) + if config.AllowedTools["git_push"] { registry.Register(NewGitPushTool()) } + if config.AllowedTools["create_pr"] { registry.Register(NewCreatePRTool()) } // Register web tools + if config.AllowedTools["web_search"] { webSearchTool, _ := NewWebSearchTool(sm.webSearchOpts) + if webSearchTool != nil { registry.Register(webSearchTool) } } + if config.AllowedTools["web_fetch"] { registry.Register(NewWebFetchTool(50000)) } // Register message tool (always available) + registry.Register(NewMessageTool()) // Register spawn tool only for coordinator preset + if config.AllowedTools["spawn"] && preset == PresetCoordinator { spawnTool := NewSpawnTool(sm) + registry.Register(spawnTool) } + // Register escalation tools for deliberate presets with channels. + + if len(task) > 0 && task[0] != nil && task[0].outCh != nil { + t := task[0] + + subKey := "subagent:" + t.ID + + registry.Register(NewAskConductorTool( + + t.ID, sm.conductorSessionKey, subKey, + + t.outCh, t.inCh, sm.recorder, + )) + + submitPlan := NewSubmitPlanTool( + + t.ID, sm.conductorSessionKey, subKey, + + t.outCh, t.inCh, sm.recorder, + ) + + submitPlan.SetPlanCallback(func(goal string, steps []string) { + t.PlanGoal = goal + + t.PlanSteps = steps + }) + + registry.Register(submitPlan) + } + return registry } +// PendingQuestions drains all outCh channels and returns pending container messages. + +// Non-blocking: reads all available messages without waiting. + +func (sm *SubagentManager) PendingQuestions() []ContainerMessage { + sm.mu.RLock() + + defer sm.mu.RUnlock() + + var msgs []ContainerMessage + + for _, task := range sm.tasks { + if task.outCh == nil { + continue + } + + for { + select { + case msg := <-task.outCh: + + msgs = append(msgs, msg) + + default: + + goto nextTask + } + } + + nextTask: + } + + return msgs +} + +// AnswerQuestion sends an answer to a subagent's inCh (non-blocking). + +func (sm *SubagentManager) AnswerQuestion(taskID, answer string) error { + sm.mu.RLock() + + task, ok := sm.tasks[taskID] + + sm.mu.RUnlock() + + if !ok { + return fmt.Errorf("task %q not found", taskID) + } + + if task.inCh == nil { + return fmt.Errorf("task %q has no escalation channel", taskID) + } + + select { + case task.inCh <- answer: + + return nil + + default: + + return fmt.Errorf("task %q answer channel full", taskID) + } +} + // WaitAll blocks until all spawned subagent goroutines have finished + // or the timeout expires. Returns true if all goroutines finished, + // false on timeout. + func (sm *SubagentManager) WaitAll(timeout time.Duration) bool { done := make(chan struct{}) + go func() { sm.wg.Wait() + close(done) }() + select { case <-done: + return true + case <-time.After(timeout): + return false } } // CancelTask cancels the context for a running subagent task. + func (sm *SubagentManager) CancelTask(taskID string) { sm.mu.RLock() + task, ok := sm.tasks[taskID] + sm.mu.RUnlock() + if ok && task.cancel != nil { task.cancel() } @@ -446,36 +1006,49 @@ func (sm *SubagentManager) CancelTask(taskID string) { func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.RLock() + defer sm.mu.RUnlock() + task, ok := sm.tasks[taskID] + return task, ok } func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() + defer sm.mu.RUnlock() tasks := make([]*SubagentTask, 0, len(sm.tasks)) + for _, task := range sm.tasks { tasks = append(tasks, task) } + return tasks } // SubagentTool executes a subagent task synchronously and returns the result. + // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion + // and returns the result directly in the ToolResult. + type SubagentTool struct { - manager *SubagentManager + manager *SubagentManager + originChannel string - originChatID string + + originChatID string } func NewSubagentTool(manager *SubagentManager) *SubagentTool { return &SubagentTool{ - manager: manager, + manager: manager, + originChannel: "cli", - originChatID: "direct", + + originChatID: "direct", } } @@ -490,30 +1063,39 @@ func (t *SubagentTool) Description() string { func (t *SubagentTool) Parameters() map[string]any { return map[string]any{ "type": "object", + "properties": map[string]any{ "task": map[string]any{ - "type": "string", + "type": "string", + "description": "The task for subagent to complete", }, + "label": map[string]any{ - "type": "string", + "type": "string", + "description": "Optional short label for the task (for display)", }, }, + "required": []string{"task"}, } } func (t *SubagentTool) SetContext(channel, chatID string) { t.originChannel = channel + t.originChatID = chatID } func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) + if !ok { return ErrorResult( + `Required parameter "task" (string) is missing. ` + + `Example: {"task": "describe what you need done"}`, ).WithError(fmt.Errorf("task parameter is required")) } @@ -526,85 +1108,218 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // Build messages for subagent + messages := []providers.Message{ { - Role: "system", + Role: "system", + Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", }, + { - Role: "user", + Role: "user", + Content: task, }, } // Use RunToolLoop to execute with tools (same as async SpawnTool) + sm := t.manager + sm.mu.RLock() + tools := sm.tools + maxIter := sm.maxIterations + maxTokens := sm.maxTokens + temperature := sm.temperature + hasMaxTokens := sm.hasMaxTokens + hasTemperature := sm.hasTemperature + sm.mu.RUnlock() var llmOptions map[string]any + if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} + if hasMaxTokens { llmOptions["max_tokens"] = maxTokens } + if hasTemperature { llmOptions["temperature"] = temperature } } loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ - Provider: sm.provider, - Model: sm.defaultModel, - Tools: tools, + Provider: sm.provider, + + Model: sm.defaultModel, + + Tools: tools, + MaxIterations: maxIter, - LLMOptions: llmOptions, + + LLMOptions: llmOptions, }, messages, t.originChannel, t.originChatID) if err != nil { return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } // ForUser: Brief summary for user (truncated if too long) + userContent := loopResult.Content + maxUserLen := 500 + if len(userContent) > maxUserLen { userContent = userContent[:maxUserLen] + "..." } // ForLLM: Full execution details + labelStr := label + if labelStr == "" { labelStr = "(unnamed)" } + llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nTool calls: %d\nResult: %s", + labelStr, loopResult.Iterations, loopResult.ToolCalls, loopResult.Content) return &ToolResult{ - ForLLM: llmContent, + ForLLM: llmContent, + ForUser: userContent, - Silent: false, + + Silent: false, + IsError: false, - Async: false, + + Async: false, } } // formatToolStats formats a tool stats map as a compact string: "exec:3,read_file:5". + // Keys are sorted alphabetically for deterministic output. + func formatToolStats(stats map[string]int) string { keys := make([]string, 0, len(stats)) + for k := range stats { keys = append(keys, k) } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { parts = append(parts, k+":"+strconv.Itoa(stats[k])) } + return strings.Join(parts, ",") } + +// extractPlanContext reads MEMORY.md from the workspace and extracts relevant + +// sections (Task, Context, Commands) to provide as subagent environment. + +func extractPlanContext(workspace string) string { + memPath := filepath.Join(workspace, "memory", "MEMORY.md") + + data, err := os.ReadFile(memPath) + if err != nil { + return "" + } + + content := string(data) + + var sections []string + + // Extract key sections by header. + + for _, header := range []string{"## Context", "## Commands", "## Orchestration"} { + if section := extractSection(content, header); section != "" { + sections = append(sections, section) + } + } + + // Also extract the task line from the header block. + + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "> Task:") { + sections = append([]string{strings.TrimSpace(line)}, sections...) + + break + } + } + + if len(sections) == 0 { + return "" + } + + return strings.Join(sections, "\n\n") +} + +// extractSection extracts a markdown section by header (including its content + +// until the next section of the same or higher level). + +func extractSection(content, header string) string { + idx := strings.Index(content, header) + + if idx < 0 { + return "" + } + + // Determine header level. + + level := 0 + + for _, c := range header { + if c == '#' { + level++ + } else { + break + } + } + + start := idx + + rest := content[idx+len(header):] + + // Find next section at same or higher level. + + nextHeader := "\n" + strings.Repeat("#", level) + " " + + end := strings.Index(rest, nextHeader) + + if end < 0 { + return strings.TrimSpace(content[start:]) + } + + return strings.TrimSpace(content[start : start+len(header)+end]) +} + +// buildSubagentSystemPrompt builds an enriched system prompt for a subagent + +// by combining the base prompt with environment context from MEMORY.md. + +func buildSubagentSystemPrompt(basePrompt, workspace string) string { + envContext := extractPlanContext(workspace) + + if envContext == "" { + return basePrompt + } + + return basePrompt + "\n\n## Environment Context\n\n" + envContext +} diff --git a/pkg/tools/subagent_container_test.go b/pkg/tools/subagent_container_test.go new file mode 100644 index 000000000..4d9b26a32 --- /dev/null +++ b/pkg/tools/subagent_container_test.go @@ -0,0 +1,125 @@ +package tools + +import ( + "testing" +) + +func TestIsDeliberatePreset(t *testing.T) { + tests := []struct { + preset Preset + want bool + }{ + {PresetScout, false}, + {PresetAnalyst, false}, + {PresetCoder, true}, + {PresetWorker, true}, + {PresetCoordinator, true}, + {"unknown", false}, + } + for _, tt := range tests { + got := isDeliberatePreset(tt.preset) + if got != tt.want { + t.Errorf("isDeliberatePreset(%q) = %v, want %v", tt.preset, got, tt.want) + } + } +} + +func TestContainerMessageChannels(t *testing.T) { + // Simulate channel creation for a deliberate preset task. + task := &SubagentTask{ + ID: "subagent-1", + inCh: make(chan string, 1), + outCh: make(chan ContainerMessage, 4), + } + + // Subagent sends a question. + task.outCh <- ContainerMessage{ + Type: "question", + Content: "Which DB schema?", + TaskID: task.ID, + } + + // Drain pending messages. + var msgs []ContainerMessage + for { + select { + case msg := <-task.outCh: + msgs = append(msgs, msg) + default: + goto done + } + } +done: + if len(msgs) != 1 { + t.Fatalf("msgs count = %d, want 1", len(msgs)) + } + if msgs[0].Type != "question" { + t.Errorf("Type = %q, want %q", msgs[0].Type, "question") + } + if msgs[0].Content != "Which DB schema?" { + t.Errorf("Content = %q, want %q", msgs[0].Content, "Which DB schema?") + } + + // Conductor answers. + task.inCh <- "Use PostgreSQL" + answer := <-task.inCh + if answer != "Use PostgreSQL" { + t.Errorf("answer = %q, want %q", answer, "Use PostgreSQL") + } +} + +func TestPendingQuestionsAndAnswerQuestion(t *testing.T) { + mgr := &SubagentManager{ + tasks: map[string]*SubagentTask{ + "subagent-1": { + ID: "subagent-1", + outCh: make(chan ContainerMessage, 4), + inCh: make(chan string, 1), + }, + "subagent-2": { + ID: "subagent-2", + // No channels — exploratory preset. + }, + }, + } + + // Send question from subagent-1. + mgr.tasks["subagent-1"].outCh <- ContainerMessage{ + Type: "question", + Content: "What port?", + TaskID: "subagent-1", + } + + msgs := mgr.PendingQuestions() + if len(msgs) != 1 { + t.Fatalf("pending count = %d, want 1", len(msgs)) + } + if msgs[0].TaskID != "subagent-1" { + t.Errorf("TaskID = %q, want %q", msgs[0].TaskID, "subagent-1") + } + + // Second call should return empty (already drained). + msgs2 := mgr.PendingQuestions() + if len(msgs2) != 0 { + t.Errorf("second pending count = %d, want 0", len(msgs2)) + } + + // Answer the question. + if err := mgr.AnswerQuestion("subagent-1", "8080"); err != nil { + t.Fatalf("AnswerQuestion: %v", err) + } + answer := <-mgr.tasks["subagent-1"].inCh + if answer != "8080" { + t.Errorf("answer = %q, want %q", answer, "8080") + } + + // Answer non-existent task. + if err := mgr.AnswerQuestion("subagent-99", "x"); err == nil { + t.Error("expected error for non-existent task") + } + + // Answer task without channels. + if err := mgr.AnswerQuestion("subagent-2", "x"); err == nil { + t.Error("expected error for task without escalation channel") + } +} diff --git a/pkg/tools/subagent_env_test.go b/pkg/tools/subagent_env_test.go new file mode 100644 index 000000000..aea6e6b91 --- /dev/null +++ b/pkg/tools/subagent_env_test.go @@ -0,0 +1,192 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractPlanContext(t *testing.T) { + tmpDir := t.TempDir() + + memDir := filepath.Join(tmpDir, "memory") + + if err := os.MkdirAll(memDir, 0o755); err != nil { + t.Fatal(err) + } + + memContent := `# Active Plan + +> Task: Implement authentication + +> Status: executing + +> Phase: 1 + + + +## Context + +The project uses JWT tokens for auth. + +Database is PostgreSQL. + + + +## Phase 1: Setup + +- [x] Add middleware + +- [ ] Add JWT validation + + + +## Commands + +build: go build ./... + +test: go test ./... + + + +## Orchestration + +### Delegated + +- auth-scout: investigate patterns + +### Findings + +- Found existing middleware in pkg/auth + +` + + if err := os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte(memContent), 0o644); err != nil { + t.Fatal(err) + } + + ctx := extractPlanContext(tmpDir) + + if ctx == "" { + t.Fatal("extractPlanContext returned empty") + } + + // Should contain the task line. + + if !strings.Contains(ctx, "> Task: Implement authentication") { + t.Error("missing task line") + } + + // Should contain Context section. + + if !strings.Contains(ctx, "JWT tokens") { + t.Error("missing Context section content") + } + + // Should contain Commands section. + + if !strings.Contains(ctx, "go build") { + t.Error("missing Commands section content") + } + + // Should contain Orchestration section. + + if !strings.Contains(ctx, "auth-scout") { + t.Error("missing Orchestration section content") + } +} + +func TestExtractPlanContext_NoFile(t *testing.T) { + ctx := extractPlanContext(t.TempDir()) + + if ctx != "" { + t.Errorf("expected empty for missing MEMORY.md, got %q", ctx) + } +} + +func TestExtractSection(t *testing.T) { + content := `## Context + +Some context here. + + + +## Commands + +build: go build + + + +## Other + +stuff` + + section := extractSection(content, "## Context") + + if !strings.Contains(section, "Some context here.") { + t.Errorf("Context section = %q, missing content", section) + } + + if strings.Contains(section, "## Commands") { + t.Errorf("Context section leaked into next section") + } + + section = extractSection(content, "## Commands") + + if !strings.Contains(section, "go build") { + t.Errorf("Commands section = %q, missing content", section) + } + + section = extractSection(content, "## Nonexistent") + + if section != "" { + t.Errorf("expected empty for nonexistent section, got %q", section) + } +} + +func TestBuildSubagentSystemPrompt(t *testing.T) { + // With no workspace/MEMORY.md, should return base prompt unchanged. + + base := "You are a subagent." + + got := buildSubagentSystemPrompt(base, t.TempDir()) + + if got != base { + t.Errorf("expected base prompt unchanged, got %q", got) + } + + // With MEMORY.md, should append environment context. + + tmpDir := t.TempDir() + + memDir := filepath.Join(tmpDir, "memory") + + os.MkdirAll(memDir, 0o755) + + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte(`# Plan + +> Task: Test task + + + +## Context + +Test context info. + +`), 0o644) + + got = buildSubagentSystemPrompt(base, tmpDir) + + if !strings.Contains(got, base) { + t.Error("result should contain base prompt") + } + + if !strings.Contains(got, "Environment Context") { + t.Error("result should contain Environment Context header") + } + + if !strings.Contains(got, "Test context info") { + t.Error("result should contain MEMORY.md context") + } +} diff --git a/pkg/tools/subagent_plan_test.go b/pkg/tools/subagent_plan_test.go new file mode 100644 index 000000000..dd4cc8533 --- /dev/null +++ b/pkg/tools/subagent_plan_test.go @@ -0,0 +1,98 @@ +package tools + +import ( + "testing" +) + +func TestSubagentPlanStateString(t *testing.T) { + tests := []struct { + state SubagentPlanState + want string + }{ + {PlanNone, "none"}, + {PlanClarifying, "clarifying"}, + {PlanReview, "review"}, + {PlanExecuting, "executing"}, + {PlanCompleted, "completed"}, + } + for _, tt := range tests { + got := tt.state.String() + if got != tt.want { + t.Errorf("SubagentPlanState(%d).String() = %q, want %q", tt.state, got, tt.want) + } + } +} + +func TestFormatPlanSteps(t *testing.T) { + steps := []string{"Read config", "Add middleware", "Write tests"} + got := formatPlanSteps(steps) + want := "1. Read config\n2. Add middleware\n3. Write tests\n" + if got != want { + t.Errorf("formatPlanSteps = %q, want %q", got, want) + } +} + +func TestClarifyingSystemPrompt(t *testing.T) { + prompt := clarifyingSystemPrompt() + if prompt == "" { + t.Error("clarifyingSystemPrompt returned empty string") + } + // Should mention ask_conductor and submit_plan. + for _, keyword := range []string{"ask_conductor", "submit_plan", "CLARIFYING"} { + if !containsString(prompt, keyword) { + t.Errorf("clarifyingSystemPrompt missing keyword %q", keyword) + } + } +} + +func TestExecutingSystemPrompt(t *testing.T) { + prompt := executingSystemPrompt() + if prompt == "" { + t.Error("executingSystemPrompt returned empty string") + } + if !containsString(prompt, "EXECUTING") { + t.Error("executingSystemPrompt missing keyword EXECUTING") + } +} + +func TestExploratorySystemPrompt(t *testing.T) { + scoutPrompt := exploratorySystemPrompt(PresetScout) + if scoutPrompt == "" { + t.Error("exploratorySystemPrompt(scout) returned empty") + } + defaultPrompt := exploratorySystemPrompt("unknown") + if defaultPrompt == "" { + t.Error("exploratorySystemPrompt(unknown) returned empty") + } + if scoutPrompt == defaultPrompt { + t.Error("scout and unknown prompts should differ") + } +} + +func TestDeliberateTaskChannelsCreated(t *testing.T) { + // Verify that channels and initial state are correct for deliberate presets. + task := &SubagentTask{ + inCh: make(chan string, 1), + outCh: make(chan ContainerMessage, 4), + } + if task.inCh == nil || task.outCh == nil { + t.Fatal("expected channels to be non-nil for deliberate task") + } + if task.PlanState != PlanNone { + t.Errorf("initial PlanState = %v, want PlanNone", task.PlanState) + } +} + +// containsString checks if s contains substr. +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/pkg/tools/submit_plan.go b/pkg/tools/submit_plan.go new file mode 100644 index 000000000..ddda70cdd --- /dev/null +++ b/pkg/tools/submit_plan.go @@ -0,0 +1,186 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// SubmitPlanTool allows a subagent to submit a plan for conductor review. + +// The subagent blocks until the conductor approves or rejects. + +type SubmitPlanTool struct { + taskID string + + conductorKey string + + subagentKey string + + outCh chan<- ContainerMessage + + inCh <-chan string + + recorder SessionRecorder + + setPlan func(goal string, steps []string) // callback to record plan on task +} + +func NewSubmitPlanTool( + taskID, conductorKey, subagentKey string, + + outCh chan<- ContainerMessage, + + inCh <-chan string, + + recorder SessionRecorder, +) *SubmitPlanTool { + return &SubmitPlanTool{ + taskID: taskID, + + conductorKey: conductorKey, + + subagentKey: subagentKey, + + outCh: outCh, + + inCh: inCh, + + recorder: recorder, + } +} + +// SetPlanCallback sets the function called when a plan is approved to record + +// the goal and steps on the parent SubagentTask. + +func (t *SubmitPlanTool) SetPlanCallback(fn func(goal string, steps []string)) { + t.setPlan = fn +} + +func (t *SubmitPlanTool) Name() string { return "submit_plan" } + +func (t *SubmitPlanTool) Description() string { + return "Submit your execution plan for conductor review. Blocks until the conductor approves or rejects. On rejection, revise and resubmit." +} + +func (t *SubmitPlanTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + + "properties": map[string]any{ + "goal": map[string]any{ + "type": "string", + + "description": "The goal of the plan", + }, + + "steps": map[string]any{ + "type": "array", + + "description": "Ordered list of steps to execute", + + "items": map[string]any{"type": "string"}, + }, + }, + + "required": []string{"goal", "steps"}, + } +} + +func (t *SubmitPlanTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + goal, _ := args["goal"].(string) + + if goal == "" { + return ErrorResult("required parameter \"goal\" (string) is missing") + } + + stepsRaw, _ := args["steps"] + + var steps []string + + switch v := stepsRaw.(type) { + case []any: + + steps = make([]string, 0, len(v)) + + for _, s := range v { + if str, ok := s.(string); ok { + steps = append(steps, str) + } + } + + case []string: + + steps = v + } + + if len(steps) == 0 { + return ErrorResult("required parameter \"steps\" (array of strings) is missing or empty") + } + + // Build plan text for recording and display. + + var b strings.Builder + + b.WriteString("Goal: ") + + b.WriteString(goal) + + b.WriteByte('\n') + + for i, step := range steps { + fmt.Fprintf(&b, "%d. %s\n", i+1, step) + } + + planText := b.String() + + // Record in session DAG. + + if t.recorder != nil { + _ = t.recorder.RecordPlanSubmit(t.conductorKey, t.subagentKey, t.taskID, planText) + } + + // Encode plan as JSON for the conductor. + + planJSON, _ := json.Marshal(map[string]any{"goal": goal, "steps": steps}) + + // Send plan_review to conductor. + + select { + case t.outCh <- ContainerMessage{Type: "plan_review", Content: string(planJSON), TaskID: t.taskID}: + + case <-ctx.Done(): + + return ErrorResult(fmt.Sprintf("context canceled while submitting plan: %v", ctx.Err())) + } + + // Wait for conductor's decision. + + select { + case decision := <-t.inCh: + + if strings.HasPrefix(decision, "approved") { + if t.setPlan != nil { + t.setPlan(goal, steps) + } + + return &ToolResult{ + ForLLM: "Plan approved by conductor. Proceed with execution.", + + ForUser: "Plan approved", + } + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Plan rejected by conductor: %s\nRevise your plan and resubmit.", decision), + + ForUser: fmt.Sprintf("Plan rejected: %s", decision), + } + + case <-ctx.Done(): + + return ErrorResult(fmt.Sprintf("context canceled while waiting for review: %v", ctx.Err())) + } +} diff --git a/pkg/tools/submit_plan_test.go b/pkg/tools/submit_plan_test.go new file mode 100644 index 000000000..0345c8278 --- /dev/null +++ b/pkg/tools/submit_plan_test.go @@ -0,0 +1,229 @@ +package tools + +import ( + "context" + "testing" + "time" +) + +func TestSubmitPlanTool_Execute_Approved(t *testing.T) { + outCh := make(chan ContainerMessage, 4) + + inCh := make(chan string, 1) + + tool := NewSubmitPlanTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil) + + var gotGoal string + + var gotSteps []string + + tool.SetPlanCallback(func(goal string, steps []string) { + gotGoal = goal + + gotSteps = steps + }) + + if tool.Name() != "submit_plan" { + t.Errorf("Name() = %q, want %q", tool.Name(), "submit_plan") + } + + // Simulate conductor approving in background. + + go func() { + msg := <-outCh + + if msg.Type != "plan_review" { + t.Errorf("msg.Type = %q, want %q", msg.Type, "plan_review") + } + + inCh <- "approved" + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + result := tool.Execute(ctx, map[string]any{ + "goal": "Add auth", + + "steps": []any{"Add middleware", "Add JWT"}, + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + if gotGoal != "Add auth" { + t.Errorf("setPlan goal = %q, want %q", gotGoal, "Add auth") + } + + if len(gotSteps) != 2 { + t.Errorf("setPlan steps count = %d, want 2", len(gotSteps)) + } +} + +func TestSubmitPlanTool_Execute_Rejected(t *testing.T) { + outCh := make(chan ContainerMessage, 4) + + inCh := make(chan string, 1) + + tool := NewSubmitPlanTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil) + + var planSet bool + + tool.SetPlanCallback(func(goal string, steps []string) { + planSet = true + }) + + go func() { + <-outCh + + inCh <- "rejected: needs more detail on step 2" + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + result := tool.Execute(ctx, map[string]any{ + "goal": "Add auth", + + "steps": []any{"Add middleware"}, + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + if planSet { + t.Error("setPlan should NOT be called on rejection") + } + + if result.ForLLM == "" { + t.Error("ForLLM should contain rejection message") + } +} + +func TestSubmitPlanTool_MissingParams(t *testing.T) { + tool := NewSubmitPlanTool("subagent-1", "", "", nil, nil, nil) + + result := tool.Execute(context.Background(), map[string]any{}) + + if !result.IsError { + t.Error("expected error for missing goal") + } + + result = tool.Execute(context.Background(), map[string]any{"goal": "test"}) + + if !result.IsError { + t.Error("expected error for missing steps") + } +} + +func TestSubmitPlanTool_ContextCanceled(t *testing.T) { + outCh := make(chan ContainerMessage) // unbuffered + + inCh := make(chan string) + + tool := NewSubmitPlanTool("subagent-1", "", "", outCh, inCh, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + cancel() + + result := tool.Execute(ctx, map[string]any{ + "goal": "test", + + "steps": []any{"step1"}, + }) + + if !result.IsError { + t.Error("expected error on canceled context") + } +} + +func TestAnswerSubagentTool_Execute(t *testing.T) { + mgr := &SubagentManager{ + tasks: map[string]*SubagentTask{ + "subagent-1": { + ID: "subagent-1", + + inCh: make(chan string, 1), + }, + }, + } + + tool := NewAnswerSubagentTool(mgr) + + if tool.Name() != "answer_subagent" { + t.Errorf("Name() = %q, want %q", tool.Name(), "answer_subagent") + } + + result := tool.Execute(context.Background(), map[string]any{ + "task_id": "subagent-1", + + "answer": "Use port 8080", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + // Verify the answer was sent. + + answer := <-mgr.tasks["subagent-1"].inCh + + if answer != "Use port 8080" { + t.Errorf("answer = %q, want %q", answer, "Use port 8080") + } +} + +func TestAnswerSubagentTool_MissingParams(t *testing.T) { + tool := NewAnswerSubagentTool(nil) + + result := tool.Execute(context.Background(), map[string]any{}) + + if !result.IsError { + t.Error("expected error for missing task_id") + } + + result = tool.Execute(context.Background(), map[string]any{"task_id": "x"}) + + if !result.IsError { + t.Error("expected error for missing answer") + } +} + +func TestReviewSubagentPlanTool_Execute(t *testing.T) { + mgr := &SubagentManager{ + tasks: map[string]*SubagentTask{ + "subagent-1": { + ID: "subagent-1", + + inCh: make(chan string, 1), + }, + }, + } + + tool := NewReviewSubagentPlanTool(mgr) + + if tool.Name() != "review_subagent_plan" { + t.Errorf("Name() = %q, want %q", tool.Name(), "review_subagent_plan") + } + + result := tool.Execute(context.Background(), map[string]any{ + "task_id": "subagent-1", + + "decision": "approved", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + decision := <-mgr.tasks["subagent-1"].inCh + + if decision != "approved" { + t.Errorf("decision = %q, want %q", decision, "approved") + } +} diff --git a/todo/TASKS-2.md b/todo/TASKS-2.md index 9a54c9341..e20446678 100644 --- a/todo/TASKS-2.md +++ b/todo/TASKS-2.md @@ -1,4 +1,4 @@ -# TASKS-2: Subagent Orchestration (Container Model) +# TASKS-2: Subagent Orchestration (Container Model) ✅ 実装済み ## TASKS-1 反映メモ (2026-03-05) @@ -78,15 +78,15 @@ TASKS-2 の下地はかなり実装済み。以下を前提として差分のみ | **Async Callback** | `pkg/agent/loop.go` `processRequest` | spawn 完了 → MessageBus → conductor に結果注入 | | **Orchestration Nudge** | `pkg/agent/loop.go` `buildOrchReminder()` | plan 実行中に spawn/subagent 使用を促すリマインダ | -### 未実装 ❌ → TASKS-2 スコープ +### 実装完了 ✅ (2026-03-05) | # | 要素 | 概要 | |---|---|---| -| 1 | **ContainerMessage channel** | subagent→conductor の question/status/result 双方向通信 | -| 2 | **Escalation chain** | subagent question → conductor 回答 or → human escalate | -| 3 | **Deliberate Plan Mode** | coder/worker/coordinator の clarifying→review→executing 状態遷移 | -| 4 | **SubagentEnvironment injection** | MEMORY.md からの自動コンテキスト注入 | -| 5 | **MEMORY.md Orchestration section** | conductor guidance に delegated/findings/decisions 追記 | +| 1 | **ContainerMessage channel** | ✅ `ContainerMessage` + `inCh`/`outCh` on `SubagentTask` | +| 2 | **Escalation chain** | ✅ `ask_conductor` / `answer_subagent` tools + conductor question injection | +| 3 | **Deliberate Plan Mode** | ✅ `SubagentPlanState` + `runDeliberateTask()` (clarifying→review→executing) | +| 4 | **SubagentEnvironment injection** | ✅ `extractPlanContext()` + `buildSubagentSystemPrompt()` | +| 5 | **MEMORY.md Orchestration section** | ✅ `orchestrationGuidance` 拡張 (Delegated/Findings/Decisions) | ---