Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e0dcae14 | ||
|
|
c60fadb88a | ||
|
|
4a297669b4 | ||
|
|
2e351ccf69 | ||
|
|
1d50b94eec | ||
|
|
00e29139c5 | ||
|
|
3b660e09a8 | ||
|
|
0d6f558b2b | ||
|
|
8388a83af0 | ||
|
|
104b0daf4c | ||
|
|
65647ce517 |
@@ -1,3 +1,28 @@
|
|||||||
|
# [1.3.0](https://github.com/asepharyana/zesdex/compare/v1.2.0...v1.3.0) (2026-07-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* enhance edit logging in subagent execution and streamline edit tracking in run_agent_turn ([c60fadb](https://github.com/asepharyana/zesdex/commit/c60fadb88ae63788a5bbe3c3443e2ce826e5778f))
|
||||||
|
|
||||||
|
# [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)
|
## [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
|
## 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/`:
|
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
|
Controller (key input → Action) → Event Loop → LLM stream → Tool execution → State mutation → TUI render
|
||||||
│ │ │
|
│ │ │
|
||||||
│ src/controller/input.rs │ src/app/runtime/actions/ │ src/tool/
|
│ 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
|
│ matching on Action variant
|
||||||
│── applies state mutations
|
│── applies state mutations
|
||||||
```
|
```
|
||||||
@@ -167,3 +167,4 @@ Rules:
|
|||||||
- Non-trivial private functions (≥10 lines) need a doc comment
|
- Non-trivial private functions (≥10 lines) need a doc comment
|
||||||
- Write the comment above the code it documents (not inline in the body)
|
- 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
|
- 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]
|
[package]
|
||||||
name = "zesdex"
|
name = "zesdex"
|
||||||
version = "1.0.4"
|
version = "1.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ pedantic = { level = "warn", priority = -2 }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.30.2"
|
ratatui = "0.30.2"
|
||||||
crossterm = "0.28"
|
crossterm = "0.29"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
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"
|
dom_smoothie = "0.18.0"
|
||||||
fast_html2md = "0.0.62"
|
fast_html2md = "0.0.62"
|
||||||
scraper = "0.27.0"
|
scraper = "0.27.0"
|
||||||
@@ -33,23 +33,24 @@ url = "2"
|
|||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml_ng = "0.9"
|
serde_yaml_ng = "0.10"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
uuid = { version = "1", features = ["v4", "v5"] }
|
uuid = { version = "1", features = ["v4", "v5"] }
|
||||||
dirs = "5"
|
dirs = "6"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
pulldown-cmark = { version = "0.13", default-features = false }
|
pulldown-cmark = { version = "0.13", default-features = false }
|
||||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
infer = "0.16"
|
infer = "0.19"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11"
|
||||||
|
hex = "0.4"
|
||||||
libc = "0.2"
|
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"
|
tracing = "0.1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
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.
|
- **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.
|
- **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 |
|
| 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` |
|
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
|
||||||
| **Memory** | `remember`, `recall`, `forget` |
|
| **Memory** | `remember`, `recall`, `forget` |
|
||||||
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
|
| **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` |
|
| **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` |
|
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
|
||||||
|
|
||||||
### Intelligence
|
### Intelligence
|
||||||
@@ -83,6 +84,7 @@ src/
|
|||||||
│ │ ├── effort.rs # Effort level selector
|
│ │ ├── effort.rs # Effort level selector
|
||||||
│ │ ├── help.rs # Help overlay
|
│ │ ├── help.rs # Help overlay
|
||||||
│ │ ├── key_input.rs # Raw key input mode
|
│ │ ├── key_input.rs # Raw key input mode
|
||||||
|
│ │ ├── learning.rs # Lesson management overlay
|
||||||
│ │ ├── loading.rs # Loading spinner overlay
|
│ │ ├── loading.rs # Loading spinner overlay
|
||||||
│ │ ├── mcp.rs # MCP server management
|
│ │ ├── mcp.rs # MCP server management
|
||||||
│ │ ├── quit_confirm.rs # Quit confirmation dialog
|
│ │ ├── 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- │ │
|
│ │ Tools │ │Sub- │ │
|
||||||
│ │ (28) │ │agents │ │
|
│ │ (37) │ │agents │ │
|
||||||
│ └────────┘ └────────┘ │
|
│ └────────┘ └────────┘ │
|
||||||
└───────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -55,7 +55,7 @@ User keystroke → Controller (KeyEvent → Action)
|
|||||||
|
|
||||||
| File | Lines | Role |
|
| File | Lines | Role |
|
||||||
|------|-------|------|
|
|------|-------|------|
|
||||||
| `src/main.rs` | 530 | Entry, TUI setup, daemon loop, attach loop |
|
| `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
|
||||||
| `src/app/runtime/actions/mod.rs` | 1022 | Action dispatch + LLM stream loop + tool execution |
|
| `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
|
||||||
| `src/controller/input.rs` | 281 | Key event → Action mapping |
|
| `src/controller/input.rs` | 365 | Key event → Action mapping |
|
||||||
| `src/view/mod.rs` | 623 | TUI rendering (ratatui) |
|
| `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
|
||||||
|
|||||||
+23
-16
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## AI Provider
|
## 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
|
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
|
||||||
- `chat_with_tools()` — non-streaming with tool definitions
|
- `chat_with_tools()` — non-streaming with tool definitions
|
||||||
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
|
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
|
||||||
@@ -18,51 +18,58 @@
|
|||||||
|
|
||||||
## IPC / Daemon
|
## IPC / Daemon
|
||||||
|
|
||||||
`src/ipc/` (7 files, ~300 lines total)
|
`src/ipc/` (7 files, ~350 lines total)
|
||||||
- Unix domain socket, length-prefixed JSON frames
|
- Unix domain socket, length-prefixed JSON frames
|
||||||
- Daemon sends `DaemonFrame { state: StatePayload, diff, tasks }` to clients
|
- Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
|
||||||
- Clients send `ClientRequest { action: Action }` back
|
- Clients send `ClientRequest` (key presses, resize, submit, scroll)
|
||||||
- State sync uses snapshots + binary diffs (rsync-style, not git)
|
- State sync uses full-state push from daemon to client after each action
|
||||||
|
|
||||||
## Workflow Engine
|
## 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
|
- Inline JS-style DSL executed by a lightweight runtime
|
||||||
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
|
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
|
||||||
- Max concurrency configurable via `workflow_max_concurrency` setting
|
- 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
|
## Sub-Agent System
|
||||||
|
|
||||||
`src/app/subagent/` (4 files, ~250 lines)
|
`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 & context
|
- `run_subagent()` — spawns independent agent with its own tool set and context
|
||||||
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
|
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
|
||||||
- Uses `LlmClient` (same as main agent) with tool-use API
|
- 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
|
## 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
|
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout
|
||||||
- HTTP transport: streaming HTTP with JSON-RPC
|
- 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)
|
- Persistent child handle for stdio (reuses connection across calls)
|
||||||
|
|
||||||
## Self-Review
|
## Self-Review
|
||||||
|
|
||||||
`src/app/review/mod.rs` (437 lines)
|
`src/app/review/mod.rs` (495 lines)
|
||||||
- Post-tool execution quality check against learned lessons
|
- Post-tool execution quality check against learned lessons
|
||||||
- Invokes `run_subagent()` with reviewer prompt
|
- Invokes `run_subagent()` with reviewer prompt
|
||||||
- Staleness detection: skips review after N consecutive empty results
|
- Staleness detection: skips review after N consecutive empty results
|
||||||
|
- Three review types: code quality, architecture, security
|
||||||
|
|
||||||
## Background Bash
|
## 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
|
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
|
||||||
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
|
- 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
|
## 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)
|
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
|
||||||
- Parses LLM verdicts (JSON or plain-text)
|
- Path traversal, credential read, and destructive command detection
|
||||||
- `test_parse_verdict_*` tests for 6 verdict formats
|
- 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
|
│ └── *.md # Markdown with YAML frontmatter
|
||||||
├── sessions/ # Per-session data
|
├── sessions/ # Per-session data
|
||||||
│ └── <session-uuid>/
|
│ └── <session-uuid>/
|
||||||
│ ├── editlog.json # Edit history
|
│ ├── edits.jsonl # Edit history (JSONL, append-only)
|
||||||
│ ├── msglog.db # SQLite message log
|
│ ├── msglog.db # SQLite message log
|
||||||
│ ├── transcript.json # Chat transcript
|
│ ├── transcript.json # Chat transcript
|
||||||
│ ├── session.json # Session metadata
|
│ ├── 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/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/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
|
||||||
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
|
| `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/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
|
||||||
| `src/model/editlog.rs` | 121 | Edit log — append-only JSON array |
|
| `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/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
|
||||||
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
|
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
|
||||||
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
|
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
|
||||||
|
|||||||
@@ -7,23 +7,23 @@
|
|||||||
| Crate | Version | Purpose |
|
| Crate | Version | Purpose |
|
||||||
|-------|---------|---------|
|
|-------|---------|---------|
|
||||||
| ratatui | 0.30 | TUI framework (tui-rs successor) |
|
| 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) |
|
| tokio | 1 | Async runtime (daemon, OAuth loopback) |
|
||||||
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
|
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
|
||||||
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
|
| 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) |
|
| anyhow | 1 | Error handling (no custom error types) |
|
||||||
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
|
| 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) |
|
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
|
||||||
| syntect | 5 | Syntax highlighting (code blocks in chat) |
|
| syntect | 5 | Syntax highlighting (code blocks in chat) |
|
||||||
| sha2 | 0.10 | SHA-256 for PKCE challenge |
|
| sha2 | 0.10 | SHA-256 for PKCE challenge |
|
||||||
| base64 | 0.22 | URL-safe base64 for PKCE |
|
| base64 | 0.22 | URL-safe base64 for PKCE |
|
||||||
| libc | 0.2 | daemon PID file locking |
|
| 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 |
|
| uuid | 1 | Session IDs, job IDs |
|
||||||
| chrono | 0.4 | Timestamps (ISO 8601, millis) |
|
| 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) |
|
| dom_smoothie | 0.18 | HTML → plain text (web scraping) |
|
||||||
| scraper | 0.27 | HTML parsing (web scraping) |
|
| scraper | 0.27 | HTML parsing (web scraping) |
|
||||||
| ignore | 0.4 | .gitignore-aware file walking (glob tool) |
|
| 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.
|
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.
|
3. Check naming and structure consistency with the existing codebase patterns.
|
||||||
4. Check that the implementation matches the apparent intent.
|
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.
|
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).
|
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.
|
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.
|
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`
|
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
|
## Output
|
||||||
After each file: confirm what was implemented and any deviations from plan.
|
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
|
- Use `seqthink` for complex reasoning steps
|
||||||
- Every plan MUST include at least one mermaid diagram
|
- Every plan MUST include at least one mermaid diagram
|
||||||
- Be specific with file paths and function names
|
- 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
|
- Output ends with a clear "Plan Complete" marker
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Check for:
|
|||||||
- Stubs, placeholders, incomplete branches
|
- Stubs, placeholders, incomplete branches
|
||||||
- Naming consistency with codebase conventions
|
- Naming consistency with codebase conventions
|
||||||
- Error handling coverage
|
- 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
|
## Phase 2: Test
|
||||||
Use write to create test files. Follow these rules:
|
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.
|
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.
|
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?
|
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.
|
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
|
- After changes, run builds and tests
|
||||||
- Use LSP diagnostics after each file edit
|
- Use LSP diagnostics after each file edit
|
||||||
- Every code path must be fully implemented and deterministic
|
- 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);
|
return Some(Verdict::Allow);
|
||||||
}
|
}
|
||||||
if l.starts_with("verdict: block") {
|
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));
|
return Some(Verdict::Block(reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
|||||||
tool: "rewind".to_string(),
|
tool: "rewind".to_string(),
|
||||||
path: restore_path.to_string_lossy().to_string(),
|
path: restore_path.to_string_lossy().to_string(),
|
||||||
reason: format!("rewind_to({index})"),
|
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,
|
bytes_delta: bytes.len() as i64,
|
||||||
origin: crate::app::state::types::Origin::Main.tag(),
|
origin: crate::app::state::types::Origin::Main.tag(),
|
||||||
session_id: state.session_id.clone(),
|
session_id: state.session_id.clone(),
|
||||||
|
|||||||
+222
-174
@@ -84,10 +84,6 @@ pub enum Action {
|
|||||||
RunWorkflow {
|
RunWorkflow {
|
||||||
script: String,
|
script: String,
|
||||||
},
|
},
|
||||||
/// User-initiated pipeline via `/pipeline full|quick|skip`.
|
|
||||||
RunPipeline {
|
|
||||||
mode: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an `Action` to the application state.
|
/// 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.agents.clear();
|
||||||
state.workflow_engine.findings.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 {
|
state.push_toast(Toast {
|
||||||
kind: ToastKind::Info,
|
kind: ToastKind::Info,
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
@@ -529,10 +530,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if turn_finished {
|
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);
|
maybe_trigger_review(state);
|
||||||
|
if state.misc.overlay == Overlay::Workflow {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if turn_finished || state.dirty {
|
if turn_finished || state.dirty {
|
||||||
state.dirty = true;
|
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.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {name}")));
|
||||||
state.dirty = true;
|
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 } => {
|
Action::RunWorkflow { script } => {
|
||||||
// Open the Workflow overlay so the user can see progress.
|
// Open the Workflow overlay so the user can see progress.
|
||||||
state.misc.overlay = Overlay::Workflow;
|
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 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(
|
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 {
|
let (kind, message) = match result {
|
||||||
@@ -774,7 +753,6 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
}) = true;
|
}) = true;
|
||||||
|
|
||||||
let events_q = turn_events.clone();
|
let events_q = turn_events.clone();
|
||||||
let pipeline_mode = state.misc.pipeline_override.clone();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let db = crate::model::msglog::open_or_create(&edit_session_dir)
|
let db = crate::model::msglog::open_or_create(&edit_session_dir)
|
||||||
@@ -794,7 +772,6 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
temperature,
|
temperature,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
abort_flag,
|
abort_flag,
|
||||||
pipeline_mode,
|
|
||||||
};
|
};
|
||||||
let result = run_agent_turn(&tc, &messages, &events_q);
|
let result = run_agent_turn(&tc, &messages, &events_q);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -823,9 +800,6 @@ struct TurnCtx {
|
|||||||
temperature: f32,
|
temperature: f32,
|
||||||
max_tokens: Option<u32>,
|
max_tokens: Option<u32>,
|
||||||
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
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
|
/// 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.
|
/// Maximum number of auto inline reviews spawned per single agent turn.
|
||||||
/// After N edits, the inline review is skipped to keep the turn fast;
|
/// After N edits, the inline review is skipped to keep the turn fast;
|
||||||
/// background subagents still fire at the end of the turn.
|
/// background subagents still fire at the end of the turn.
|
||||||
@@ -1001,11 +965,10 @@ fn run_agent_turn(
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
const MAX_TODO_RETRIES: usize = 5;
|
const MAX_TODO_RETRIES: usize = 5;
|
||||||
let mut msgs = messages.to_vec();
|
let mut msgs = messages.to_vec();
|
||||||
let mut edits_this_turn = 0u32;
|
|
||||||
let mut edited_paths: Vec<String> = Vec::new();
|
let mut edited_paths: Vec<String> = Vec::new();
|
||||||
|
let initial_edits = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir).len();
|
||||||
let mut inline_reviews_count: usize = 0;
|
let mut inline_reviews_count: usize = 0;
|
||||||
let mut prev_shaped = false;
|
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
|
// Build system prompt components once and cache them for the entire turn
|
||||||
// instead of regenerating on every loop iteration (which walks the full
|
// instead of regenerating on every loop iteration (which walks the full
|
||||||
@@ -1027,11 +990,6 @@ fn run_agent_turn(
|
|||||||
|
|
||||||
// ── AUTO CEO PIPELINE ──
|
// ── AUTO CEO PIPELINE ──
|
||||||
// Before the main agent starts working, check if the pipeline should run.
|
// 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()
|
let user_msg_count = msgs.iter()
|
||||||
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
||||||
.count();
|
.count();
|
||||||
@@ -1044,14 +1002,7 @@ fn run_agent_turn(
|
|||||||
if user_request.is_empty() {
|
if user_request.is_empty() {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
match tc.pipeline_mode.as_deref() {
|
crate::app::workflow::company::is_complex_request(user_request)
|
||||||
Some("skip") => {
|
|
||||||
tracing::debug!("[ceo] pipeline skipped via /pipeline skip");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Some("full" | "quick") => true,
|
|
||||||
_ => crate::app::workflow::company::is_complex_request(user_request),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
@@ -1063,10 +1014,10 @@ fn run_agent_turn(
|
|||||||
.and_then(|m| m.content.as_deref())
|
.and_then(|m| m.content.as_deref())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let use_full = tc.pipeline_mode.as_deref() != Some("quick");
|
let use_full = true;
|
||||||
let mode_label = if use_full { "full" } else { "quick" };
|
let mode_label = "full";
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[ceo] pipeline triggered (mode={}) — delegating to company pipeline",
|
"[ceo] pipeline triggered (mode={}) — dynamically generating planning workflow via LLM",
|
||||||
mode_label
|
mode_label
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1074,28 +1025,107 @@ fn run_agent_turn(
|
|||||||
q.push_back(TurnEvent::SystemNote {
|
q.push_back(TurnEvent::SystemNote {
|
||||||
kind: "pipeline".to_string(),
|
kind: "pipeline".to_string(),
|
||||||
message: format!(
|
message: format!(
|
||||||
"Company pipeline started ({}): {} → Engineering → Quality{}",
|
"CEO is planning workflow (mode={mode_label})...",
|
||||||
mode_label,
|
|
||||||
"Strategy",
|
|
||||||
if use_full { " → Security → Documentation" } else { "" },
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let pipeline_result = if use_full {
|
let pipeline_abort = Some(tc.abort_flag.clone());
|
||||||
crate::app::workflow::company::run_company_pipeline(
|
|
||||||
user_request,
|
// Ask LLM to dynamically generate the workflow specialists plan
|
||||||
&tc.edit_log_session_dir,
|
let required_divisions = if use_full {
|
||||||
&tc.workspace_roots,
|
"all 5 divisions (Strategy, Engineering, Quality, Security, Documentation)"
|
||||||
Some(events_q),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
crate::app::workflow::company::run_company_pipeline_quick(
|
"the 3 quick divisions (Strategy, Engineering, Quality)"
|
||||||
user_request,
|
};
|
||||||
&tc.edit_log_session_dir,
|
let example_json = if use_full {
|
||||||
&tc.workspace_roots,
|
r#"{
|
||||||
Some(events_q),
|
"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 {
|
match pipeline_result {
|
||||||
@@ -1127,24 +1157,19 @@ fn run_agent_turn(
|
|||||||
tracing::debug!("[ceo] pipeline not triggered — handling directly");
|
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;
|
let mut todo_retry_count = 0usize;
|
||||||
|
|
||||||
loop {
|
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()
|
let total_chars: usize = msgs.iter()
|
||||||
.filter_map(|m| m.content.as_deref())
|
.filter_map(|m| m.content.as_deref())
|
||||||
.map(str::len)
|
.map(str::len)
|
||||||
@@ -1152,7 +1177,11 @@ fn run_agent_turn(
|
|||||||
let token_estimate = total_chars / 4;
|
let token_estimate = total_chars / 4;
|
||||||
let max_wire_tokens = tc.context_window;
|
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;
|
prev_shaped = true;
|
||||||
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
|
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 {
|
let (response, final_usage) = match result {
|
||||||
Ok((msg, u)) => (msg, u.or(usage)),
|
Ok((msg, u)) => (msg, u.or(usage)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// If abort was requested, return immediately.
|
||||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
|
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
|
||||||
if let Ok(mut q) = events_q.lock() {
|
if let Ok(mut q) = events_q.lock() {
|
||||||
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
|
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match tc.client.chat_with_tools_non_streaming(&wire_msgs, Some(tc.tdefs.clone())) {
|
// Streaming-only: no non-streaming fallback.
|
||||||
Ok((msg, usage_fb)) => (msg, usage_fb),
|
// Non-streaming blocks up to 1 minute without checking
|
||||||
Err(api_err) => {
|
// abort_flag, making cancellation unresponsive.
|
||||||
let todo_path = tc.ctx.session_dir.join("todo.md");
|
// If the API supports streaming (which it must), this
|
||||||
let mut has_unfinished = false;
|
// path handles transient errors via the retry loop below.
|
||||||
if let Ok(todo_text) = std::fs::read_to_string(&todo_path) {
|
let api_err = e;
|
||||||
if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) {
|
let todo_path = tc.ctx.session_dir.join("todo.md");
|
||||||
has_unfinished = true;
|
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("- [ ]")) {
|
||||||
if has_unfinished {
|
has_unfinished = true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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,48 +1310,62 @@ fn run_agent_turn(
|
|||||||
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
||||||
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
||||||
msgs.push(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 tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
if let Ok(mut q) = events_q.lock() {
|
if let Ok(mut q) = events_q.lock() {
|
||||||
q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
|
q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
|
||||||
}
|
}
|
||||||
return Ok(());
|
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 {
|
if is_edit {
|
||||||
edits_this_turn += 1;
|
|
||||||
|
|
||||||
// ── Auto-subagent orchestration ──
|
// ── Auto-subagent orchestration ──
|
||||||
// Extract path from tool args for auto-review and
|
// Extract path from tool args for auto-review and
|
||||||
// background subagent tracking.
|
// background subagent tracking.
|
||||||
@@ -1374,7 +1419,6 @@ fn run_agent_turn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string);
|
let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1442,24 +1486,28 @@ fn run_agent_turn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if edits_this_turn > 0 {
|
let el = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir);
|
||||||
|
let final_edits = el.len();
|
||||||
|
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
|
||||||
|
|
||||||
|
if total_edits_this_turn > 0 {
|
||||||
if let Ok(mut q) = events_q.lock() {
|
if let Ok(mut q) = events_q.lock() {
|
||||||
q.push_back(TurnEvent::SystemNote {
|
q.push_back(TurnEvent::SystemNote {
|
||||||
kind: "edits".to_string(),
|
kind: "edits".to_string(),
|
||||||
message: edits_this_turn.to_string(),
|
message: total_edits_this_turn.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect edited paths from the new edit log entries
|
||||||
|
let mut bg_paths = Vec::new();
|
||||||
|
for entry in el.entries.iter().skip(initial_edits) {
|
||||||
|
bg_paths.push(entry.path.clone());
|
||||||
|
}
|
||||||
|
bg_paths.sort();
|
||||||
|
bg_paths.dedup();
|
||||||
|
|
||||||
// ── Background auto-subagents ──
|
// ── Background auto-subagents ──
|
||||||
// After a turn with edits, spawn deeper-analysis subagents in the
|
if !bg_paths.is_empty() {
|
||||||
// background (test generation, architecture review, security review).
|
|
||||||
// These run asynchronously on OS threads and report via SystemNote
|
|
||||||
// events, so they do not block the main agent or TUI.
|
|
||||||
//
|
|
||||||
// Only spawn background agents if we actually accumulated paths
|
|
||||||
// (safety check — should always be true when edits_this_turn > 0).
|
|
||||||
if !edited_paths.is_empty() {
|
|
||||||
let bg_paths = edited_paths.clone();
|
|
||||||
let bg_session_dir = tc.edit_log_session_dir.clone();
|
let bg_session_dir = tc.edit_log_session_dir.clone();
|
||||||
let bg_workspaces = tc.workspace_roots.clone();
|
let bg_workspaces = tc.workspace_roots.clone();
|
||||||
let bg_events = events_q.clone();
|
let bg_events = events_q.clone();
|
||||||
@@ -1538,7 +1586,7 @@ fn execute_one_tool(
|
|||||||
let hash = sha2::Sha256::digest(
|
let hash = sha2::Sha256::digest(
|
||||||
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
||||||
);
|
);
|
||||||
format!("{hash:x}")
|
hex::encode(hash)
|
||||||
};
|
};
|
||||||
let bytes_delta = if name == "write" {
|
let bytes_delta = if name == "write" {
|
||||||
args.get("content")
|
args.get("content")
|
||||||
@@ -1674,7 +1722,7 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
|||||||
|
|
||||||
let verifier = CodeVerifier::new();
|
let verifier = CodeVerifier::new();
|
||||||
let challenge = verifier.challenge();
|
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 mut manager = OAuthManager::new(config.clone());
|
||||||
let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str());
|
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 } => {
|
Command::WorkflowRun { script } => {
|
||||||
vec![Action::RunWorkflow { script }]
|
vec![Action::RunWorkflow { script }]
|
||||||
}
|
}
|
||||||
Command::Pipeline { mode } => {
|
|
||||||
vec![Action::RunPipeline { mode }]
|
|
||||||
}
|
|
||||||
Command::Unknown(cmd) => {
|
Command::Unknown(cmd) => {
|
||||||
vec![Action::SystemNote {
|
vec![Action::SystemNote {
|
||||||
kind: "error".to_string(),
|
kind: "error".to_string(),
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ mod tests {
|
|||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
match &events[0] {
|
match &events[0] {
|
||||||
StreamEvent::Token(t) => assert_eq!(t, "hello"),
|
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);
|
assert_eq!(e2.len(), 1);
|
||||||
match &e2[0] {
|
match &e2[0] {
|
||||||
StreamEvent::Token(t) => assert_eq!(t, "partial"),
|
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!(name.as_deref(), Some("bash"));
|
||||||
assert_eq!(arguments_delta, "{\"cmd\"");
|
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!(*completion_tokens, 5);
|
||||||
assert_eq!(*total_tokens, 15);
|
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!(a, "a");
|
||||||
assert_eq!(b, "b");
|
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",
|
"/model add",
|
||||||
"/workflow",
|
"/workflow",
|
||||||
"/workflow run",
|
"/workflow run",
|
||||||
"/pipeline",
|
|
||||||
"/pipeline full",
|
|
||||||
"/pipeline quick",
|
|
||||||
"/pipeline skip",
|
|
||||||
"/compact",
|
"/compact",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -293,14 +289,6 @@ pub struct MiscState {
|
|||||||
pub api_context_length: Option<u32>,
|
pub api_context_length: Option<u32>,
|
||||||
pub tick_count: u64,
|
pub tick_count: u64,
|
||||||
pub todo_content: String,
|
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 {
|
impl MiscState {
|
||||||
@@ -319,7 +307,6 @@ impl MiscState {
|
|||||||
api_context_length: None,
|
api_context_length: None,
|
||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
todo_content: String::new(),
|
todo_content: String::new(),
|
||||||
pipeline_override: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ impl AppStateRest {
|
|||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
let mut hasher = sha2::Sha256::new();
|
let mut hasher = sha2::Sha256::new();
|
||||||
hasher.update(abs_root.to_string_lossy().as_bytes());
|
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 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_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||||||
let history_dir = base_dir.join("history");
|
let history_dir = base_dir.join("history");
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ const SKIP_REVIEW_FILES: &[&str] = &[
|
|||||||
".gitignore", ".env", ".env.example",
|
".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 ───
|
/// ─── Helpers ───
|
||||||
///
|
///
|
||||||
/// Check whether a file path is worth auto-reviewing (not config/lock/data).
|
/// 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(),
|
"quick-reviewer".to_string(),
|
||||||
"reviewer".to_string(),
|
"reviewer".to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(prompt)
|
.with_system_prompt(prompt);
|
||||||
.with_max_steps(QUICK_REVIEW_MAX_STEPS);
|
|
||||||
|
|
||||||
let mut ctx = build_subagent_context(&def);
|
let mut ctx = build_subagent_context(&def);
|
||||||
ctx.session_dir = session_dir.to_path_buf();
|
ctx.session_dir = session_dir.to_path_buf();
|
||||||
@@ -189,7 +182,7 @@ pub fn spawn_background_test_gen(
|
|||||||
"coder".to_string(), // needs write access
|
"coder".to_string(), // needs write access
|
||||||
)
|
)
|
||||||
.with_system_prompt(prompt)
|
.with_system_prompt(prompt)
|
||||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
;
|
||||||
|
|
||||||
let mut ctx = build_subagent_context(&def);
|
let mut ctx = build_subagent_context(&def);
|
||||||
ctx.session_dir = sd;
|
ctx.session_dir = sd;
|
||||||
@@ -269,7 +262,7 @@ pub fn spawn_background_arch_review(
|
|||||||
"reviewer".to_string(),
|
"reviewer".to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(prompt)
|
.with_system_prompt(prompt)
|
||||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
;
|
||||||
|
|
||||||
let mut ctx = build_subagent_context(&def);
|
let mut ctx = build_subagent_context(&def);
|
||||||
ctx.session_dir = sd;
|
ctx.session_dir = sd;
|
||||||
@@ -355,7 +348,7 @@ pub fn spawn_background_security_review(
|
|||||||
"reviewer".to_string(),
|
"reviewer".to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(prompt)
|
.with_system_prompt(prompt)
|
||||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
;
|
||||||
|
|
||||||
let mut ctx = build_subagent_context(&def);
|
let mut ctx = build_subagent_context(&def);
|
||||||
ctx.session_dir = sd;
|
ctx.session_dir = sd;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let max_steps = def.max_steps.unwrap_or(25);
|
let max_steps = def.max_steps.unwrap_or(usize::MAX);
|
||||||
SubagentContext {
|
SubagentContext {
|
||||||
system_prompt: String::new(),
|
system_prompt: String::new(),
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ pub fn strategy_division() -> AgentDefinition {
|
|||||||
roles::STRATEGY.to_string(),
|
roles::STRATEGY.to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string())
|
.with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string())
|
||||||
.with_max_steps(15)
|
|
||||||
.with_allowed_tools(vec![
|
.with_allowed_tools(vec![
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"grep".to_string(),
|
"grep".to_string(),
|
||||||
@@ -57,6 +56,7 @@ pub fn strategy_division() -> AgentDefinition {
|
|||||||
"lsp_hover".to_string(),
|
"lsp_hover".to_string(),
|
||||||
"lsp_definition".to_string(),
|
"lsp_definition".to_string(),
|
||||||
"lsp_references".to_string(),
|
"lsp_references".to_string(),
|
||||||
|
"read_findings".to_string(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ pub fn engineering_division() -> AgentDefinition {
|
|||||||
roles::ENGINEERING.to_string(),
|
roles::ENGINEERING.to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string())
|
.with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string())
|
||||||
.with_max_steps(50)
|
|
||||||
.with_allowed_tools(vec![
|
.with_allowed_tools(vec![
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"write".to_string(),
|
"write".to_string(),
|
||||||
@@ -90,6 +89,7 @@ pub fn engineering_division() -> AgentDefinition {
|
|||||||
"lsp_disconnect".to_string(),
|
"lsp_disconnect".to_string(),
|
||||||
"todowrite".to_string(),
|
"todowrite".to_string(),
|
||||||
"todofinish".to_string(),
|
"todofinish".to_string(),
|
||||||
|
"read_findings".to_string(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,6 @@ pub fn quality_division() -> AgentDefinition {
|
|||||||
roles::QUALITY.to_string(),
|
roles::QUALITY.to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string())
|
.with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string())
|
||||||
.with_max_steps(30)
|
|
||||||
.with_allowed_tools(vec![
|
.with_allowed_tools(vec![
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"write".to_string(),
|
"write".to_string(),
|
||||||
@@ -119,6 +118,7 @@ pub fn quality_division() -> AgentDefinition {
|
|||||||
"lsp_hover".to_string(),
|
"lsp_hover".to_string(),
|
||||||
"lsp_definition".to_string(),
|
"lsp_definition".to_string(),
|
||||||
"lsp_references".to_string(),
|
"lsp_references".to_string(),
|
||||||
|
"read_findings".to_string(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,6 @@ pub fn security_division() -> AgentDefinition {
|
|||||||
roles::SECURITY.to_string(),
|
roles::SECURITY.to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string())
|
.with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string())
|
||||||
.with_max_steps(15)
|
|
||||||
.with_allowed_tools(vec![
|
.with_allowed_tools(vec![
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"grep".to_string(),
|
"grep".to_string(),
|
||||||
@@ -146,6 +145,7 @@ pub fn security_division() -> AgentDefinition {
|
|||||||
"lsp_hover".to_string(),
|
"lsp_hover".to_string(),
|
||||||
"lsp_definition".to_string(),
|
"lsp_definition".to_string(),
|
||||||
"lsp_references".to_string(),
|
"lsp_references".to_string(),
|
||||||
|
"read_findings".to_string(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,6 @@ pub fn documentation_division() -> AgentDefinition {
|
|||||||
roles::DOCUMENTATION.to_string(),
|
roles::DOCUMENTATION.to_string(),
|
||||||
)
|
)
|
||||||
.with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string())
|
.with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string())
|
||||||
.with_max_steps(15)
|
|
||||||
.with_allowed_tools(vec![
|
.with_allowed_tools(vec![
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"write".to_string(),
|
"write".to_string(),
|
||||||
@@ -168,6 +167,7 @@ pub fn documentation_division() -> AgentDefinition {
|
|||||||
"glob".to_string(),
|
"glob".to_string(),
|
||||||
"recall".to_string(),
|
"recall".to_string(),
|
||||||
"remember".to_string(),
|
"remember".to_string(),
|
||||||
|
"read_findings".to_string(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+166
-62
@@ -28,10 +28,16 @@ use super::event::SubagentEvent;
|
|||||||
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||||
let all = all_tools();
|
let all = all_tools();
|
||||||
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
|
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 {
|
} else {
|
||||||
all.into_iter()
|
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()
|
.collect()
|
||||||
};
|
};
|
||||||
let defs = tool_defs(&filtered);
|
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
|
/// Flow: inject system prompt (with workspace tree if available) → for each
|
||||||
/// step: resolve provider config, build an LLM client, call
|
/// step: resolve provider config, build an LLM client, call
|
||||||
/// `chat_with_tools_non_streaming`, process tool calls (gated against both
|
/// `chat_with_tools_streaming` (with abort check per SSE event), process
|
||||||
/// the allowlist and Harness-style content safety checks) or collect text
|
/// tool calls (gated against both the allowlist and Harness-style content
|
||||||
/// output → send `SubagentEvent`s on `tx` → break on first text-only
|
/// safety checks) or collect text output → send `SubagentEvent`s on `tx` →
|
||||||
/// (non-empty) response.
|
/// break on first text-only (non-empty) response.
|
||||||
///
|
///
|
||||||
/// Why: runs synchronously on a dedicated thread so the main async event
|
/// Why: runs synchronously on a dedicated thread so the main async event
|
||||||
/// loop is not blocked. Tool gating prevents restricted, risky, or
|
/// 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}");
|
anyhow::bail!("subagent aborted by parent at step {step}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the structured tool-calling API so the LLM can request tools with
|
// Use streaming API so the abort flag is checked per SSE event,
|
||||||
// proper arguments, exactly like the main agent does.
|
// making the subagent responsive to cancellation even during an
|
||||||
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) {
|
// 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,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
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 {
|
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||||
step,
|
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}");
|
anyhow::bail!("subagent call failed at step {step}: {e}");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -356,67 +391,128 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
// Push the assistant message with tool_calls into the conversation
|
// Push the assistant message with tool_calls into the conversation
|
||||||
messages.push(response);
|
messages.push(response);
|
||||||
|
|
||||||
for tool_call in &tool_calls {
|
let mut results_vec = Vec::new();
|
||||||
// Check abort flag before each tool execution
|
std::thread::scope(|s| {
|
||||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
let mut handles = Vec::new();
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
let tools_ref = &tools;
|
||||||
step,
|
let tool_ctx_ref = &tool_ctx;
|
||||||
error: "subagent aborted by parent during tool execution".to_string(),
|
for tool_call in &tool_calls {
|
||||||
});
|
let handle = s.spawn(move || {
|
||||||
anyhow::bail!("subagent aborted by parent during tool call at step {step}");
|
// 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) => {
|
||||||
|
let is_edit = tool_name == "write" || tool_name == "edit";
|
||||||
|
if is_edit && !tool_call.id.is_empty() {
|
||||||
|
if let Ok(conn) = crate::model::msglog::open_or_create(&ctx.session_dir) {
|
||||||
|
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if let Ok(abs_path) = crate::tool::resolve_path(&tool_ctx_ref.workspaces, path) {
|
||||||
|
if let Ok(bytes) = std::fs::read(&abs_path) {
|
||||||
|
let session_id = ctx.session_dir
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let _ = crate::model::msglog::store_blob(
|
||||||
|
&conn, session_id, &tool_call.id, &bytes, None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_res = tool.run(tool_ctx_ref, &args);
|
||||||
|
|
||||||
|
if is_edit && run_res.is_ok() {
|
||||||
|
let reason = args
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unnamed");
|
||||||
|
let path = args
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let content_sha256 = {
|
||||||
|
let content = args.get("content").or_else(|| args.get("new"));
|
||||||
|
use sha2::Digest;
|
||||||
|
let hash = sha2::Sha256::digest(
|
||||||
|
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
||||||
|
);
|
||||||
|
hex::encode(hash)
|
||||||
|
};
|
||||||
|
let bytes_delta = if tool_name == "write" {
|
||||||
|
args.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map_or(0, |s| s.len() as i64)
|
||||||
|
} else {
|
||||||
|
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
(new.len() as i64 - old.len() as i64).abs()
|
||||||
|
};
|
||||||
|
let session_id = ctx.session_dir
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let entry = crate::model::editlog::EditLogEntry {
|
||||||
|
ts: chrono::Utc::now().timestamp_millis(),
|
||||||
|
tool: tool_name.clone(),
|
||||||
|
path: path.to_string(),
|
||||||
|
reason: reason.to_string(),
|
||||||
|
content_sha256,
|
||||||
|
bytes_delta,
|
||||||
|
origin: tool_ctx_ref.origin.tag(),
|
||||||
|
session_id,
|
||||||
|
};
|
||||||
|
let mut el = crate::model::editlog::EditLog::new(&ctx.session_dir);
|
||||||
|
el.append(entry).ok();
|
||||||
|
}
|
||||||
|
run_res
|
||||||
|
}
|
||||||
|
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 tool_name = &tool_call.function.name;
|
||||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
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 {
|
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||||
tool: tool_name.clone(),
|
tool: tool_name.clone(),
|
||||||
args: args.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 {
|
match result {
|
||||||
Ok(output_text) => {
|
Ok(output_text) => {
|
||||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
||||||
@@ -426,6 +522,14 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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}");
|
let msg = format!("tool '{tool_name}' failed: {e}");
|
||||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ impl AgentDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builder method: limit this agent to at most `steps` LLM calls.
|
/// Builder method: limit this agent to at most `steps` LLM calls.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||||
self.max_steps = Some(steps);
|
self.max_steps = Some(steps);
|
||||||
self
|
self
|
||||||
|
|||||||
+269
-127
@@ -1,6 +1,6 @@
|
|||||||
//! Company-style workflow orchestrator: runs the complete division pipeline
|
//! Company-style workflow orchestrator: runs the complete division pipeline
|
||||||
//! (Strategy → Engineering → Quality → Security → Documentation) with
|
//! (Strategy → Engineering → [Quality || Security || Documentation] in parallel)
|
||||||
//! findings flowing between stages, then returns a consolidated executive
|
//! with findings flowing between stages, then returns a consolidated executive
|
||||||
//! summary to the CEO (main agent).
|
//! summary to the CEO (main agent).
|
||||||
//!
|
//!
|
||||||
//! Flow:
|
//! Flow:
|
||||||
@@ -9,146 +9,137 @@
|
|||||||
//! │ delegates to run_company_pipeline(request)
|
//! │ delegates to run_company_pipeline(request)
|
||||||
//! ▼
|
//! ▼
|
||||||
//! ┌──────────────────────────────────────────────────┐
|
//! ┌──────────────────────────────────────────────────┐
|
||||||
//! │ Strategy Division — plan + mermaid diagrams │
|
//! │ Strategy Division — plan + mermaid diagrams │ (runs sequentially first)
|
||||||
//! │ Engineering Division — implement per plan │
|
//! └─────────────────────────┬────────────────────────┘
|
||||||
//! │ Quality Division — review + write tests │
|
//! ▼
|
||||||
//! │ Security Division — vulnerability audit │
|
//! ┌──────────────────────────────────────────────────┐
|
||||||
//! │ Documentation Div — update docs │
|
//! │ Engineering Division — implement per plan │ (runs sequentially second)
|
||||||
//! └──────────────────────────────────────────────────┘
|
//! └─────────────────────────┬────────────────────────┘
|
||||||
//! │ returns consolidated summary
|
//! ▼
|
||||||
//! ▼
|
//! ┌────────────┼────────────┐
|
||||||
//! CEO Main Agent delivers to user
|
//! ▼ ▼ ▼
|
||||||
|
//! ┌───────────┐┌───────────┐┌───────────┐
|
||||||
|
//! │ Quality ││ Security ││ Docs │ (run concurrently in parallel)
|
||||||
|
//! └───────────┘└───────────┘└───────────┘
|
||||||
|
//! │ │ │
|
||||||
|
//! └────────────┼────────────┘
|
||||||
|
//! ▼
|
||||||
|
//! CEO Main Agent delivers consolidated summary to user
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
||||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
|
||||||
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||||
|
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||||
use crate::app::subagent::division;
|
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.
|
/// Run the full company-style pipeline for a given user request.
|
||||||
///
|
///
|
||||||
/// This orchestrates all five divisions in sequence:
|
/// This orchestrates all five divisions, running Strategy and Engineering
|
||||||
/// 1. **Strategy** — create plan with diagrams
|
/// sequentially, followed by Quality, Security, and Documentation in parallel.
|
||||||
/// 2. **Engineering** — implement code
|
|
||||||
/// 3. **Quality** — review + write tests
|
|
||||||
/// 4. **Security** — audit
|
|
||||||
/// 5. **Documentation** — update docs
|
|
||||||
///
|
///
|
||||||
/// Each division receives findings from all previous divisions, enabling
|
/// Each division receives findings from all previous divisions, enabling
|
||||||
/// context to flow through the pipeline.
|
/// context to flow through the pipeline.
|
||||||
///
|
///
|
||||||
/// Returns a consolidated executive summary string.
|
/// Returns a consolidated executive summary string.
|
||||||
|
#[allow(clippy::ref_option)]
|
||||||
pub fn run_company_pipeline(
|
pub fn run_company_pipeline(
|
||||||
user_request: &str,
|
user_request: &str,
|
||||||
session_dir: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
workspaces: &[std::path::PathBuf],
|
workspaces: &[std::path::PathBuf],
|
||||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
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> {
|
) -> anyhow::Result<String> {
|
||||||
let divisions = division::all_divisions();
|
let divisions = division::all_divisions();
|
||||||
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(divisions.len());
|
|
||||||
|
|
||||||
for div in &divisions {
|
let strategy_specs = custom_specialists.get("Strategy")
|
||||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
|
||||||
// Prepend [Division Name] so the first 40 chars of the prompt
|
let engineering_specs = custom_specialists.get("Engineering")
|
||||||
// become the agent_name in spawn_single_agent, making the TUI
|
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
|
||||||
// panel show division names instead of UUID fragments.
|
let quality_specs = custom_specialists.get("Quality")
|
||||||
let prompt = format!(
|
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
|
||||||
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
|
let security_specs = custom_specialists.get("Security")
|
||||||
div.name,
|
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Security"))?;
|
||||||
div_prompt,
|
let documentation_specs = custom_specialists.get("Documentation")
|
||||||
user_request,
|
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Documentation"))?;
|
||||||
);
|
|
||||||
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
|
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 {
|
let wf = WorkflowScript {
|
||||||
name: "company-pipeline".to_string(),
|
name: "company-pipeline".to_string(),
|
||||||
description: "Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation".to_string(),
|
description: "Company Pipeline: Strategy → Engineering → (Quality || Security || Documentation)".to_string(),
|
||||||
script: ScriptPrimitive::Pipeline(pipeline_scripts),
|
script: pipeline_primitive,
|
||||||
options: ScriptOptions {
|
options: ScriptOptions {
|
||||||
max_concurrency: 1, // sequential by design
|
max_concurrency: 10,
|
||||||
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,
|
|
||||||
continue_on_error: true,
|
continue_on_error: true,
|
||||||
timeout_ms: None,
|
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 findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(
|
||||||
&wf.script, &args, 1, true,
|
&wf.script,
|
||||||
live.as_ref(), session_dir, workspaces, &findings, None,
|
&args,
|
||||||
|
wf.options.max_concurrency,
|
||||||
|
true,
|
||||||
|
abort_flag,
|
||||||
|
live.as_ref(),
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
&findings,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let all_findings = findings.lock()
|
let all_findings = findings.lock()
|
||||||
.map(|f| f.clone())
|
.map(|f| f.clone())
|
||||||
.unwrap_or_default();
|
.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.
|
/// Build a compressed executive summary from pipeline results.
|
||||||
///
|
///
|
||||||
/// Keeps output brief to save context window space — just division verdicts
|
/// Flow: print user request header → for each division, fetch its specialist verdicts
|
||||||
/// and key findings, not full outputs. Full results are accessible to the
|
/// → join with pipes → append findings count.
|
||||||
/// CEO via the notes/findings that were archived during execution.
|
///
|
||||||
|
/// 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(
|
fn build_executive_summary(
|
||||||
request: &str,
|
request: &str,
|
||||||
results: &[String],
|
results: &[String],
|
||||||
findings: &[String],
|
findings: &[String],
|
||||||
divisions: &[division::Division],
|
divisions: &[division::Division],
|
||||||
|
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut summary = String::new();
|
let mut summary = String::new();
|
||||||
writeln!(summary, "Pipeline for: {request}").unwrap();
|
writeln!(summary, "Pipeline for: {request}").unwrap();
|
||||||
|
|
||||||
for (i, div) in divisions.iter().enumerate() {
|
let mut start_index = 0;
|
||||||
let verdict = results.get(i).map_or_else(|| "—".to_string(), |r| {
|
for div in divisions {
|
||||||
r.lines().next().unwrap_or(r)
|
let count = custom_specialists.get(div.name)
|
||||||
.chars().take(100).collect::<String>()
|
.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();
|
writeln!(summary, " {}: {}", div.name, verdict).unwrap();
|
||||||
|
start_index += count;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !findings.is_empty() {
|
if !findings.is_empty() {
|
||||||
@@ -228,6 +321,7 @@ fn build_executive_summary(
|
|||||||
/// - Negative keywords (simple/trivial/typo/quick) skip the pipeline.
|
/// - Negative keywords (simple/trivial/typo/quick) skip the pipeline.
|
||||||
/// - Positive keywords (refactor/api/implement/architecture) trigger it.
|
/// - Positive keywords (refactor/api/implement/architecture) trigger it.
|
||||||
/// - Multi-line or multi-sentence requests are more likely complex.
|
/// - Multi-line or multi-sentence requests are more likely complex.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn is_complex_request(request: &str) -> bool {
|
pub fn is_complex_request(request: &str) -> bool {
|
||||||
let trimmed = request.trim();
|
let trimmed = request.trim();
|
||||||
// Very short requests are never complex
|
// 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))
|
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.
|
//! leaks between concurrent workflow runs.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
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.
|
/// a stuck stage from blocking the entire pipeline forever.
|
||||||
///
|
///
|
||||||
/// Return: the agent's text output, or an error on failure.
|
/// 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(
|
fn spawn_single_agent(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
agent_name: &str,
|
agent_name: &str,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
findings_snapshot: &[String],
|
findings_snapshot: &[String],
|
||||||
findings: &Arc<Mutex<Vec<String>>>,
|
findings: &Arc<Mutex<Vec<String>>>,
|
||||||
|
abort_flag: &Option<Arc<AtomicBool>>,
|
||||||
live: Option<&LiveStateFn>,
|
live: Option<&LiveStateFn>,
|
||||||
session_dir: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
workspaces: &[std::path::PathBuf],
|
workspaces: &[std::path::PathBuf],
|
||||||
@@ -133,8 +134,35 @@ fn spawn_single_agent(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string())
|
let mut role = "coder".to_string();
|
||||||
.with_max_steps(50);
|
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);
|
let mut ctx = build_subagent_context(&def);
|
||||||
ctx.session_dir = session_dir.to_path_buf();
|
ctx.session_dir = session_dir.to_path_buf();
|
||||||
ctx.workspaces = workspaces.to_vec();
|
ctx.workspaces = workspaces.to_vec();
|
||||||
@@ -157,9 +185,7 @@ fn spawn_single_agent(
|
|||||||
// Link the shared findings Arc so note_finding calls within this
|
// Link the shared findings Arc so note_finding calls within this
|
||||||
// subagent write into the same vec visible to sibling agents.
|
// subagent write into the same vec visible to sibling agents.
|
||||||
ctx.workflow_findings = Some(findings.clone());
|
ctx.workflow_findings = Some(findings.clone());
|
||||||
// Abort flag stays None by default — the parent can set it to abort
|
ctx.abort_flag.clone_from(abort_flag);
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Create an mpsc channel and drain events in a background thread.
|
// Create an mpsc channel and drain events in a background thread.
|
||||||
// The drain thread also pushes intra-division progress updates to the
|
// 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
|
// Check abort before even starting the subagent.
|
||||||
// waiting with a deadline. If the deadline expires, the thread is
|
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||||
// abandoned (Rust threads cannot be forcibly killed, but we proceed
|
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
||||||
// without waiting for it — the drain thread will drop when tx is
|
}
|
||||||
// dropped on thread exit).
|
|
||||||
let result = if let Some(timeout) = timeout_ms {
|
// Run subagent on a separate thread so the abort flag can be polled.
|
||||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
// If abort is requested while the subagent is running, we abandon the
|
||||||
let timeout_ctx = ctx;
|
// thread (Rust threads cannot be forcibly killed) and return early.
|
||||||
let timeout_tx = tx;
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||||
std::thread::spawn(move || {
|
let bg_ctx = ctx;
|
||||||
let _ = done_tx.send(run_subagent(&timeout_ctx, &timeout_tx));
|
let bg_tx = tx;
|
||||||
});
|
let bg_name = agent_name.to_string();
|
||||||
match done_rx.recv_timeout(Duration::from_millis(timeout)) {
|
let bg_abort = abort_flag.clone();
|
||||||
Ok(r) => r,
|
std::thread::spawn(move || {
|
||||||
Err(_) => Err(anyhow::anyhow!(
|
let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
|
||||||
"subagent '{agent_name}' timed out after {timeout}ms",
|
});
|
||||||
)),
|
|
||||||
|
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 {
|
elapsed += poll_interval;
|
||||||
run_subagent(&ctx, &tx)
|
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();
|
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
|
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||||
/// the order they were submitted.
|
/// the order they were submitted.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[allow(clippy::ref_option, clippy::too_many_lines)]
|
||||||
pub fn execute_primitive(
|
pub fn execute_primitive(
|
||||||
primitive: &ScriptPrimitive,
|
primitive: &ScriptPrimitive,
|
||||||
args: &HashMap<String, String>,
|
args: &HashMap<String, String>,
|
||||||
concurrency_cap: usize,
|
concurrency_cap: usize,
|
||||||
continue_on_error: bool,
|
continue_on_error: bool,
|
||||||
|
abort_flag: &Option<Arc<AtomicBool>>,
|
||||||
live: Option<&LiveStateFn>,
|
live: Option<&LiveStateFn>,
|
||||||
session_dir: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
workspaces: &[std::path::PathBuf],
|
workspaces: &[std::path::PathBuf],
|
||||||
@@ -313,11 +369,25 @@ pub fn execute_primitive(
|
|||||||
) -> anyhow::Result<Vec<String>> {
|
) -> anyhow::Result<Vec<String>> {
|
||||||
match primitive {
|
match primitive {
|
||||||
ScriptPrimitive::Agent(prompt) => {
|
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();
|
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_id = uuid::Uuid::new_v4().to_string();
|
||||||
let agent_name = resolved.chars().take(40).collect::<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]),
|
Ok(text) => Ok(vec![text]),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if continue_on_error {
|
||||||
@@ -348,6 +418,7 @@ pub fn execute_primitive(
|
|||||||
let sem = Arc::clone(&semaphore);
|
let sem = Arc::clone(&semaphore);
|
||||||
let results = Arc::clone(&results);
|
let results = Arc::clone(&results);
|
||||||
let cap = concurrency_cap;
|
let cap = concurrency_cap;
|
||||||
|
let abort = abort_flag.clone();
|
||||||
let live_clone = live.cloned();
|
let live_clone = live.cloned();
|
||||||
let session_dir = session_dir.to_path_buf();
|
let session_dir = session_dir.to_path_buf();
|
||||||
let workspaces = workspaces.to_vec();
|
let workspaces = workspaces.to_vec();
|
||||||
@@ -358,6 +429,7 @@ pub fn execute_primitive(
|
|||||||
let _permit = sem.acquire();
|
let _permit = sem.acquire();
|
||||||
let result = execute_primitive(
|
let result = execute_primitive(
|
||||||
&script, &args, cap, continue_on_error,
|
&script, &args, cap, continue_on_error,
|
||||||
|
&abort,
|
||||||
live_clone.as_ref(),
|
live_clone.as_ref(),
|
||||||
&session_dir,
|
&session_dir,
|
||||||
&workspaces,
|
&workspaces,
|
||||||
@@ -390,13 +462,26 @@ pub fn execute_primitive(
|
|||||||
ScriptPrimitive::Pipeline(scripts) => {
|
ScriptPrimitive::Pipeline(scripts) => {
|
||||||
// Sequential: each stage runs only after the previous completes.
|
// 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
|
// Why: parallel execution defeats the purpose of a pipeline whose
|
||||||
// stages are supposed to build on each other's output. Findings
|
// stages are supposed to build on each other's output. Findings
|
||||||
// written by stage N are visible to stage N+1 through the shared
|
// written by stage N are visible to stage N+1 through the shared
|
||||||
// `findings` Arc (same isolation scope as parent).
|
// `findings` Arc (same isolation scope as parent).
|
||||||
let mut all = Vec::new();
|
let mut all = Vec::new();
|
||||||
for (idx, script) in scripts.iter().enumerate() {
|
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),
|
Ok(outputs) => all.extend(outputs),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if continue_on_error {
|
||||||
@@ -411,7 +496,7 @@ pub fn execute_primitive(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ScriptPrimitive::Phase { name: _name, script } => {
|
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,
|
session_dir: &std::path::Path,
|
||||||
workspaces: &[std::path::PathBuf],
|
workspaces: &[std::path::PathBuf],
|
||||||
) -> anyhow::Result<String> {
|
) -> 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
|
/// 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.
|
/// `spawn_agents` invocations remain fully isolated.
|
||||||
///
|
///
|
||||||
/// Return: a human-readable summary string.
|
/// Return: a human-readable summary string.
|
||||||
|
#[allow(clippy::ref_option)]
|
||||||
pub fn run_workflow_tracked(
|
pub fn run_workflow_tracked(
|
||||||
script: &WorkflowScript,
|
script: &WorkflowScript,
|
||||||
args: &HashMap<String, String>,
|
args: &HashMap<String, String>,
|
||||||
|
abort_flag: &Option<Arc<AtomicBool>>,
|
||||||
live: Option<&LiveStateFn>,
|
live: Option<&LiveStateFn>,
|
||||||
session_dir: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
workspaces: &[std::path::PathBuf],
|
workspaces: &[std::path::PathBuf],
|
||||||
@@ -457,7 +544,7 @@ pub fn run_workflow_tracked(
|
|||||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(
|
||||||
&script.script, args, concurrency_cap,
|
&script.script, args, concurrency_cap,
|
||||||
script.options.continue_on_error, live,
|
script.options.continue_on_error, abort_flag, live,
|
||||||
session_dir, workspaces, &findings,
|
session_dir, workspaces, &findings,
|
||||||
script.options.timeout_ms,
|
script.options.timeout_ms,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ pub enum Command {
|
|||||||
WorkflowRun {
|
WorkflowRun {
|
||||||
script: String,
|
script: String,
|
||||||
},
|
},
|
||||||
/// /pipeline full|quick|skip
|
|
||||||
Pipeline {
|
|
||||||
mode: String,
|
|
||||||
},
|
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,15 +75,6 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
"/workflow" => Command::WorkflowRun {
|
"/workflow" => Command::WorkflowRun {
|
||||||
script: arg1.to_string(),
|
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()),
|
_ => 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> {
|
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
|
||||||
Ok(serde_json::from_slice(data)?)
|
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 anyhow::Result;
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
@@ -118,7 +117,7 @@ fn run_single_process() -> Result<()> {
|
|||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
@@ -126,7 +125,7 @@ fn run_single_process() -> Result<()> {
|
|||||||
let run_result = run_loop(&mut state, &mut terminal);
|
let run_result = run_loop(&mut state, &mut terminal);
|
||||||
|
|
||||||
let mut restore_stdout = io::stdout();
|
let mut restore_stdout = io::stdout();
|
||||||
let _ = execute!(restore_stdout, LeaveAlternateScreen, DisableMouseCapture);
|
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
|
|
||||||
if let Err(e) = run_result {
|
if let Err(e) = run_result {
|
||||||
@@ -468,7 +467,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
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 _ = disable_raw_mode();
|
||||||
|
|
||||||
let _ = client_state.settings.save();
|
let _ = client_state.settings.save();
|
||||||
@@ -580,7 +579,6 @@ fn run_loop(
|
|||||||
if let Err(ref _e) = result {
|
if let Err(ref _e) = result {
|
||||||
let _ = terminal.clear();
|
let _ = terminal.clear();
|
||||||
|
|
||||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ mod tests {
|
|||||||
log.append(EditLogEntry {
|
log.append(EditLogEntry {
|
||||||
ts: i,
|
ts: i,
|
||||||
tool: "edit".to_string(),
|
tool: "edit".to_string(),
|
||||||
path: format!("file{}.txt", i),
|
path: format!("file{i}.txt"),
|
||||||
reason: format!("reason {}", i),
|
reason: format!("reason {i}"),
|
||||||
content_sha256: "hash".to_string(),
|
content_sha256: "hash".to_string(),
|
||||||
bytes_delta: 10 + i,
|
bytes_delta: 10 + i,
|
||||||
origin: "main".to_string(),
|
origin: "main".to_string(),
|
||||||
|
|||||||
+1
-1
@@ -375,7 +375,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
mem.write(&dir).unwrap();
|
mem.write(&dir).unwrap();
|
||||||
let names = Memory::list(&dir);
|
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);
|
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.
|
/// 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).
|
/// workflow, utility).
|
||||||
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||||
vec![
|
vec![
|
||||||
@@ -162,6 +162,7 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
|||||||
Box::new(super::tool::plan::PlanReady),
|
Box::new(super::tool::plan::PlanReady),
|
||||||
Box::new(super::tool::workflow::WorkflowRun),
|
Box::new(super::tool::workflow::WorkflowRun),
|
||||||
Box::new(super::tool::workflow::NoteFinding),
|
Box::new(super::tool::workflow::NoteFinding),
|
||||||
|
Box::new(super::tool::workflow::ReadFindings),
|
||||||
Box::new(super::tool::workflow::CompanyPipeline),
|
Box::new(super::tool::workflow::CompanyPipeline),
|
||||||
Box::new(super::tool::spawn::SpawnAgents),
|
Box::new(super::tool::spawn::SpawnAgents),
|
||||||
Box::new(super::tool::spawn::SpawnPipeline),
|
Box::new(super::tool::spawn::SpawnPipeline),
|
||||||
|
|||||||
@@ -106,11 +106,13 @@ impl Tool for SpawnAgents {
|
|||||||
// by this tool call are isolated from any other concurrent
|
// by this tool call are isolated from any other concurrent
|
||||||
// spawn_agents or workflow_run invocations.
|
// spawn_agents or workflow_run invocations.
|
||||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
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(
|
let results = crate::app::workflow::engine::execute_primitive(
|
||||||
&wf.script,
|
&wf.script,
|
||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
max_concurrency,
|
max_concurrency,
|
||||||
true,
|
true,
|
||||||
|
&no_abort,
|
||||||
live.as_ref(),
|
live.as_ref(),
|
||||||
&ctx.session_dir,
|
&ctx.session_dir,
|
||||||
&ctx.workspaces,
|
&ctx.workspaces,
|
||||||
@@ -195,11 +197,13 @@ impl Tool for SpawnPipeline {
|
|||||||
// Per-invocation findings scope isolates this pipeline from any
|
// Per-invocation findings scope isolates this pipeline from any
|
||||||
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
|
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
|
||||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
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(
|
let results = crate::app::workflow::engine::execute_primitive(
|
||||||
&wf.script,
|
&wf.script,
|
||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
1,
|
1,
|
||||||
false,
|
false,
|
||||||
|
&no_abort,
|
||||||
live.as_ref(),
|
live.as_ref(),
|
||||||
&ctx.session_dir,
|
&ctx.session_dir,
|
||||||
&ctx.workspaces,
|
&ctx.workspaces,
|
||||||
|
|||||||
+76
-1
@@ -171,9 +171,22 @@ impl Tool for CompanyPipeline {
|
|||||||
"enum": ["full", "quick"],
|
"enum": ["full", "quick"],
|
||||||
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks",
|
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks",
|
||||||
"default": "full"
|
"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())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("full");
|
.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 {
|
match mode {
|
||||||
"quick" => {
|
"quick" => {
|
||||||
crate::app::workflow::company::run_company_pipeline_quick(
|
crate::app::workflow::company::run_company_pipeline_quick(
|
||||||
@@ -193,6 +224,8 @@ impl Tool for CompanyPipeline {
|
|||||||
&ctx.session_dir,
|
&ctx.session_dir,
|
||||||
&ctx.workspaces,
|
&ctx.workspaces,
|
||||||
ctx.turn_events.as_ref(),
|
ctx.turn_events.as_ref(),
|
||||||
|
&no_abort,
|
||||||
|
&custom_specialists,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -201,8 +234,50 @@ impl Tool for CompanyPipeline {
|
|||||||
&ctx.session_dir,
|
&ctx.session_dir,
|
||||||
&ctx.workspaces,
|
&ctx.workspaces,
|
||||||
ctx.turn_events.as_ref(),
|
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