Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a297669b4 | ||
|
|
2e351ccf69 | ||
|
|
1d50b94eec | ||
|
|
00e29139c5 | ||
|
|
3b660e09a8 | ||
|
|
0d6f558b2b | ||
|
|
8388a83af0 | ||
|
|
104b0daf4c | ||
|
|
65647ce517 |
@@ -1,3 +1,21 @@
|
||||
# [1.2.0](https://github.com/asepharyana/zesdex/compare/v1.1.0...v1.2.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enhance responsiveness by implementing abort checks in streaming API calls ([0d6f558](https://github.com/asepharyana/zesdex/commit/0d6f558b2bd0282a7a1695f7680ab1d1c6142579))
|
||||
* refactor agent step limits and enhance workflow orchestration with new findings tool ([3b660e0](https://github.com/asepharyana/zesdex/commit/3b660e09a87f3e982db94f48d2282ddb63116341))
|
||||
* remove pipeline command and refactor workflow execution to use custom specialists ([00e2913](https://github.com/asepharyana/zesdex/commit/00e29139c53c5fed4c13b0493297dd9da984460c))
|
||||
* update overlay handling in apply_action and remove mouse capture from terminal execution ([2e351cc](https://github.com/asepharyana/zesdex/commit/2e351ccf6930ff4823f55b581308222229fe6684))
|
||||
* update README and documentation for new tools and features ([1d50b94](https://github.com/asepharyana/zesdex/commit/1d50b94eec1ed82dfc40d43d41bd01aeb79edfe1))
|
||||
|
||||
# [1.1.0](https://github.com/asepharyana/zesdex/compare/v1.0.4...v1.1.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement abort mechanism for workflows and subagents ([104b0da](https://github.com/asepharyana/zesdex/commit/104b0daf4cc51581de04f03d6b727cdb16f9b6c3))
|
||||
|
||||
## [1.0.4](https://github.com/asepharyana/zesdex/compare/v1.0.3...v1.0.4) (2026-07-13)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Tracing output goes to `~/.local/share/zesdex/zesdex.log`. Set `RUST_LOG=debug`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 28 built-in tools.
|
||||
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 37 built-in tools.
|
||||
|
||||
Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
|
||||
@@ -62,7 +62,7 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
Controller (key input → Action) → Event Loop → LLM stream → Tool execution → State mutation → TUI render
|
||||
│ │ │
|
||||
│ src/controller/input.rs │ src/app/runtime/actions/ │ src/tool/
|
||||
└── maps keys to Action enum │── dispatches Action::* └── 28 tool impls
|
||||
└── maps keys to Action enum │── dispatches Action::* └── 37 tool impls
|
||||
│ matching on Action variant
|
||||
│── applies state mutations
|
||||
```
|
||||
@@ -167,3 +167,4 @@ Rules:
|
||||
- Non-trivial private functions (≥10 lines) need a doc comment
|
||||
- Write the comment above the code it documents (not inline in the body)
|
||||
- Update comments when code behavior changes — stale docs are worse than no docs
|
||||
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Always fix the underlying code issues instead.
|
||||
|
||||
Generated
+387
-256
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zesdex"
|
||||
version = "1.0.4"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
@@ -23,9 +23,9 @@ pedantic = { level = "warn", priority = -2 }
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30.2"
|
||||
crossterm = "0.28"
|
||||
crossterm = "0.29"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] }
|
||||
reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
|
||||
dom_smoothie = "0.18.0"
|
||||
fast_html2md = "0.0.62"
|
||||
scraper = "0.27.0"
|
||||
@@ -33,23 +33,24 @@ url = "2"
|
||||
percent-encoding = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.9"
|
||||
serde_yaml_ng = "0.10"
|
||||
anyhow = "1"
|
||||
include_dir = "0.7"
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
dirs = "5"
|
||||
dirs = "6"
|
||||
futures-util = "0.3"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
globset = "0.4"
|
||||
infer = "0.16"
|
||||
infer = "0.19"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
libc = "0.2"
|
||||
rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
tracing = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -15,7 +15,7 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
|
||||
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
|
||||
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
|
||||
|
||||
### Tool System (34 built-in tools)
|
||||
### Tool System (37 built-in tools)
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
@@ -25,8 +25,9 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
|
||||
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
|
||||
| **Memory** | `remember`, `recall`, `forget` |
|
||||
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
|
||||
| **Workflow** | `workflow_run`, `note_finding`, `company_pipeline` |
|
||||
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `company_pipeline` |
|
||||
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
|
||||
| **Agent** | `spawn_agents`, `spawn_pipeline` |
|
||||
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
|
||||
|
||||
### Intelligence
|
||||
@@ -83,6 +84,7 @@ src/
|
||||
│ │ ├── effort.rs # Effort level selector
|
||||
│ │ ├── help.rs # Help overlay
|
||||
│ │ ├── key_input.rs # Raw key input mode
|
||||
│ │ ├── learning.rs # Lesson management overlay
|
||||
│ │ ├── loading.rs # Loading spinner overlay
|
||||
│ │ ├── mcp.rs # MCP server management
|
||||
│ │ ├── quit_confirm.rs # Quit confirmation dialog
|
||||
|
||||
@@ -28,7 +28,7 @@ Zesdex is a single-process terminal AI coding agent with optional daemon/client
|
||||
│ │ │ │
|
||||
│ ┌─────▼──┐ ┌───▼────┐ │
|
||||
│ │ Tools │ │Sub- │ │
|
||||
│ │ (28) │ │agents │ │
|
||||
│ │ (37) │ │agents │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -55,7 +55,7 @@ User keystroke → Controller (KeyEvent → Action)
|
||||
|
||||
| File | Lines | Role |
|
||||
|------|-------|------|
|
||||
| `src/main.rs` | 530 | Entry, TUI setup, daemon loop, attach loop |
|
||||
| `src/app/runtime/actions/mod.rs` | 1022 | Action dispatch + LLM stream loop + tool execution |
|
||||
| `src/controller/input.rs` | 281 | Key event → Action mapping |
|
||||
| `src/view/mod.rs` | 623 | TUI rendering (ratatui) |
|
||||
| `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
|
||||
| `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
|
||||
| `src/controller/input.rs` | 365 | Key event → Action mapping |
|
||||
| `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
|
||||
|
||||
+23
-16
@@ -4,7 +4,7 @@
|
||||
|
||||
## AI Provider
|
||||
|
||||
`src/service/provider.rs` (258 lines)
|
||||
`src/service/provider.rs` (310 lines)
|
||||
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
|
||||
- `chat_with_tools()` — non-streaming with tool definitions
|
||||
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
|
||||
@@ -18,51 +18,58 @@
|
||||
|
||||
## IPC / Daemon
|
||||
|
||||
`src/ipc/` (7 files, ~300 lines total)
|
||||
`src/ipc/` (7 files, ~350 lines total)
|
||||
- Unix domain socket, length-prefixed JSON frames
|
||||
- Daemon sends `DaemonFrame { state: StatePayload, diff, tasks }` to clients
|
||||
- Clients send `ClientRequest { action: Action }` back
|
||||
- State sync uses snapshots + binary diffs (rsync-style, not git)
|
||||
- Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
|
||||
- Clients send `ClientRequest` (key presses, resize, submit, scroll)
|
||||
- State sync uses full-state push from daemon to client after each action
|
||||
|
||||
## Workflow Engine
|
||||
|
||||
`src/app/workflow/engine.rs` (251 lines) + `script.rs`
|
||||
`src/app/workflow/engine.rs` (648 lines) + `script.rs`
|
||||
- Inline JS-style DSL executed by a lightweight runtime
|
||||
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
|
||||
- Max concurrency configurable via `workflow_max_concurrency` setting
|
||||
- Company pipeline orchestrator in `company.rs` (406 lines): full 5-division or quick 3-division pipelines
|
||||
|
||||
## Sub-Agent System
|
||||
|
||||
`src/app/subagent/` (4 files, ~250 lines)
|
||||
- `run_subagent()` — spawns independent agent with its own tool set & context
|
||||
`src/app/subagent/` (6 files: `spawn.rs`, `engine.rs`, `context.rs`, `event.rs`, `division.rs`, `auto.rs`, ~450 lines total)
|
||||
- `run_subagent()` — spawns independent agent with its own tool set and context
|
||||
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
|
||||
- Uses `LlmClient` (same as main agent) with tool-use API
|
||||
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
|
||||
- Division roles: Strategy, Engineering, Quality, Security, Documentation
|
||||
|
||||
## MCP Client
|
||||
|
||||
`src/app/mcp/manager.rs` (371 lines)
|
||||
`src/app/mcp/manager.rs` (441+ lines)
|
||||
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout
|
||||
- HTTP transport: streaming HTTP with JSON-RPC
|
||||
- Tool registration: `tools/list` → `McpToolAdapter` implements `crate::tool::Tool`
|
||||
- Dynamic tool list refresh and error recovery
|
||||
- Persistent child handle for stdio (reuses connection across calls)
|
||||
|
||||
## Self-Review
|
||||
|
||||
`src/app/review/mod.rs` (437 lines)
|
||||
`src/app/review/mod.rs` (495 lines)
|
||||
- Post-tool execution quality check against learned lessons
|
||||
- Invokes `run_subagent()` with reviewer prompt
|
||||
- Staleness detection: skips review after N consecutive empty results
|
||||
- Three review types: code quality, architecture, security
|
||||
|
||||
## Background Bash
|
||||
|
||||
`src/app/bgbash/` (2 files)
|
||||
`src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
|
||||
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
|
||||
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
|
||||
- Killable via PID
|
||||
- Killable via PID (SIGTERM)
|
||||
- Output buffering capped at 10,000 lines to prevent memory issues
|
||||
|
||||
## Gate Guard / Harness
|
||||
|
||||
`src/app/harness.rs` (127 lines)
|
||||
`src/app/harness.rs` (495 lines)
|
||||
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
|
||||
- Parses LLM verdicts (JSON or plain-text)
|
||||
- `test_parse_verdict_*` tests for 6 verdict formats
|
||||
- Path traversal, credential read, and destructive command detection
|
||||
- Pattern detection for stub code, denial language, and assumptions in write/edit content
|
||||
- Reason validation for mutating tools (minimum 8 characters, rejects generic non-answers)
|
||||
- Includes 8 unit tests for verdict parsing formats
|
||||
|
||||
@@ -16,7 +16,7 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
|
||||
│ └── *.md # Markdown with YAML frontmatter
|
||||
├── sessions/ # Per-session data
|
||||
│ └── <session-uuid>/
|
||||
│ ├── editlog.json # Edit history
|
||||
│ ├── edits.jsonl # Edit history (JSONL, append-only)
|
||||
│ ├── msglog.db # SQLite message log
|
||||
│ ├── transcript.json # Chat transcript
|
||||
│ ├── session.json # Session metadata
|
||||
@@ -34,8 +34,8 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
|
||||
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) |
|
||||
| `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
|
||||
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
|
||||
| `src/model/memory.rs` | 332 | Memory CRUD — markdown files with frontmatter |
|
||||
| `src/model/editlog.rs` | 121 | Edit log — append-only JSON array |
|
||||
| `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
|
||||
| `src/model/editlog.rs` | 161 | Edit log — append-only JSONL (not JSON array) |
|
||||
| `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
|
||||
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
|
||||
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
|
||||
|
||||
@@ -7,23 +7,23 @@
|
||||
| Crate | Version | Purpose |
|
||||
|-------|---------|---------|
|
||||
| ratatui | 0.30 | TUI framework (tui-rs successor) |
|
||||
| crossterm | 0.28 | Terminal manipulation (raw mode, alt screen) |
|
||||
| crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
|
||||
| tokio | 1 | Async runtime (daemon, OAuth loopback) |
|
||||
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
|
||||
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
|
||||
| serde_yaml_ng | 0.9 | YAML frontmatter parsing (memory files) |
|
||||
| serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
|
||||
| anyhow | 1 | Error handling (no custom error types) |
|
||||
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
|
||||
| rusqlite | 0.32 | SQLite (bundled, for message log) |
|
||||
| rusqlite | 0.40 | SQLite (bundled, for message log) |
|
||||
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
|
||||
| syntect | 5 | Syntax highlighting (code blocks in chat) |
|
||||
| sha2 | 0.10 | SHA-256 for PKCE challenge |
|
||||
| base64 | 0.22 | URL-safe base64 for PKCE |
|
||||
| libc | 0.2 | daemon PID file locking |
|
||||
| rmcp | 1.8 | MCP client (stdio + HTTP transports) |
|
||||
| rmcp | 2.2 | MCP client (stdio + HTTP transports) |
|
||||
| uuid | 1 | Session IDs, job IDs |
|
||||
| chrono | 0.4 | Timestamps (ISO 8601, millis) |
|
||||
| dirs | 5 | Platform data directories |
|
||||
| dirs | 6 | Platform data directories |
|
||||
| dom_smoothie | 0.18 | HTML → plain text (web scraping) |
|
||||
| scraper | 0.27 | HTML parsing (web scraping) |
|
||||
| ignore | 0.4 | .gitignore-aware file walking (glob tool) |
|
||||
|
||||
@@ -9,6 +9,7 @@ Review guidelines:
|
||||
2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases.
|
||||
3. Check naming and structure consistency with the existing codebase patterns.
|
||||
4. Check that the implementation matches the apparent intent.
|
||||
5. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to silence warnings or skip linter checks. Reject them.
|
||||
|
||||
Output: a concise 2-4 line verdict. If you find issues, be specific about what and where.
|
||||
Skip if the file is trivial (config, tests with no logic changes).
|
||||
|
||||
@@ -14,6 +14,7 @@ Full access: read, write, edit, delete, bash, grep, glob, git_operator, lsp_*, s
|
||||
6. Run `cargo build` or equivalent after each logical chunk.
|
||||
7. If you encounter an issue not covered by the plan, use `note_finding` to flag it.
|
||||
8. Update todo.md as you complete each file: `todofinish`
|
||||
9. NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to skip or silence warnings. Fix the underlying code to adhere to linter guidelines.
|
||||
|
||||
## Output
|
||||
After each file: confirm what was implemented and any deviations from plan.
|
||||
|
||||
@@ -31,4 +31,5 @@ You MUST produce a structured plan covering:
|
||||
- Use `seqthink` for complex reasoning steps
|
||||
- Every plan MUST include at least one mermaid diagram
|
||||
- Be specific with file paths and function names
|
||||
- Ensure implementation plans NEVER suggest or allow using compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings; always plan to fully resolve underlying code issues.
|
||||
- Output ends with a clear "Plan Complete" marker
|
||||
|
||||
@@ -9,6 +9,7 @@ Check for:
|
||||
- Stubs, placeholders, incomplete branches
|
||||
- Naming consistency with codebase conventions
|
||||
- Error handling coverage
|
||||
- Absence of compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings
|
||||
|
||||
## Phase 2: Test
|
||||
Use write to create test files. Follow these rules:
|
||||
|
||||
@@ -9,6 +9,7 @@ Review guidelines:
|
||||
2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws.
|
||||
4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture.
|
||||
5. Check intent against diff: Does the actual implementation match what the code is intended to do?
|
||||
6. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to skip warnings. Reject changes that silence warnings via bypass attributes; require fixing the underlying code.
|
||||
|
||||
If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits.
|
||||
|
||||
|
||||
@@ -88,3 +88,4 @@ Available tools are described in system-tools.txt section. Key tools for orchest
|
||||
- After changes, run builds and tests
|
||||
- Use LSP diagnostics after each file edit
|
||||
- Every code path must be fully implemented and deterministic
|
||||
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Fix the underlying code issues instead.
|
||||
|
||||
+1
-1
@@ -430,7 +430,7 @@ mod tests {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if l.starts_with("verdict: block") {
|
||||
let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string();
|
||||
let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
|
||||
return Some(Verdict::Block(reason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
tool: "rewind".to_string(),
|
||||
path: restore_path.to_string_lossy().to_string(),
|
||||
reason: format!("rewind_to({index})"),
|
||||
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)),
|
||||
content_sha256: hex::encode(sha2::Sha256::digest(&bytes)),
|
||||
bytes_delta: bytes.len() as i64,
|
||||
origin: crate::app::state::types::Origin::Main.tag(),
|
||||
session_id: state.session_id.clone(),
|
||||
|
||||
+206
-160
@@ -84,10 +84,6 @@ pub enum Action {
|
||||
RunWorkflow {
|
||||
script: String,
|
||||
},
|
||||
/// User-initiated pipeline via `/pipeline full|quick|skip`.
|
||||
RunPipeline {
|
||||
mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Apply an `Action` to the application state.
|
||||
@@ -393,6 +389,11 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.workflow_engine.agents.clear();
|
||||
state.workflow_engine.findings.clear();
|
||||
}
|
||||
if message.to_lowercase().contains("complete") {
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
}
|
||||
}
|
||||
state.push_toast(Toast {
|
||||
kind: ToastKind::Info,
|
||||
message: message.clone(),
|
||||
@@ -529,10 +530,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
if turn_finished {
|
||||
// Consume pipeline override after each turn so it doesn't
|
||||
// persist across multiple submissions.
|
||||
state.misc.pipeline_override = None;
|
||||
maybe_trigger_review(state);
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
}
|
||||
}
|
||||
if turn_finished || state.dirty {
|
||||
state.dirty = true;
|
||||
@@ -593,30 +594,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {name}")));
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::RunPipeline { mode } => {
|
||||
match mode.as_str() {
|
||||
"full" => {
|
||||
state.misc.pipeline_override = Some("full".to_string());
|
||||
state.push_toast(Toast::new(ToastKind::Info, "Pipeline mode: full (5 divisions) — next request will run Strategy→Engineering→Quality→Security→Documentation".to_string()));
|
||||
}
|
||||
"quick" => {
|
||||
state.misc.pipeline_override = Some("quick".to_string());
|
||||
state.push_toast(Toast::new(ToastKind::Info, "Pipeline mode: quick (3 divisions) — next request will run Strategy→Engineering→Quality".to_string()));
|
||||
}
|
||||
"skip" => {
|
||||
state.misc.pipeline_override = Some("skip".to_string());
|
||||
state.push_toast(Toast::new(ToastKind::Info, "Pipeline mode: skip — next request will NOT run the company pipeline".to_string()));
|
||||
}
|
||||
"status" => {
|
||||
let current = state.misc.pipeline_override.as_deref().unwrap_or("auto");
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Pipeline mode: {current} (use /pipeline full|quick|skip to change)")));
|
||||
}
|
||||
_ => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, format!("Unknown pipeline mode: {mode} (use: full, quick, skip)")));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
Action::RunWorkflow { script } => {
|
||||
// Open the Workflow overlay so the user can see progress.
|
||||
state.misc.overlay = Overlay::Workflow;
|
||||
@@ -682,8 +660,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
});
|
||||
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
let result = crate::app::workflow::engine::run_workflow_tracked(
|
||||
&wf, &args, Some(&live), &session_dir, &workspace_roots,
|
||||
&wf, &args, &no_abort, Some(&live), &session_dir, &workspace_roots,
|
||||
);
|
||||
|
||||
let (kind, message) = match result {
|
||||
@@ -774,7 +753,6 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
}) = true;
|
||||
|
||||
let events_q = turn_events.clone();
|
||||
let pipeline_mode = state.misc.pipeline_override.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let db = crate::model::msglog::open_or_create(&edit_session_dir)
|
||||
@@ -794,7 +772,6 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
temperature,
|
||||
max_tokens,
|
||||
abort_flag,
|
||||
pipeline_mode,
|
||||
};
|
||||
let result = run_agent_turn(&tc, &messages, &events_q);
|
||||
if let Err(e) = result {
|
||||
@@ -823,9 +800,6 @@ struct TurnCtx {
|
||||
temperature: f32,
|
||||
max_tokens: Option<u32>,
|
||||
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Pipeline override: None=auto, Some("full"), Some("quick"), Some("skip").
|
||||
/// Set by the `/pipeline` slash command. Consumed once per turn.
|
||||
pipeline_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Build an ASCII tree of the workspace directory structure for the
|
||||
@@ -956,16 +930,6 @@ fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connect
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of LLM call + tool-execution iterations per single
|
||||
/// agent turn before bailing. Prevents runaway token consumption when
|
||||
/// the agent gets stuck in a loop (e.g. an unachievable todo item).
|
||||
const MAX_TURN_STEPS: usize = 10000;
|
||||
|
||||
/// Hard wall-clock timeout per agent turn (5 minutes). Prevents a single
|
||||
/// user turn from running indefinitely even if the step budget isn't
|
||||
/// exhausted (e.g. slow LLM responses, stuck tool calls).
|
||||
const MAX_TURN_TIMEOUT_MS: u64 = 300_000;
|
||||
|
||||
/// Maximum number of auto inline reviews spawned per single agent turn.
|
||||
/// After N edits, the inline review is skipped to keep the turn fast;
|
||||
/// background subagents still fire at the end of the turn.
|
||||
@@ -1005,7 +969,6 @@ fn run_agent_turn(
|
||||
let mut edited_paths: Vec<String> = Vec::new();
|
||||
let mut inline_reviews_count: usize = 0;
|
||||
let mut prev_shaped = false;
|
||||
let turn_start_ms = std::time::Instant::now();
|
||||
|
||||
// Build system prompt components once and cache them for the entire turn
|
||||
// instead of regenerating on every loop iteration (which walks the full
|
||||
@@ -1027,11 +990,6 @@ fn run_agent_turn(
|
||||
|
||||
// ── AUTO CEO PIPELINE ──
|
||||
// Before the main agent starts working, check if the pipeline should run.
|
||||
// The pipeline mode is determined by:
|
||||
// 1. User override: `/pipeline full|quick|skip` (consumed once)
|
||||
// 2. Auto-detect: `is_complex_request()` heuristics
|
||||
//
|
||||
// This only triggers on the first turn of a session to avoid re-planning.
|
||||
let user_msg_count = msgs.iter()
|
||||
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
||||
.count();
|
||||
@@ -1044,14 +1002,7 @@ fn run_agent_turn(
|
||||
if user_request.is_empty() {
|
||||
false
|
||||
} else {
|
||||
match tc.pipeline_mode.as_deref() {
|
||||
Some("skip") => {
|
||||
tracing::debug!("[ceo] pipeline skipped via /pipeline skip");
|
||||
false
|
||||
}
|
||||
Some("full" | "quick") => true,
|
||||
_ => crate::app::workflow::company::is_complex_request(user_request),
|
||||
}
|
||||
crate::app::workflow::company::is_complex_request(user_request)
|
||||
}
|
||||
} else {
|
||||
false
|
||||
@@ -1063,10 +1014,10 @@ fn run_agent_turn(
|
||||
.and_then(|m| m.content.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
let use_full = tc.pipeline_mode.as_deref() != Some("quick");
|
||||
let mode_label = if use_full { "full" } else { "quick" };
|
||||
let use_full = true;
|
||||
let mode_label = "full";
|
||||
tracing::info!(
|
||||
"[ceo] pipeline triggered (mode={}) — delegating to company pipeline",
|
||||
"[ceo] pipeline triggered (mode={}) — dynamically generating planning workflow via LLM",
|
||||
mode_label
|
||||
);
|
||||
|
||||
@@ -1074,28 +1025,107 @@ fn run_agent_turn(
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "pipeline".to_string(),
|
||||
message: format!(
|
||||
"Company pipeline started ({}): {} → Engineering → Quality{}",
|
||||
mode_label,
|
||||
"Strategy",
|
||||
if use_full { " → Security → Documentation" } else { "" },
|
||||
"CEO is planning workflow (mode={mode_label})...",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let pipeline_result = if use_full {
|
||||
crate::app::workflow::company::run_company_pipeline(
|
||||
user_request,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.workspace_roots,
|
||||
Some(events_q),
|
||||
)
|
||||
let pipeline_abort = Some(tc.abort_flag.clone());
|
||||
|
||||
// Ask LLM to dynamically generate the workflow specialists plan
|
||||
let required_divisions = if use_full {
|
||||
"all 5 divisions (Strategy, Engineering, Quality, Security, Documentation)"
|
||||
} else {
|
||||
crate::app::workflow::company::run_company_pipeline_quick(
|
||||
user_request,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.workspace_roots,
|
||||
Some(events_q),
|
||||
)
|
||||
"the 3 quick divisions (Strategy, Engineering, Quality)"
|
||||
};
|
||||
let example_json = if use_full {
|
||||
r#"{
|
||||
"Strategy": [ ["Architect", "Analyze component tree..."] ],
|
||||
"Engineering": [ ["Developer", "Implement core algorithms..."] ],
|
||||
"Quality": [ ["Tester", "Write unit tests..."] ],
|
||||
"Security": [ ["Auditor", "Review dependencies..."] ],
|
||||
"Documentation": [ ["Writer", "Document API endpoints..."] ]
|
||||
}"#
|
||||
} else {
|
||||
r#"{
|
||||
"Strategy": [ ["Architect", "Analyze component tree..."] ],
|
||||
"Engineering": [ ["Developer", "Implement core algorithms..."] ],
|
||||
"Quality": [ ["Tester", "Write unit tests..."] ]
|
||||
}"#
|
||||
};
|
||||
|
||||
let system_msg = ChatMessage::system(
|
||||
"You are a professional software architect and workflow planner. \
|
||||
Generate a tailored, structured multi-agent workflow specialists plan for the requested task. \
|
||||
Do not explain. Return ONLY raw JSON matching the requested structure."
|
||||
);
|
||||
let user_msg = ChatMessage::user(format!(
|
||||
"Design a structured multi-agent workflow plan for the following task:\n\n\
|
||||
\"{user_request}\"\n\n\
|
||||
You must output a JSON object representing the 'specialists' configuration for {required_divisions}.\n\
|
||||
Each division must have a list of custom specialists defined by a pair of [label, focus_description].\n\n\
|
||||
Return ONLY a JSON object with this exact structure, with no markdown codeblocks and no explanation:\n\
|
||||
{example_json}"
|
||||
));
|
||||
|
||||
let planner_result = tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None);
|
||||
let pipeline_result = match planner_result {
|
||||
Ok((reply, _)) => {
|
||||
let reply_text = reply.content.as_deref().unwrap_or("").trim();
|
||||
let clean_json = if reply_text.starts_with("```") {
|
||||
let mut lines = reply_text.lines();
|
||||
lines.next();
|
||||
let mut content = lines.collect::<Vec<&str>>();
|
||||
if content.last().is_some_and(|s| s.trim() == "```") {
|
||||
content.pop();
|
||||
}
|
||||
content.join("\n")
|
||||
} else {
|
||||
reply_text.to_string()
|
||||
};
|
||||
|
||||
match serde_json::from_str::<std::collections::HashMap<String, Vec<(String, String)>>>(&clean_json) {
|
||||
Ok(custom_specialists) => {
|
||||
let spec_desc = custom_specialists.iter()
|
||||
.map(|(k, v)| format!("{}: {} agents", k, v.len()))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "pipeline".to_string(),
|
||||
message: format!(
|
||||
"CEO planned: Strategy → Engineering → Quality{}. (Config: {}) Running specialists...",
|
||||
if use_full { " → Security → Documentation" } else { "" },
|
||||
spec_desc
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if use_full {
|
||||
crate::app::workflow::company::run_company_pipeline(
|
||||
user_request,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.workspace_roots,
|
||||
Some(events_q),
|
||||
&pipeline_abort,
|
||||
&custom_specialists,
|
||||
)
|
||||
} else {
|
||||
crate::app::workflow::company::run_company_pipeline_quick(
|
||||
user_request,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.workspace_roots,
|
||||
Some(events_q),
|
||||
&pipeline_abort,
|
||||
&custom_specialists,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!("Failed to parse LLM planning JSON: {e}. Cleaned JSON was: {clean_json}")),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!("Failed to query LLM for planning workflow: {e}")),
|
||||
};
|
||||
|
||||
match pipeline_result {
|
||||
@@ -1127,24 +1157,19 @@ fn run_agent_turn(
|
||||
tracing::debug!("[ceo] pipeline not triggered — handling directly");
|
||||
}
|
||||
|
||||
let mut turn_step = 0usize;
|
||||
// Check abort after pipeline completes, before entering main loop.
|
||||
// This catches the case where the user pressed Esc during the pipeline
|
||||
// phase, which previously ran unchecked for minutes at a time.
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut todo_retry_count = 0usize;
|
||||
|
||||
loop {
|
||||
turn_step += 1;
|
||||
if turn_step > MAX_TURN_STEPS {
|
||||
anyhow::bail!(
|
||||
"turn exceeded maximum steps ({MAX_TURN_STEPS}) — possible runaway loop. \
|
||||
aborting to prevent excessive token usage",
|
||||
);
|
||||
}
|
||||
if turn_start_ms.elapsed().as_millis() as u64 > MAX_TURN_TIMEOUT_MS {
|
||||
anyhow::bail!(
|
||||
"turn exceeded maximum duration ({}s) — aborting. \
|
||||
Use /compact or shorter prompts if the model needs more time.",
|
||||
MAX_TURN_TIMEOUT_MS / 1000,
|
||||
);
|
||||
}
|
||||
let total_chars: usize = msgs.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
@@ -1152,7 +1177,11 @@ fn run_agent_turn(
|
||||
let token_estimate = total_chars / 4;
|
||||
let max_wire_tokens = tc.context_window;
|
||||
|
||||
let wire_msgs = if crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) {
|
||||
// Skip message compaction if abort was requested — the non-streaming
|
||||
// LLM call for summarization would block without checking abort_flag.
|
||||
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||
&& crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped)
|
||||
{
|
||||
prev_shaped = true;
|
||||
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
|
||||
|
||||
@@ -1226,42 +1255,44 @@ fn run_agent_turn(
|
||||
let (response, final_usage) = match result {
|
||||
Ok((msg, u)) => (msg, u.or(usage)),
|
||||
Err(e) => {
|
||||
// If abort was requested, return immediately.
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match tc.client.chat_with_tools_non_streaming(&wire_msgs, Some(tc.tdefs.clone())) {
|
||||
Ok((msg, usage_fb)) => (msg, usage_fb),
|
||||
Err(api_err) => {
|
||||
let todo_path = tc.ctx.session_dir.join("todo.md");
|
||||
let mut has_unfinished = false;
|
||||
if let Ok(todo_text) = std::fs::read_to_string(&todo_path) {
|
||||
if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) {
|
||||
has_unfinished = true;
|
||||
}
|
||||
}
|
||||
if has_unfinished {
|
||||
todo_retry_count += 1;
|
||||
if todo_retry_count > MAX_TODO_RETRIES {
|
||||
anyhow::bail!(
|
||||
"exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
|
||||
Edit todo.md manually or ask me to focus on specific items.",
|
||||
);
|
||||
}
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: format!("Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"),
|
||||
});
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
continue;
|
||||
}
|
||||
return Err(api_err);
|
||||
// Streaming-only: no non-streaming fallback.
|
||||
// Non-streaming blocks up to 1 minute without checking
|
||||
// abort_flag, making cancellation unresponsive.
|
||||
// If the API supports streaming (which it must), this
|
||||
// path handles transient errors via the retry loop below.
|
||||
let api_err = e;
|
||||
let todo_path = tc.ctx.session_dir.join("todo.md");
|
||||
let mut has_unfinished = false;
|
||||
if let Ok(todo_text) = std::fs::read_to_string(&todo_path) {
|
||||
if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) {
|
||||
has_unfinished = true;
|
||||
}
|
||||
}
|
||||
if has_unfinished {
|
||||
todo_retry_count += 1;
|
||||
if todo_retry_count > MAX_TODO_RETRIES {
|
||||
anyhow::bail!(
|
||||
"exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
|
||||
Edit todo.md manually or ask me to focus on specific items.",
|
||||
);
|
||||
}
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: format!("Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"),
|
||||
});
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
continue;
|
||||
}
|
||||
return Err(api_err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1279,44 +1310,60 @@ fn run_agent_turn(
|
||||
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
||||
msgs.push(response);
|
||||
for tool_call in tool_calls {
|
||||
let mut results_vec = Vec::new();
|
||||
std::thread::scope(|s| {
|
||||
let mut handles = Vec::new();
|
||||
let tc_ref = tc;
|
||||
for tool_call in &tool_calls {
|
||||
let handle = s.spawn(move || {
|
||||
let tool_name = tool_call.function.name.clone();
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
||||
&tool_call.function.arguments,
|
||||
);
|
||||
|
||||
let ws_roots: Vec<&std::path::Path> =
|
||||
tc_ref.workspace_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let verdict = crate::app::harness::Harness::gate_tool_call(
|
||||
&tool_name,
|
||||
&args,
|
||||
&ws_roots,
|
||||
);
|
||||
|
||||
let is_edit_tool = tool_name == "write" || tool_name == "edit";
|
||||
let (output, is_error, is_edit) = match verdict {
|
||||
Verdict::Allow => match execute_one_tool(
|
||||
&tc_ref.tools,
|
||||
&tc_ref.ctx,
|
||||
&tool_name,
|
||||
&tool_call.id,
|
||||
&args,
|
||||
&tc_ref.edit_log_session_dir,
|
||||
&tc_ref.session_id,
|
||||
tc_ref.db.as_ref(),
|
||||
) {
|
||||
Ok(result) => (result, false, is_edit_tool),
|
||||
Err(e) => (e.to_string(), true, false),
|
||||
},
|
||||
Verdict::Block(reason) => (format!("Blocked: {reason}"), true, false),
|
||||
};
|
||||
(tool_call, tool_name, args, output, is_error, is_edit)
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
for h in handles {
|
||||
if let Ok(res) = h.join() {
|
||||
results_vec.push(res);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (tool_call, tool_name, args, output, is_error, is_edit) in results_vec {
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tool_name = tool_call.function.name.clone();
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
||||
&tool_call.function.arguments,
|
||||
);
|
||||
|
||||
let ws_roots: Vec<&std::path::Path> =
|
||||
tc.workspace_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let verdict = crate::app::harness::Harness::gate_tool_call(
|
||||
&tool_name,
|
||||
&args,
|
||||
|
||||
&ws_roots,
|
||||
);
|
||||
|
||||
let is_edit_tool = tool_name == "write" || tool_name == "edit";
|
||||
let (output, is_error, is_edit) = match verdict {
|
||||
Verdict::Allow => match execute_one_tool(
|
||||
&tc.tools,
|
||||
&tc.ctx,
|
||||
&tool_name,
|
||||
&tool_call.id,
|
||||
&args,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.session_id,
|
||||
tc.db.as_ref(),
|
||||
) {
|
||||
Ok(result) => (result, false, is_edit_tool),
|
||||
Err(e) => (e.to_string(), true, false),
|
||||
},
|
||||
Verdict::Block(reason) => (format!("Blocked: {reason}"), true, false),
|
||||
};
|
||||
|
||||
if is_edit {
|
||||
edits_this_turn += 1;
|
||||
@@ -1374,7 +1421,6 @@ fn run_agent_turn(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string);
|
||||
|
||||
{
|
||||
@@ -1538,7 +1584,7 @@ fn execute_one_tool(
|
||||
let hash = sha2::Sha256::digest(
|
||||
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
||||
);
|
||||
format!("{hash:x}")
|
||||
hex::encode(hash)
|
||||
};
|
||||
let bytes_delta = if name == "write" {
|
||||
args.get("content")
|
||||
@@ -1674,7 +1720,7 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
|
||||
let verifier = CodeVerifier::new();
|
||||
let challenge = verifier.challenge();
|
||||
let state_token = format!("{:x}", sha2::Sha256::digest(rand_bytes(16)));
|
||||
let state_token = hex::encode(sha2::Sha256::digest(rand_bytes(16)));
|
||||
|
||||
let mut manager = OAuthManager::new(config.clone());
|
||||
let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str());
|
||||
|
||||
@@ -69,9 +69,6 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::WorkflowRun { script } => {
|
||||
vec![Action::RunWorkflow { script }]
|
||||
}
|
||||
Command::Pipeline { mode } => {
|
||||
vec![Action::RunPipeline { mode }]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
|
||||
@@ -280,7 +280,7 @@ mod tests {
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "hello"),
|
||||
other => panic!("expected Token, got {:?}", other),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ mod tests {
|
||||
assert_eq!(e2.len(), 1);
|
||||
match &e2[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "partial"),
|
||||
other => panic!("expected Token, got {:?}", other),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ mod tests {
|
||||
assert_eq!(name.as_deref(), Some("bash"));
|
||||
assert_eq!(arguments_delta, "{\"cmd\"");
|
||||
}
|
||||
other => panic!("expected ToolCallDelta, got {:?}", other),
|
||||
other => panic!("expected ToolCallDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ mod tests {
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
}
|
||||
other => panic!("expected Usage, got {:?}", other),
|
||||
other => panic!("expected Usage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ mod tests {
|
||||
assert_eq!(a, "a");
|
||||
assert_eq!(b, "b");
|
||||
}
|
||||
other => panic!("expected two Tokens, got {:?}", other),
|
||||
other => panic!("expected two Tokens, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,6 @@ const COMMANDS: &[&str] = &[
|
||||
"/model add",
|
||||
"/workflow",
|
||||
"/workflow run",
|
||||
"/pipeline",
|
||||
"/pipeline full",
|
||||
"/pipeline quick",
|
||||
"/pipeline skip",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
@@ -293,14 +289,6 @@ pub struct MiscState {
|
||||
pub api_context_length: Option<u32>,
|
||||
pub tick_count: u64,
|
||||
pub todo_content: String,
|
||||
/// Pipeline mode override set by `/pipeline` command.
|
||||
/// - `None`: auto-detect (default)
|
||||
/// - `Some("full")`: force full pipeline
|
||||
/// - `Some("quick")`: force quick pipeline
|
||||
/// - `Some("skip")`: skip pipeline, handle directly
|
||||
///
|
||||
/// Consumed on the next agent turn.
|
||||
pub pipeline_override: Option<String>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
@@ -319,7 +307,6 @@ impl MiscState {
|
||||
api_context_length: None,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
pipeline_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ impl AppStateRest {
|
||||
use sha2::Digest;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(abs_root.to_string_lossy().as_bytes());
|
||||
let hash_hex = format!("{:x}", hasher.finalize());
|
||||
let hash_hex = hex::encode(hasher.finalize());
|
||||
let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
||||
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||||
let history_dir = base_dir.join("history");
|
||||
|
||||
@@ -37,12 +37,6 @@ const SKIP_REVIEW_FILES: &[&str] = &[
|
||||
".gitignore", ".env", ".env.example",
|
||||
];
|
||||
|
||||
/// Maximum LLM steps for a quick-review subagent. Keeps reviews fast.
|
||||
const QUICK_REVIEW_MAX_STEPS: usize = 2;
|
||||
|
||||
/// Maximum LLM steps for background subagents (test gen, arch, security).
|
||||
const BG_SUBAGENT_MAX_STEPS: usize = 8;
|
||||
|
||||
/// ─── Helpers ───
|
||||
///
|
||||
/// Check whether a file path is worth auto-reviewing (not config/lock/data).
|
||||
@@ -114,8 +108,7 @@ pub fn spawn_quick_review(
|
||||
"quick-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(QUICK_REVIEW_MAX_STEPS);
|
||||
.with_system_prompt(prompt);
|
||||
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
@@ -189,7 +182,7 @@ pub fn spawn_background_test_gen(
|
||||
"coder".to_string(), // needs write access
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
;
|
||||
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
@@ -269,7 +262,7 @@ pub fn spawn_background_arch_review(
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
;
|
||||
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
@@ -355,7 +348,7 @@ pub fn spawn_background_security_review(
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
;
|
||||
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
|
||||
Vec::new()
|
||||
}
|
||||
});
|
||||
let max_steps = def.max_steps.unwrap_or(25);
|
||||
let max_steps = def.max_steps.unwrap_or(usize::MAX);
|
||||
SubagentContext {
|
||||
system_prompt: String::new(),
|
||||
allowed_tools,
|
||||
|
||||
@@ -43,7 +43,6 @@ pub fn strategy_division() -> AgentDefinition {
|
||||
roles::STRATEGY.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string())
|
||||
.with_max_steps(15)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
@@ -57,6 +56,7 @@ pub fn strategy_division() -> AgentDefinition {
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ pub fn engineering_division() -> AgentDefinition {
|
||||
roles::ENGINEERING.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string())
|
||||
.with_max_steps(50)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
@@ -90,6 +89,7 @@ pub fn engineering_division() -> AgentDefinition {
|
||||
"lsp_disconnect".to_string(),
|
||||
"todowrite".to_string(),
|
||||
"todofinish".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ pub fn quality_division() -> AgentDefinition {
|
||||
roles::QUALITY.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string())
|
||||
.with_max_steps(30)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
@@ -119,6 +118,7 @@ pub fn quality_division() -> AgentDefinition {
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -132,7 +132,6 @@ pub fn security_division() -> AgentDefinition {
|
||||
roles::SECURITY.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string())
|
||||
.with_max_steps(15)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
@@ -146,6 +145,7 @@ pub fn security_division() -> AgentDefinition {
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -159,7 +159,6 @@ pub fn documentation_division() -> AgentDefinition {
|
||||
roles::DOCUMENTATION.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string())
|
||||
.with_max_steps(15)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
@@ -168,6 +167,7 @@ pub fn documentation_division() -> AgentDefinition {
|
||||
"glob".to_string(),
|
||||
"recall".to_string(),
|
||||
"remember".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+100
-62
@@ -28,10 +28,16 @@ use super::event::SubagentEvent;
|
||||
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||
let all = all_tools();
|
||||
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
|
||||
all
|
||||
all.into_iter()
|
||||
.filter(|t| t.name() != "company_pipeline" && t.name() != "workflow_run")
|
||||
.collect()
|
||||
} else {
|
||||
all.into_iter()
|
||||
.filter(|t| allowed_tools.contains(&t.name().to_string()))
|
||||
.filter(|t| {
|
||||
allowed_tools.contains(&t.name().to_string())
|
||||
&& t.name() != "company_pipeline"
|
||||
&& t.name() != "workflow_run"
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let defs = tool_defs(&filtered);
|
||||
@@ -278,10 +284,10 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
///
|
||||
/// Flow: inject system prompt (with workspace tree if available) → for each
|
||||
/// step: resolve provider config, build an LLM client, call
|
||||
/// `chat_with_tools_non_streaming`, process tool calls (gated against both
|
||||
/// the allowlist and Harness-style content safety checks) or collect text
|
||||
/// output → send `SubagentEvent`s on `tx` → break on first text-only
|
||||
/// (non-empty) response.
|
||||
/// `chat_with_tools_streaming` (with abort check per SSE event), process
|
||||
/// tool calls (gated against both the allowlist and Harness-style content
|
||||
/// safety checks) or collect text output → send `SubagentEvent`s on `tx` →
|
||||
/// break on first text-only (non-empty) response.
|
||||
///
|
||||
/// Why: runs synchronously on a dedicated thread so the main async event
|
||||
/// loop is not blocked. Tool gating prevents restricted, risky, or
|
||||
@@ -333,15 +339,44 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
||||
anyhow::bail!("subagent aborted by parent at step {step}");
|
||||
}
|
||||
|
||||
// Use the structured tool-calling API so the LLM can request tools with
|
||||
// proper arguments, exactly like the main agent does.
|
||||
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) {
|
||||
// Use streaming API so the abort flag is checked per SSE event,
|
||||
// making the subagent responsive to cancellation even during an
|
||||
// LLM call (non-streaming would block for 10-30s unchecked).
|
||||
let stream_result = client.chat_with_tools_streaming(
|
||||
&messages,
|
||||
tdefs_opt.clone(),
|
||||
Some(0.7),
|
||||
Some(4096),
|
||||
|_event| -> bool {
|
||||
// Check abort on every SSE event for responsive cancellation.
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
return false; // signals provider to abort
|
||||
}
|
||||
// We don't stream tokens to the UI for subagents — just
|
||||
// need the assembled message at the end.
|
||||
true
|
||||
},
|
||||
);
|
||||
|
||||
let (response, _usage) = match stream_result {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|
||||
|| e.to_string().contains("aborted");
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
step,
|
||||
error: e.to_string(),
|
||||
error: if is_abort {
|
||||
"subagent aborted by user".to_string()
|
||||
} else {
|
||||
e.to_string()
|
||||
},
|
||||
});
|
||||
if is_abort {
|
||||
anyhow::bail!("subagent aborted by parent at step {step}");
|
||||
}
|
||||
// No non-streaming fallback — API must support streaming.
|
||||
// Non-streaming calls block for up to 1 min without checking
|
||||
// abort_flag, making cancellation unresponsive.
|
||||
anyhow::bail!("subagent call failed at step {step}: {e}");
|
||||
}
|
||||
};
|
||||
@@ -356,67 +391,62 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
||||
// Push the assistant message with tool_calls into the conversation
|
||||
messages.push(response);
|
||||
|
||||
for tool_call in &tool_calls {
|
||||
// Check abort flag before each tool execution
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
step,
|
||||
error: "subagent aborted by parent during tool execution".to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent aborted by parent during tool call at step {step}");
|
||||
}
|
||||
let mut results_vec = Vec::new();
|
||||
std::thread::scope(|s| {
|
||||
let mut handles = Vec::new();
|
||||
let tools_ref = &tools;
|
||||
let tool_ctx_ref = &tool_ctx;
|
||||
for tool_call in &tool_calls {
|
||||
let handle = s.spawn(move || {
|
||||
// Check abort flag before each tool execution
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
return (tool_call, Err(anyhow::anyhow!("subagent aborted by parent during tool execution")));
|
||||
}
|
||||
|
||||
let tool_name = &tool_call.function.name;
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
||||
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||
|
||||
// Level 1: allowlist check — is this tool even permitted?
|
||||
if !generally_allowed {
|
||||
return (tool_call, Ok(format!("tool '{tool_name}' not allowed for this subagent")));
|
||||
}
|
||||
|
||||
// Level 2: risky tool check — risky tools require explicit permission
|
||||
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||
return (tool_call, Ok(format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent")));
|
||||
}
|
||||
|
||||
// Level 3: Harness-style content safety gating
|
||||
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
|
||||
return (tool_call, Ok(format!("Blocked by subagent gate: {block_reason}")));
|
||||
}
|
||||
|
||||
let result = match tools_ref.iter().find(|t| t.name() == tool_name.as_str()) {
|
||||
Some(tool) => tool.run(tool_ctx_ref, &args),
|
||||
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
|
||||
};
|
||||
(tool_call, result)
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
for h in handles {
|
||||
if let Ok(res) = h.join() {
|
||||
results_vec.push(res);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (tool_call, result) in results_vec {
|
||||
let tool_name = &tool_call.function.name;
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
||||
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||
tool: tool_name.clone(),
|
||||
args: args.clone(),
|
||||
});
|
||||
|
||||
// Level 1: allowlist check — is this tool even permitted?
|
||||
if !generally_allowed {
|
||||
let msg = format!("tool '{tool_name}' not allowed for this subagent");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Level 2: risky tool check — risky tools require explicit permission
|
||||
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||
let msg = format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Level 3: Harness-style content safety gating — mirrors the main
|
||||
// agent's gate_tool_call checks (path traversal, reason validation,
|
||||
// stub/denial/assumption scanning, bash exfiltration, destructive
|
||||
// commands, sensitive path reads).
|
||||
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
|
||||
let msg = format!("Blocked by subagent gate: {block_reason}");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
|
||||
Some(tool) => tool.run(&tool_ctx, &args),
|
||||
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output_text) => {
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
||||
@@ -426,6 +456,14 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("subagent aborted by parent") {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
step,
|
||||
error: err_str.clone(),
|
||||
});
|
||||
anyhow::bail!("{err_str}");
|
||||
}
|
||||
let msg = format!("tool '{tool_name}' failed: {e}");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
|
||||
@@ -30,6 +30,7 @@ impl AgentDefinition {
|
||||
}
|
||||
|
||||
/// Builder method: limit this agent to at most `steps` LLM calls.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
|
||||
+269
-127
@@ -1,6 +1,6 @@
|
||||
//! Company-style workflow orchestrator: runs the complete division pipeline
|
||||
//! (Strategy → Engineering → Quality → Security → Documentation) with
|
||||
//! findings flowing between stages, then returns a consolidated executive
|
||||
//! (Strategy → Engineering → [Quality || Security || Documentation] in parallel)
|
||||
//! with findings flowing between stages, then returns a consolidated executive
|
||||
//! summary to the CEO (main agent).
|
||||
//!
|
||||
//! Flow:
|
||||
@@ -9,146 +9,137 @@
|
||||
//! │ delegates to run_company_pipeline(request)
|
||||
//! ▼
|
||||
//! ┌──────────────────────────────────────────────────┐
|
||||
//! │ Strategy Division — plan + mermaid diagrams │
|
||||
//! │ Engineering Division — implement per plan │
|
||||
//! │ Quality Division — review + write tests │
|
||||
//! │ Security Division — vulnerability audit │
|
||||
//! │ Documentation Div — update docs │
|
||||
//! └──────────────────────────────────────────────────┘
|
||||
//! │ returns consolidated summary
|
||||
//! ▼
|
||||
//! CEO Main Agent delivers to user
|
||||
//! │ Strategy Division — plan + mermaid diagrams │ (runs sequentially first)
|
||||
//! └─────────────────────────┬────────────────────────┘
|
||||
//! ▼
|
||||
//! ┌──────────────────────────────────────────────────┐
|
||||
//! │ Engineering Division — implement per plan │ (runs sequentially second)
|
||||
//! └─────────────────────────┬────────────────────────┘
|
||||
//! ▼
|
||||
//! ┌────────────┼────────────┐
|
||||
//! ▼ ▼ ▼
|
||||
//! ┌───────────┐┌───────────┐┌───────────┐
|
||||
//! │ Quality ││ Security ││ Docs │ (run concurrently in parallel)
|
||||
//! └───────────┘└───────────┘└───────────┘
|
||||
//! │ │ │
|
||||
//! └────────────┼────────────┘
|
||||
//! ▼
|
||||
//! CEO Main Agent delivers consolidated summary to user
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
||||
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||
use crate::app::subagent::division;
|
||||
|
||||
/// Construct the specialized agents for a division.
|
||||
///
|
||||
/// Flow: map division name to its specialization pool.
|
||||
///
|
||||
/// Return: a `Vec<ScriptPrimitive>` containing the specialist agents.
|
||||
fn make_division_specialists(
|
||||
div: &division::Division,
|
||||
user_request: &str,
|
||||
specs: &[(String, String)],
|
||||
) -> Vec<ScriptPrimitive> {
|
||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
||||
|
||||
specs
|
||||
.iter()
|
||||
.map(|(label, focus)| {
|
||||
// Prepend [Division Name: Specialist Label] so the first 40 chars
|
||||
// of the prompt become the agent_name in spawn_single_agent.
|
||||
// We use quadruple curly braces `{{{{findings}}}}` so that Rust's `format!` formats it
|
||||
// into `{{findings}}` in the output string, which `resolve_template` then recognizes
|
||||
// and replaces.
|
||||
let prompt = format!(
|
||||
"[{}: {}]\n\n{}\n\n{}\n\nUser request: {}\n\nFindings from previous divisions:\n{{{{findings}}}}",
|
||||
div.name,
|
||||
label,
|
||||
focus,
|
||||
div_prompt,
|
||||
user_request,
|
||||
);
|
||||
ScriptPrimitive::Agent(prompt)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Construct a named Phase wrapper containing a Parallel block of division specialists.
|
||||
///
|
||||
/// Flow: construct division specialists → wrap in a `Parallel` primitive wrapper.
|
||||
///
|
||||
/// Return: a `ScriptPrimitive::Phase` wrapper.
|
||||
fn make_division_phase(
|
||||
div: &division::Division,
|
||||
user_request: &str,
|
||||
specs: &[(String, String)],
|
||||
) -> ScriptPrimitive {
|
||||
let specialists = make_division_specialists(div, user_request, specs);
|
||||
ScriptPrimitive::Phase {
|
||||
name: div.name.to_string(),
|
||||
script: Box::new(ScriptPrimitive::Parallel(specialists)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the full company-style pipeline for a given user request.
|
||||
///
|
||||
/// This orchestrates all five divisions in sequence:
|
||||
/// 1. **Strategy** — create plan with diagrams
|
||||
/// 2. **Engineering** — implement code
|
||||
/// 3. **Quality** — review + write tests
|
||||
/// 4. **Security** — audit
|
||||
/// 5. **Documentation** — update docs
|
||||
/// This orchestrates all five divisions, running Strategy and Engineering
|
||||
/// sequentially, followed by Quality, Security, and Documentation in parallel.
|
||||
///
|
||||
/// Each division receives findings from all previous divisions, enabling
|
||||
/// context to flow through the pipeline.
|
||||
///
|
||||
/// Returns a consolidated executive summary string.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_company_pipeline(
|
||||
user_request: &str,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let divisions = division::all_divisions();
|
||||
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(divisions.len());
|
||||
|
||||
for div in &divisions {
|
||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
||||
// Prepend [Division Name] so the first 40 chars of the prompt
|
||||
// become the agent_name in spawn_single_agent, making the TUI
|
||||
// panel show division names instead of UUID fragments.
|
||||
let prompt = format!(
|
||||
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
|
||||
div.name,
|
||||
div_prompt,
|
||||
user_request,
|
||||
);
|
||||
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
|
||||
}
|
||||
let strategy_specs = custom_specialists.get("Strategy")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
|
||||
let engineering_specs = custom_specialists.get("Engineering")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
|
||||
let quality_specs = custom_specialists.get("Quality")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
|
||||
let security_specs = custom_specialists.get("Security")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Security"))?;
|
||||
let documentation_specs = custom_specialists.get("Documentation")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Documentation"))?;
|
||||
|
||||
let strategy_phase = make_division_phase(&divisions[0], user_request, strategy_specs);
|
||||
let engineering_phase = make_division_phase(&divisions[1], user_request, engineering_specs);
|
||||
|
||||
let quality_phase = make_division_phase(&divisions[2], user_request, quality_specs);
|
||||
let security_phase = make_division_phase(&divisions[3], user_request, security_specs);
|
||||
let documentation_phase = make_division_phase(&divisions[4], user_request, documentation_specs);
|
||||
|
||||
let parallel_divisions = ScriptPrimitive::Parallel(vec![
|
||||
quality_phase,
|
||||
security_phase,
|
||||
documentation_phase,
|
||||
]);
|
||||
|
||||
let pipeline_primitive = ScriptPrimitive::Pipeline(vec![
|
||||
strategy_phase,
|
||||
engineering_phase,
|
||||
parallel_divisions,
|
||||
]);
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline".to_string(),
|
||||
description: "Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation".to_string(),
|
||||
script: ScriptPrimitive::Pipeline(pipeline_scripts),
|
||||
description: "Company Pipeline: Strategy → Engineering → (Quality || Security || Documentation)".to_string(),
|
||||
script: pipeline_primitive,
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 1, // sequential by design
|
||||
continue_on_error: true, // one division failing shouldn't block the rest
|
||||
timeout_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Build a live callback for TUI updates if turn_events is available.
|
||||
// Uses agent_name (division name) for the display label in the panel.
|
||||
let live: Option<LiveStateFn> = turn_events.map(|events| {
|
||||
let events = events.clone();
|
||||
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||
let display_name = agent_name.chars().take(30).collect::<String>();
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id: display_name.clone(),
|
||||
agent_name: display_name,
|
||||
status,
|
||||
});
|
||||
}
|
||||
});
|
||||
f
|
||||
});
|
||||
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let live_ref = live.as_ref();
|
||||
|
||||
// Create a per-pipeline findings scope so divisions can pass data
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let results = execute_primitive(
|
||||
&wf.script,
|
||||
&args,
|
||||
1,
|
||||
true,
|
||||
live_ref,
|
||||
session_dir,
|
||||
workspaces,
|
||||
&findings,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Collect all findings for the executive summary
|
||||
let all_findings = findings.lock()
|
||||
.map(|f| f.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, &divisions))
|
||||
}
|
||||
|
||||
/// Run a quick company pipeline that skips non-essential divisions
|
||||
/// for simple tasks. Flow: Strategy → Engineering → Quality.
|
||||
///
|
||||
/// This is for smaller tasks where security audit and full docs are overkill.
|
||||
pub fn run_company_pipeline_quick(
|
||||
user_request: &str,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let divisions = division::all_divisions();
|
||||
// Only use first 3 divisions for quick pipeline: Strategy, Engineering, Quality
|
||||
let quick_divisions = &divisions[..3];
|
||||
|
||||
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(quick_divisions.len());
|
||||
for div in quick_divisions {
|
||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
||||
let prompt = format!(
|
||||
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
|
||||
div.name,
|
||||
div_prompt,
|
||||
user_request,
|
||||
);
|
||||
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
|
||||
}
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline-quick".to_string(),
|
||||
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
|
||||
script: ScriptPrimitive::Pipeline(pipeline_scripts),
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 1,
|
||||
max_concurrency: 10,
|
||||
continue_on_error: true,
|
||||
timeout_ms: None,
|
||||
},
|
||||
@@ -173,38 +164,140 @@ pub fn run_company_pipeline_quick(
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let results = execute_primitive(
|
||||
&wf.script, &args, 1, true,
|
||||
live.as_ref(), session_dir, workspaces, &findings, None,
|
||||
&wf.script,
|
||||
&args,
|
||||
wf.options.max_concurrency,
|
||||
true,
|
||||
abort_flag,
|
||||
live.as_ref(),
|
||||
session_dir,
|
||||
workspaces,
|
||||
&findings,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let all_findings = findings.lock()
|
||||
.map(|f| f.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions))
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, &divisions, custom_specialists))
|
||||
}
|
||||
|
||||
/// Run a quick company pipeline that skips non-essential divisions
|
||||
/// for simple tasks. Flow: Strategy → Engineering → Quality.
|
||||
///
|
||||
/// This is for smaller tasks where security audit and full docs are overkill.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_company_pipeline_quick(
|
||||
user_request: &str,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let divisions = division::all_divisions();
|
||||
let quick_divisions = &divisions[..3];
|
||||
|
||||
let strategy_specs = custom_specialists.get("Strategy")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
|
||||
let engineering_specs = custom_specialists.get("Engineering")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
|
||||
let quality_specs = custom_specialists.get("Quality")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
|
||||
|
||||
let strategy_phase = make_division_phase(&quick_divisions[0], user_request, strategy_specs);
|
||||
let engineering_phase = make_division_phase(&quick_divisions[1], user_request, engineering_specs);
|
||||
let quality_phase = make_division_phase(&quick_divisions[2], user_request, quality_specs);
|
||||
|
||||
let pipeline_primitive = ScriptPrimitive::Pipeline(vec![
|
||||
strategy_phase,
|
||||
engineering_phase,
|
||||
quality_phase,
|
||||
]);
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline-quick".to_string(),
|
||||
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
|
||||
script: pipeline_primitive,
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 10,
|
||||
continue_on_error: true,
|
||||
timeout_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
let live: Option<LiveStateFn> = turn_events.map(|events| {
|
||||
let events = events.clone();
|
||||
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||
let display_name = agent_name.chars().take(30).collect::<String>();
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id: display_name.clone(),
|
||||
agent_name: display_name,
|
||||
status,
|
||||
});
|
||||
}
|
||||
});
|
||||
f
|
||||
});
|
||||
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let results = execute_primitive(
|
||||
&wf.script, &args, wf.options.max_concurrency, true,
|
||||
abort_flag, live.as_ref(), session_dir, workspaces, &findings, None,
|
||||
)?;
|
||||
|
||||
let all_findings = findings.lock()
|
||||
.map(|f| f.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions, custom_specialists))
|
||||
}
|
||||
|
||||
/// Build a compressed executive summary from pipeline results.
|
||||
///
|
||||
/// Keeps output brief to save context window space — just division verdicts
|
||||
/// and key findings, not full outputs. Full results are accessible to the
|
||||
/// CEO via the notes/findings that were archived during execution.
|
||||
/// Flow: print user request header → for each division, fetch its specialist verdicts
|
||||
/// → join with pipes → append findings count.
|
||||
///
|
||||
/// Why: keeps output brief to save context window space. Full results are accessible
|
||||
/// to the CEO via findings.
|
||||
///
|
||||
/// Return: a formatted executive summary string.
|
||||
fn build_executive_summary(
|
||||
request: &str,
|
||||
results: &[String],
|
||||
findings: &[String],
|
||||
divisions: &[division::Division],
|
||||
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||
) -> String {
|
||||
let mut summary = String::new();
|
||||
writeln!(summary, "Pipeline for: {request}").unwrap();
|
||||
|
||||
for (i, div) in divisions.iter().enumerate() {
|
||||
let verdict = results.get(i).map_or_else(|| "—".to_string(), |r| {
|
||||
r.lines().next().unwrap_or(r)
|
||||
.chars().take(100).collect::<String>()
|
||||
});
|
||||
let mut start_index = 0;
|
||||
for div in divisions {
|
||||
let count = custom_specialists.get(div.name)
|
||||
.map_or(0, Vec::len);
|
||||
|
||||
let mut division_verdicts = Vec::new();
|
||||
for offset in 0..count {
|
||||
if let Some(r) = results.get(start_index + offset) {
|
||||
let first_line = r.lines().next().unwrap_or(r);
|
||||
let trimmed = first_line.chars().take(40).collect::<String>();
|
||||
division_verdicts.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
let verdict = if division_verdicts.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
division_verdicts.join(" | ")
|
||||
};
|
||||
|
||||
writeln!(summary, " {}: {}", div.name, verdict).unwrap();
|
||||
start_index += count;
|
||||
}
|
||||
|
||||
if !findings.is_empty() {
|
||||
@@ -228,6 +321,7 @@ fn build_executive_summary(
|
||||
/// - Negative keywords (simple/trivial/typo/quick) skip the pipeline.
|
||||
/// - Positive keywords (refactor/api/implement/architecture) trigger it.
|
||||
/// - Multi-line or multi-sentence requests are more likely complex.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_complex_request(request: &str) -> bool {
|
||||
let trimmed = request.trim();
|
||||
// Very short requests are never complex
|
||||
@@ -262,3 +356,51 @@ pub fn is_complex_request(request: &str) -> bool {
|
||||
];
|
||||
complexity_keywords.iter().any(|k| lower.contains(k))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_too_short() {
|
||||
assert!(!is_complex_request("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_simple_keywords() {
|
||||
assert!(!is_complex_request("just a simple update to the readme"));
|
||||
assert!(!is_complex_request("minor typo fix in main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_multi_sentence() {
|
||||
assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_complex_keywords() {
|
||||
assert!(is_complex_request("implement user authentication endpoint"));
|
||||
assert!(is_complex_request("refactor the whole engine module"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_division_specialists_custom() {
|
||||
let divisions = division::all_divisions();
|
||||
let div = &divisions[0];
|
||||
let mut custom = HashMap::new();
|
||||
custom.insert(
|
||||
"Strategy".to_string(),
|
||||
vec![
|
||||
("Custom Label".to_string(), "Custom Focus Description".to_string())
|
||||
]
|
||||
);
|
||||
let specs = make_division_specialists(div, "Test Request", custom.get("Strategy").unwrap());
|
||||
assert_eq!(specs.len(), 1);
|
||||
if let ScriptPrimitive::Agent(prompt) = &specs[0] {
|
||||
assert!(prompt.contains("Custom Label"));
|
||||
assert!(prompt.contains("Custom Focus Description"));
|
||||
} else {
|
||||
panic!("Expected ScriptPrimitive::Agent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
-33
@@ -15,7 +15,7 @@
|
||||
//! leaks between concurrent workflow runs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
||||
use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
@@ -98,13 +98,14 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
/// a stuck stage from blocking the entire pipeline forever.
|
||||
///
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]
|
||||
fn spawn_single_agent(
|
||||
agent_id: &str,
|
||||
agent_name: &str,
|
||||
prompt: &str,
|
||||
findings_snapshot: &[String],
|
||||
findings: &Arc<Mutex<Vec<String>>>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
@@ -133,8 +134,35 @@ fn spawn_single_agent(
|
||||
);
|
||||
}
|
||||
|
||||
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string())
|
||||
.with_max_steps(50);
|
||||
let mut role = "coder".to_string();
|
||||
let mut allowed_tools = None;
|
||||
|
||||
if agent_name.contains("Strategy") {
|
||||
let div_def = crate::app::subagent::division::strategy_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Engineering") {
|
||||
let div_def = crate::app::subagent::division::engineering_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Quality") {
|
||||
let div_def = crate::app::subagent::division::quality_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Security") {
|
||||
let div_def = crate::app::subagent::division::security_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Documentation") {
|
||||
let div_def = crate::app::subagent::division::documentation_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
}
|
||||
|
||||
let mut def = AgentDefinition::new(agent_name.to_string(), role);
|
||||
if let Some(tools) = allowed_tools {
|
||||
def = def.with_allowed_tools(tools);
|
||||
}
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
ctx.workspaces = workspaces.to_vec();
|
||||
@@ -157,9 +185,7 @@ fn spawn_single_agent(
|
||||
// Link the shared findings Arc so note_finding calls within this
|
||||
// subagent write into the same vec visible to sibling agents.
|
||||
ctx.workflow_findings = Some(findings.clone());
|
||||
// Abort flag stays None by default — the parent can set it to abort
|
||||
// long-running agents. No abort mechanism is wired yet at this level;
|
||||
// future work can expose a kill-switch per agent via the live callback.
|
||||
ctx.abort_flag.clone_from(abort_flag);
|
||||
|
||||
// Create an mpsc channel and drain events in a background thread.
|
||||
// The drain thread also pushes intra-division progress updates to the
|
||||
@@ -221,27 +247,55 @@ fn spawn_single_agent(
|
||||
}
|
||||
});
|
||||
|
||||
// Enforce timeout by running subagent on a separate thread and
|
||||
// waiting with a deadline. If the deadline expires, the thread is
|
||||
// abandoned (Rust threads cannot be forcibly killed, but we proceed
|
||||
// without waiting for it — the drain thread will drop when tx is
|
||||
// dropped on thread exit).
|
||||
let result = if let Some(timeout) = timeout_ms {
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||
let timeout_ctx = ctx;
|
||||
let timeout_tx = tx;
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(run_subagent(&timeout_ctx, &timeout_tx));
|
||||
});
|
||||
match done_rx.recv_timeout(Duration::from_millis(timeout)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"subagent '{agent_name}' timed out after {timeout}ms",
|
||||
)),
|
||||
// Check abort before even starting the subagent.
|
||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
||||
}
|
||||
|
||||
// Run subagent on a separate thread so the abort flag can be polled.
|
||||
// If abort is requested while the subagent is running, we abandon the
|
||||
// thread (Rust threads cannot be forcibly killed) and return early.
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||
let bg_ctx = ctx;
|
||||
let bg_tx = tx;
|
||||
let bg_name = agent_name.to_string();
|
||||
let bg_abort = abort_flag.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
|
||||
});
|
||||
|
||||
let poll_interval = Duration::from_millis(200);
|
||||
let result = if let Some(timeout) = timeout_ms {
|
||||
let deadline = Duration::from_millis(timeout);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
loop {
|
||||
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
|
||||
break r;
|
||||
}
|
||||
} else {
|
||||
run_subagent(&ctx, &tx)
|
||||
};
|
||||
elapsed += poll_interval;
|
||||
if elapsed >= deadline {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' timed out after {timeout}ms",
|
||||
));
|
||||
}
|
||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' aborted by user",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loop {
|
||||
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
|
||||
break r;
|
||||
}
|
||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' aborted by user",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
@@ -300,11 +354,13 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::ref_option, clippy::too_many_lines)]
|
||||
pub fn execute_primitive(
|
||||
primitive: &ScriptPrimitive,
|
||||
args: &HashMap<String, String>,
|
||||
concurrency_cap: usize,
|
||||
continue_on_error: bool,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
@@ -313,11 +369,25 @@ pub fn execute_primitive(
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(prompt) => {
|
||||
let resolved = resolve_template(prompt, args);
|
||||
let mut resolved_args = args.clone();
|
||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
if !resolved_args.contains_key("findings") {
|
||||
let formatted_findings = if findings_snapshot.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
findings_snapshot
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
resolved_args.insert("findings".to_string(), formatted_findings);
|
||||
}
|
||||
let resolved = resolve_template(prompt, &resolved_args);
|
||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) {
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
|
||||
Ok(text) => Ok(vec![text]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -348,6 +418,7 @@ pub fn execute_primitive(
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let cap = concurrency_cap;
|
||||
let abort = abort_flag.clone();
|
||||
let live_clone = live.cloned();
|
||||
let session_dir = session_dir.to_path_buf();
|
||||
let workspaces = workspaces.to_vec();
|
||||
@@ -358,6 +429,7 @@ pub fn execute_primitive(
|
||||
let _permit = sem.acquire();
|
||||
let result = execute_primitive(
|
||||
&script, &args, cap, continue_on_error,
|
||||
&abort,
|
||||
live_clone.as_ref(),
|
||||
&session_dir,
|
||||
&workspaces,
|
||||
@@ -390,13 +462,26 @@ pub fn execute_primitive(
|
||||
ScriptPrimitive::Pipeline(scripts) => {
|
||||
// Sequential: each stage runs only after the previous completes.
|
||||
//
|
||||
// Abort is checked between stages so the user can cancel the
|
||||
// pipeline immediately when moving to the next division, rather
|
||||
// than having to wait for the current subagent to finish.
|
||||
//
|
||||
// Why: parallel execution defeats the purpose of a pipeline whose
|
||||
// stages are supposed to build on each other's output. Findings
|
||||
// written by stage N are visible to stage N+1 through the shared
|
||||
// `findings` Arc (same isolation scope as parent).
|
||||
let mut all = Vec::new();
|
||||
for (idx, script) in scripts.iter().enumerate() {
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) {
|
||||
// Check abort before each pipeline stage so we don't
|
||||
// launch the next division after the user cancelled.
|
||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
if continue_on_error {
|
||||
all.push(format!("pipeline aborted at stage {idx}"));
|
||||
break;
|
||||
}
|
||||
anyhow::bail!("pipeline aborted by user at stage {idx}");
|
||||
}
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms) {
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -411,7 +496,7 @@ pub fn execute_primitive(
|
||||
}
|
||||
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms)
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,7 +511,7 @@ pub fn run_workflow(
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
) -> anyhow::Result<String> {
|
||||
run_workflow_tracked(script, args, None, session_dir, workspaces)
|
||||
run_workflow_tracked(script, args, &None, None, session_dir, workspaces)
|
||||
}
|
||||
|
||||
/// Run a `WorkflowScript` with real-time live-state callbacks so the TUI
|
||||
@@ -441,9 +526,11 @@ pub fn run_workflow(
|
||||
/// `spawn_agents` invocations remain fully isolated.
|
||||
///
|
||||
/// Return: a human-readable summary string.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_workflow_tracked(
|
||||
script: &WorkflowScript,
|
||||
args: &HashMap<String, String>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
@@ -457,7 +544,7 @@ pub fn run_workflow_tracked(
|
||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||
let results = execute_primitive(
|
||||
&script.script, args, concurrency_cap,
|
||||
script.options.continue_on_error, live,
|
||||
script.options.continue_on_error, abort_flag, live,
|
||||
session_dir, workspaces, &findings,
|
||||
script.options.timeout_ms,
|
||||
)?;
|
||||
|
||||
@@ -22,10 +22,6 @@ pub enum Command {
|
||||
WorkflowRun {
|
||||
script: String,
|
||||
},
|
||||
/// /pipeline full|quick|skip
|
||||
Pipeline {
|
||||
mode: String,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -79,15 +75,6 @@ pub fn parse_command(text: &str) -> Command {
|
||||
"/workflow" => Command::WorkflowRun {
|
||||
script: arg1.to_string(),
|
||||
},
|
||||
"/pipeline" if arg1.is_empty() => Command::Pipeline {
|
||||
mode: "status".to_string(),
|
||||
},
|
||||
"/pipeline" if arg1 == "full" || arg1 == "quick" || arg1 == "skip" => {
|
||||
Command::Pipeline {
|
||||
mode: arg1.to_string(),
|
||||
}
|
||||
}
|
||||
"/pipeline" => Command::Unknown(format!("/pipeline {arg1} (use: full|quick|skip)")),
|
||||
_ => Command::Unknown(cmd.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,3 +73,123 @@ pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write a value, read it back, and verify exact equality.
|
||||
fn roundtrip_bytes(data: &[u8]) {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
write_frame(&mut buf, data).unwrap();
|
||||
let read_back = read_frame(&mut buf.as_slice())
|
||||
.unwrap()
|
||||
.expect("expected Some(frame)");
|
||||
assert_eq!(read_back, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_empty() {
|
||||
roundtrip_bytes(b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_small_text() {
|
||||
roundtrip_bytes(b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_binary() {
|
||||
roundtrip_bytes(&[0x00, 0xFF, 0xAB, 0xCD, 0x01, 0x02, 0x03]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_large() {
|
||||
let data = vec![0x42u8; 100_000];
|
||||
roundtrip_bytes(&data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_rejects_too_large_frame() {
|
||||
let oversized = vec![0u8; MAX_FRAME_SIZE + 1];
|
||||
let mut buf = Vec::new();
|
||||
let result = write_frame(&mut buf, &oversized);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("too large") || err.contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_rejects_too_large_header() {
|
||||
// Manually craft a 4-byte length header that exceeds MAX_FRAME_SIZE
|
||||
let len = (MAX_FRAME_SIZE as u32).wrapping_add(1);
|
||||
let header = len.to_be_bytes();
|
||||
let mut buf = Vec::from(&header[..]);
|
||||
buf.extend_from_slice(b"dummy");
|
||||
let result = read_frame(&mut buf.as_slice());
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_empty_buf_returns_none() {
|
||||
let empty: &[u8] = &[];
|
||||
let result = read_frame(&mut &empty[..]).unwrap();
|
||||
assert!(result.is_none(), "expected None for empty reader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_partial_header_returns_none() {
|
||||
// Only 2 bytes of the 4-byte header → EOF
|
||||
let partial: &[u8] = &[0x00, 0x01];
|
||||
let result = read_frame(&mut &partial[..]).unwrap();
|
||||
assert!(result.is_none(), "expected None for partial header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_truncated_payload_returns_err() {
|
||||
let mut buf = Vec::new();
|
||||
let header = (10u32).to_be_bytes();
|
||||
buf.extend_from_slice(&header);
|
||||
buf.extend_from_slice(b"abc"); // only 3 of 10 bytes
|
||||
let result = read_frame(&mut buf.as_slice());
|
||||
assert!(result.is_err(), "truncated payload should error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_roundtrip() {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
struct Msg {
|
||||
id: u32,
|
||||
content: String,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
let original = Msg {
|
||||
id: 42,
|
||||
content: "hello world".into(),
|
||||
tags: vec!["foo".into(), "bar".into()],
|
||||
};
|
||||
|
||||
let bytes = serialize_frame(&original).unwrap();
|
||||
let deserialized: Msg = deserialize_frame(&bytes).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_rejects_oversized_value() {
|
||||
let huge = vec![0u8; MAX_FRAME_SIZE + 1];
|
||||
let result = serialize_frame(&huge);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_malformed_json_errors() {
|
||||
let bad_json = b"this is not json";
|
||||
let result: Result<String> = deserialize_frame(bad_json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -12,7 +12,6 @@ use std::sync::Mutex;
|
||||
use anyhow::Result;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
@@ -118,7 +117,7 @@ fn run_single_process() -> Result<()> {
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
@@ -126,7 +125,7 @@ fn run_single_process() -> Result<()> {
|
||||
let run_result = run_loop(&mut state, &mut terminal);
|
||||
|
||||
let mut restore_stdout = io::stdout();
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen, DisableMouseCapture);
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
if let Err(e) = run_result {
|
||||
@@ -468,7 +467,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
@@ -556,7 +555,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
let _ = client_state.settings.save();
|
||||
@@ -580,7 +579,6 @@ fn run_loop(
|
||||
if let Err(ref _e) = result {
|
||||
let _ = terminal.clear();
|
||||
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
}
|
||||
|
||||
@@ -145,8 +145,8 @@ mod tests {
|
||||
log.append(EditLogEntry {
|
||||
ts: i,
|
||||
tool: "edit".to_string(),
|
||||
path: format!("file{}.txt", i),
|
||||
reason: format!("reason {}", i),
|
||||
path: format!("file{i}.txt"),
|
||||
reason: format!("reason {i}"),
|
||||
content_sha256: "hash".to_string(),
|
||||
bytes_delta: 10 + i,
|
||||
origin: "main".to_string(),
|
||||
|
||||
+1
-1
@@ -375,7 +375,7 @@ mod tests {
|
||||
};
|
||||
mem.write(&dir).unwrap();
|
||||
let names = Memory::list(&dir);
|
||||
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names);
|
||||
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {names:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -141,7 +141,7 @@ impl ToolCtxBuilder {
|
||||
|
||||
/// Construct one instance of every built-in tool, in the fixed order exposed to the LLM.
|
||||
///
|
||||
/// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan,
|
||||
/// Return: boxed trait objects for all 37 tools (fs, search, bash, git, memory, plan,
|
||||
/// workflow, utility).
|
||||
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
@@ -162,6 +162,7 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
Box::new(super::tool::plan::PlanReady),
|
||||
Box::new(super::tool::workflow::WorkflowRun),
|
||||
Box::new(super::tool::workflow::NoteFinding),
|
||||
Box::new(super::tool::workflow::ReadFindings),
|
||||
Box::new(super::tool::workflow::CompanyPipeline),
|
||||
Box::new(super::tool::spawn::SpawnAgents),
|
||||
Box::new(super::tool::spawn::SpawnPipeline),
|
||||
|
||||
@@ -106,11 +106,13 @@ impl Tool for SpawnAgents {
|
||||
// by this tool call are isolated from any other concurrent
|
||||
// spawn_agents or workflow_run invocations.
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
let results = crate::app::workflow::engine::execute_primitive(
|
||||
&wf.script,
|
||||
&HashMap::new(),
|
||||
max_concurrency,
|
||||
true,
|
||||
&no_abort,
|
||||
live.as_ref(),
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
@@ -195,11 +197,13 @@ impl Tool for SpawnPipeline {
|
||||
// Per-invocation findings scope isolates this pipeline from any
|
||||
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
let results = crate::app::workflow::engine::execute_primitive(
|
||||
&wf.script,
|
||||
&HashMap::new(),
|
||||
1,
|
||||
false,
|
||||
&no_abort,
|
||||
live.as_ref(),
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
|
||||
+76
-1
@@ -171,9 +171,22 @@ impl Tool for CompanyPipeline {
|
||||
"enum": ["full", "quick"],
|
||||
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks",
|
||||
"default": "full"
|
||||
},
|
||||
"specialists": {
|
||||
"type": "object",
|
||||
"description": "Mapping from division name (Strategy, Engineering, Quality, Security, Documentation) to list of custom specialists. Each specialist is defined by a pair of [label, focus_description]. This parameter is mandatory. The CEO/main agent must fully define the specialized roles and focuses for every division in the pipeline to run.",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["request"]
|
||||
"required": ["request", "specialists"]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,6 +199,24 @@ impl Tool for CompanyPipeline {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("full");
|
||||
|
||||
let custom_specialists: std::collections::HashMap<String, Vec<(String, String)>> = args.get("specialists")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter().map(|(k, v)| {
|
||||
let specs = v.as_array().map(|arr| {
|
||||
arr.iter().filter_map(|item| {
|
||||
let pair = item.as_array()?;
|
||||
let label = pair.first()?.as_str()?.to_string();
|
||||
let focus = pair.get(1)?.as_str()?.to_string();
|
||||
Some((label, focus))
|
||||
}).collect()
|
||||
}).unwrap_or_default();
|
||||
(k.clone(), specs)
|
||||
}).collect()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing required argument: specialists"))?;
|
||||
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
match mode {
|
||||
"quick" => {
|
||||
crate::app::workflow::company::run_company_pipeline_quick(
|
||||
@@ -193,6 +224,8 @@ impl Tool for CompanyPipeline {
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
ctx.turn_events.as_ref(),
|
||||
&no_abort,
|
||||
&custom_specialists,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
@@ -201,8 +234,50 @@ impl Tool for CompanyPipeline {
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
ctx.turn_events.as_ref(),
|
||||
&no_abort,
|
||||
&custom_specialists,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool that retrieves all findings shared by sibling agents in the current workflow run.
|
||||
pub struct ReadFindings;
|
||||
|
||||
impl Tool for ReadFindings {
|
||||
fn name(&self) -> &'static str {
|
||||
"read_findings"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Retrieve all findings shared by sibling agents in the current workflow run. Use this to get real-time context updates from other divisions/subagents working in parallel."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
if let Some(ref findings) = ctx.workflow_findings {
|
||||
let f = findings.lock().map_err(|e| anyhow!("poisoned lock: {e}"))?;
|
||||
if f.is_empty() {
|
||||
Ok("No findings recorded yet in this workflow run.".to_string())
|
||||
} else {
|
||||
let formatted = f
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(format!("Findings in this workflow run:\n{formatted}"))
|
||||
}
|
||||
} else {
|
||||
Ok("No findings database available (called outside a workflow run).".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user