feat: introduce workflow management tools and commands
- Added new workflow commands: `/workflow` to open the workflow panel and `/workflow run <prompt>` to execute workflows. - Implemented `spawn_agents` and `spawn_pipeline` tools for parallel and sequential task execution, respectively. - Enhanced workflow engine to handle real-time agent status updates and display in the UI. - Updated workflow panel to show agent statuses, findings count, and session counters. - Refactored existing code to integrate new workflow functionalities and improve overall structure.
This commit is contained in:
+22
-13
@@ -5,19 +5,28 @@ Core principles:
|
|||||||
2. Deliver production-ready code — ensure absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or unfinished logic). Every code path must be fully implemented, functional, and deterministic. No dead code or redundant structures are allowed.
|
2. Deliver production-ready code — ensure absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or unfinished logic). Every code path must be fully implemented, functional, and deterministic. No dead code or redundant structures are allowed.
|
||||||
3. NEVER ignore pre-existing errors, warnings, or technical debt. If you encounter any existing issue (compiler warnings, lint errors, logic bugs, edge cases not handled), fix it immediately — do not leave it for later. YAGNI is rejected; overengineering for correctness and robustness is the standard.
|
3. NEVER ignore pre-existing errors, warnings, or technical debt. If you encounter any existing issue (compiler warnings, lint errors, logic bugs, edge cases not handled), fix it immediately — do not leave it for later. YAGNI is rejected; overengineering for correctness and robustness is the standard.
|
||||||
4. Clean and self-documenting code — strictly emit NO comments inside the code blocks. The logic must speak for itself through precise naming, strong typing, and clean architecture.
|
4. Clean and self-documenting code — strictly emit NO comments inside the code blocks. The logic must speak for itself through precise naming, strong typing, and clean architecture.
|
||||||
4. Use the tools available to explore, understand, and modify the codebase.
|
5. Use the tools available to explore, understand, and modify the codebase.
|
||||||
5. For simple tasks, handle them directly with read/grep/write/edit.
|
6. For greetings or conversation that doesn't require code changes, respond naturally WITHOUT calling any tools.
|
||||||
6. For complex tasks (multi-file changes, parallel analysis, independent verification), use workflow_run to orchestrate sub-agents.
|
7. After making changes, verify they work by running builds or tests.
|
||||||
7. Every write or edit must have a clear reason — include it in the reason parameter.
|
|
||||||
8. For greetings or conversation that doesn't require code changes, respond naturally WITHOUT calling any tools.
|
|
||||||
9. After making changes, verify they work by running builds or tests.
|
|
||||||
|
|
||||||
14. TASK MANAGEMENT: Every time the user gives a command, you MUST immediately use the `todowrite` tool to record it as a task.
|
PARALLEL SUBAGENT STRATEGY — use this automatically, without being asked:
|
||||||
15. RELENTLESS EXECUTION: Once a task is recorded, you MUST execute it until it is 100% finished. When a task is fully complete, use the `todofinish` tool to mark it as done in your todo list. Do not stop calling tools and do not finish your turn prematurely. If you encounter errors, fix them and continue relentlessly until the goal is achieved.
|
- When a task has 2 or more INDEPENDENT parts (e.g. refactor file A and file B, analyse multiple modules, write multiple independent components), ALWAYS call spawn_agents with one prompt per subtask. Do NOT do them one by one inline.
|
||||||
16. LSP INTEGRATION: Language Server Protocol servers for Rust, TypeScript, Go, and Java
|
- When a task has sequential dependent stages (e.g. research → plan → implement → verify), call spawn_pipeline with one prompt per stage.
|
||||||
are auto-provisioned and auto-connected on startup. After writing or editing code, use
|
- Examples of when to use spawn_agents automatically:
|
||||||
lsp_diagnostics to check for errors. Use lsp_hover for type information, lsp_definition
|
* "Fix all lint warnings" → spawn one agent per file/module with warnings
|
||||||
to navigate to symbol definitions, and lsp_references to find all usages. Use lsp_connect
|
* "Add tests for these 3 functions" → spawn 3 agents in parallel
|
||||||
to add servers for other languages.
|
* "Refactor the auth and payment modules" → spawn 2 agents in parallel
|
||||||
|
* "Analyse the codebase for security issues" → spawn agents per subsystem
|
||||||
|
- Examples of when NOT to use spawn_agents (do inline instead):
|
||||||
|
* Single-file edits, simple bug fixes, read/grep tasks
|
||||||
|
* Tasks where context from step 1 is needed for step 2 (use spawn_pipeline)
|
||||||
|
|
||||||
|
TASK MANAGEMENT:
|
||||||
|
- Every time the user gives a command, you MUST immediately use the `todowrite` tool to record it as a task.
|
||||||
|
- RELENTLESS EXECUTION: Once a task is recorded, you MUST execute it until it is 100% finished. When a task is fully complete, use the `todofinish` tool to mark it as done. Do not stop calling tools and do not finish your turn prematurely. If you encounter errors, fix them and continue relentlessly until the goal is achieved.
|
||||||
|
|
||||||
|
LSP INTEGRATION: Language Server Protocol servers for Rust, TypeScript, Go, and Java are auto-provisioned and auto-connected on startup. After writing or editing code, use lsp_diagnostics to check for errors. Use lsp_hover for type information, lsp_definition to navigate to symbol definitions, and lsp_references to find all usages.
|
||||||
|
|
||||||
|
Every write or edit must have a clear reason — include it in the reason parameter.
|
||||||
|
|
||||||
Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task.
|
Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task.
|
||||||
@@ -37,10 +37,17 @@ Memory & Planning:
|
|||||||
- todowrite(task) — Append a task to the session todo list.
|
- todowrite(task) — Append a task to the session todo list.
|
||||||
- todofinish(task_index?) — Mark a task (or all if omitted) as finished in todo.md.
|
- todofinish(task_index?) — Mark a task (or all if omitted) as finished in todo.md.
|
||||||
|
|
||||||
Workflow:
|
Workflow (USE THESE AUTOMATICALLY for multi-part tasks — no user prompt needed):
|
||||||
- workflow_run(script, args) — Fan out work to sub-agents. Use for complex
|
- spawn_agents(agents, max_concurrency?) — Run a list of prompts as PARALLEL subagents.
|
||||||
multi-step tasks needing parallel analysis or verification. Pass inline
|
Each agent is fully autonomous with all tools. Returns combined results.
|
||||||
scripts with agent(), parallel(), and pipeline() primitives.
|
USE THIS when tasks are independent of each other.
|
||||||
|
Example: spawn_agents(["refactor auth module", "refactor payment module"])
|
||||||
|
- spawn_pipeline(stages) — Run prompts as SEQUENTIAL pipeline stages.
|
||||||
|
Each stage can call note_finding() to pass data to later stages.
|
||||||
|
USE THIS when stage N needs output from stage N-1.
|
||||||
|
Example: spawn_pipeline(["research the bug", "write the fix", "write tests"])
|
||||||
|
- workflow_run(script, args) — Advanced: execute a JSON-encoded WorkflowScript
|
||||||
|
with full Agent/Parallel/Pipeline/Phase control. Prefer spawn_agents/spawn_pipeline.
|
||||||
- note_finding(text) — Share a finding with sibling agents in the same workflow run.
|
- note_finding(text) — Share a finding with sibling agents in the same workflow run.
|
||||||
|
|
||||||
Language Server Protocol (LSP) tools:
|
Language Server Protocol (LSP) tools:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod editor;
|
|||||||
pub mod effort;
|
pub mod effort;
|
||||||
pub mod key_input;
|
pub mod key_input;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
pub mod quit_confirm;
|
pub mod quit_confirm;
|
||||||
pub mod rewind;
|
pub mod rewind;
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ pub enum Action {
|
|||||||
ModelList,
|
ModelList,
|
||||||
AbortTurn,
|
AbortTurn,
|
||||||
Compact,
|
Compact,
|
||||||
|
RunWorkflow {
|
||||||
|
script: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an `Action` to the application state.
|
/// Apply an `Action` to the application state.
|
||||||
@@ -106,8 +109,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
Action::SwitchMode(mode) => {
|
Action::SwitchMode(mode) => {
|
||||||
state.misc.overlay = match mode {
|
state.misc.overlay = match mode {
|
||||||
ModeKind::Chat
|
ModeKind::Chat
|
||||||
| ModeKind::Bash
|
| ModeKind::Bash => Overlay::None,
|
||||||
| ModeKind::Workflow => Overlay::None,
|
ModeKind::Workflow => Overlay::Workflow,
|
||||||
ModeKind::Help => Overlay::Help,
|
ModeKind::Help => Overlay::Help,
|
||||||
ModeKind::Settings => Overlay::Settings,
|
ModeKind::Settings => Overlay::Settings,
|
||||||
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
||||||
@@ -434,6 +437,30 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
} else if kind == "connectivity" {
|
} else if kind == "connectivity" {
|
||||||
state.misc.api_connected = message == "connected";
|
state.misc.api_connected = message == "connected";
|
||||||
|
} else if kind == "workflow_done" {
|
||||||
|
state.push_toast(Toast {
|
||||||
|
kind: ToastKind::Success,
|
||||||
|
message: message.clone(),
|
||||||
|
created_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
lifetime_ms: 10000,
|
||||||
|
});
|
||||||
|
state.push_transcript(ChatMessageDisplay::new(
|
||||||
|
crate::dto::chat::message::Role::System,
|
||||||
|
format!("✓ {}", message),
|
||||||
|
));
|
||||||
|
state.dirty = true;
|
||||||
|
} else if kind == "workflow_error" {
|
||||||
|
state.push_toast(Toast {
|
||||||
|
kind: ToastKind::Error,
|
||||||
|
message: message.clone(),
|
||||||
|
created_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
lifetime_ms: 12000,
|
||||||
|
});
|
||||||
|
state.push_transcript(ChatMessageDisplay::new(
|
||||||
|
crate::dto::chat::message::Role::System,
|
||||||
|
format!("✗ {}", message),
|
||||||
|
));
|
||||||
|
state.dirty = true;
|
||||||
} else {
|
} else {
|
||||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||||
}
|
}
|
||||||
@@ -492,6 +519,25 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status } => {
|
||||||
|
// Upsert the agent in the workflow engine roster.
|
||||||
|
// Running agents are pushed as new entries; status
|
||||||
|
// updates find the existing entry by id and replace it.
|
||||||
|
use crate::app::workflow::engine::WorkflowAgent;
|
||||||
|
if let Some(existing) = state.workflow_engine.agents
|
||||||
|
.iter_mut()
|
||||||
|
.find(|a| a.id == agent_id)
|
||||||
|
{
|
||||||
|
existing.status = status;
|
||||||
|
} else {
|
||||||
|
state.workflow_engine.agents.push(WorkflowAgent {
|
||||||
|
id: agent_id,
|
||||||
|
name: agent_name,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if turn_finished {
|
if turn_finished {
|
||||||
@@ -542,6 +588,84 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
format!("rejected lesson: {}", name)));
|
format!("rejected lesson: {}", name)));
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
Action::RunWorkflow { script } => {
|
||||||
|
// Open the Workflow overlay so the user can see progress.
|
||||||
|
state.misc.overlay = Overlay::Workflow;
|
||||||
|
state.dirty = true;
|
||||||
|
|
||||||
|
// Reset engine state before starting.
|
||||||
|
state.workflow_engine.agents.clear();
|
||||||
|
state.workflow_engine.findings.clear();
|
||||||
|
|
||||||
|
let turn_events = state.turn_events.clone();
|
||||||
|
let turn_events_live = state.turn_events.clone();
|
||||||
|
|
||||||
|
state.push_toast(Toast::new(
|
||||||
|
ToastKind::Info,
|
||||||
|
format!("Starting workflow: {}…", &script.chars().take(40).collect::<String>()),
|
||||||
|
));
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||||
|
use crate::app::workflow::engine::{LiveStateFn, AgentStatus};
|
||||||
|
|
||||||
|
// Parse the script string:
|
||||||
|
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
|
||||||
|
// "prompt1 -> prompt2" → Pipeline of 2 stages
|
||||||
|
// "prompt" → single Agent
|
||||||
|
let parts_pipe: Vec<&str> = script.split('|').map(|s| s.trim()).collect();
|
||||||
|
let parts_arrow: Vec<&str> = script.split("->").map(|s| s.trim()).collect();
|
||||||
|
|
||||||
|
let primitive = if parts_pipe.len() > 1 {
|
||||||
|
ScriptPrimitive::Parallel(
|
||||||
|
parts_pipe.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
|
||||||
|
)
|
||||||
|
} else if parts_arrow.len() > 1 {
|
||||||
|
ScriptPrimitive::Pipeline(
|
||||||
|
parts_arrow.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ScriptPrimitive::Agent(script.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let wf = WorkflowScript {
|
||||||
|
name: script.chars().take(40).collect(),
|
||||||
|
description: script.clone(),
|
||||||
|
script: primitive,
|
||||||
|
options: ScriptOptions::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a live-state callback that pushes WorkflowAgentUpdate events
|
||||||
|
// into the turn_events queue so the TUI panel updates in real time.
|
||||||
|
let live: LiveStateFn = Arc::new(move |agent_id: String, status: AgentStatus| {
|
||||||
|
let name = agent_id.chars().take(30).collect::<String>();
|
||||||
|
if let Ok(mut q) = turn_events_live.lock() {
|
||||||
|
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
agent_name: name,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let args: HashMap<String, String> = HashMap::new();
|
||||||
|
let result = crate::app::workflow::engine::run_workflow_tracked(&wf, &args, Some(live));
|
||||||
|
|
||||||
|
let (kind, message) = match result {
|
||||||
|
Ok(summary) => ("workflow_done".to_string(), summary),
|
||||||
|
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut q) = turn_events.lock() {
|
||||||
|
q.push_back(crate::app::state::runtime::TurnEvent::SystemNote {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
Command::Compact => {
|
Command::Compact => {
|
||||||
vec![Action::Compact]
|
vec![Action::Compact]
|
||||||
}
|
}
|
||||||
|
Command::WorkflowOpen => {
|
||||||
|
vec![Action::OpenOverlay(Overlay::Workflow)]
|
||||||
|
}
|
||||||
|
Command::WorkflowRun { script } => {
|
||||||
|
vec![Action::RunWorkflow { script }]
|
||||||
|
}
|
||||||
Command::Unknown(cmd) => {
|
Command::Unknown(cmd) => {
|
||||||
vec![Action::SystemNote {
|
vec![Action::SystemNote {
|
||||||
kind: "error".to_string(),
|
kind: "error".to_string(),
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ const COMMANDS: &[&str] = &[
|
|||||||
"/model",
|
"/model",
|
||||||
"/model ls",
|
"/model ls",
|
||||||
"/model add",
|
"/model add",
|
||||||
|
"/workflow",
|
||||||
|
"/workflow run",
|
||||||
|
"/compact",
|
||||||
];
|
];
|
||||||
|
|
||||||
impl InputState {
|
impl InputState {
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ pub enum TurnEvent {
|
|||||||
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
|
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
|
||||||
Error(String),
|
Error(String),
|
||||||
Done,
|
Done,
|
||||||
|
/// Real-time update from a workflow subagent: push the new status
|
||||||
|
/// into `AppStateRest::workflow_engine.agents`.
|
||||||
|
WorkflowAgentUpdate {
|
||||||
|
agent_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
status: crate::app::workflow::engine::AgentStatus,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionRuntime {
|
impl SessionRuntime {
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ pub struct SubagentContext {
|
|||||||
|
|
||||||
/// Build a `SubagentContext` from an `AgentDefinition`.
|
/// Build a `SubagentContext` from an `AgentDefinition`.
|
||||||
///
|
///
|
||||||
/// Flow: copy optional `allowed_tools` from the def -> fall back to the
|
/// Flow: copy optional `allowed_tools` from the def → fall back to the
|
||||||
/// reviewer-allowlist when the def has none and the role is "reviewer" ->
|
/// reviewer-allowlist when the def has none and the role is "reviewer" →
|
||||||
/// fall back to an empty list (i.e. "all tools allowed") for other roles.
|
/// fall back to an empty list (i.e. "all tools allowed") for other roles.
|
||||||
|
/// `max_steps` is read from the definition, defaulting to 25 if absent.
|
||||||
///
|
///
|
||||||
/// Return: a context with empty `system_prompt` and `session_dir`,
|
/// Return: a context with empty `system_prompt` and `session_dir`,
|
||||||
/// `max_steps = 25`, and the resolved allowed-tool list.
|
/// resolved `max_steps`, and the resolved allowed-tool list.
|
||||||
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||||
if def.role == "reviewer" {
|
if def.role == "reviewer" {
|
||||||
@@ -32,10 +33,11 @@ pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let max_steps = def.max_steps.unwrap_or(25);
|
||||||
SubagentContext {
|
SubagentContext {
|
||||||
system_prompt: String::new(),
|
system_prompt: String::new(),
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
max_steps: 25,
|
max_steps,
|
||||||
session_dir: PathBuf::new(),
|
session_dir: PathBuf::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-58
@@ -1,6 +1,14 @@
|
|||||||
//! Workflow engine: interprets `ScriptPrimitive` values (agent, parallel,
|
//! Workflow engine: interprets `ScriptPrimitive` values (agent, parallel,
|
||||||
//! pipeline, phase) by spawning subagents, collecting results, and
|
//! pipeline, phase) by spawning subagents, collecting results, and
|
||||||
//! managing concurrency.
|
//! managing concurrency.
|
||||||
|
//!
|
||||||
|
//! Key design points:
|
||||||
|
//! - `Parallel` branches run concurrently (capped by semaphore) — this is
|
||||||
|
//! the main advantage over single-turn chat.
|
||||||
|
//! - `Pipeline` branches run sequentially so each stage sees findings from
|
||||||
|
//! the previous one.
|
||||||
|
//! - `run_workflow_tracked` accepts a `LiveState` callback that receives
|
||||||
|
//! real-time agent status updates for the TUI panel.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -53,21 +61,48 @@ impl WorkflowEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a single synchronous subagent with the given prompt, passing it
|
/// Shared live state used by `run_workflow_tracked` to push real-time
|
||||||
/// any findings from earlier sibling agents.
|
/// agent status updates into the TUI's `WorkflowEngine`.
|
||||||
///
|
///
|
||||||
/// Flow: build an `AgentDefinition` -> build a `SubagentContext` ->
|
/// The closure receives `(agent_id, new_status)` and should update the
|
||||||
/// inject findings into the system prompt -> call `run_subagent` on a
|
/// corresponding agent in `AppStateRest::workflow_engine`.
|
||||||
/// dedicated mpsc channel.
|
pub type LiveStateFn = Arc<dyn Fn(String, AgentStatus) + Send + Sync>;
|
||||||
|
|
||||||
|
/// Spawn a single synchronous subagent with the given prompt, passing it
|
||||||
|
/// any findings from earlier sibling agents. Updates live state before and
|
||||||
|
/// after to reflect Running → Completed/Failed transitions.
|
||||||
|
///
|
||||||
|
/// Flow: push agent as `Running` → build SubagentContext with prompt +
|
||||||
|
/// findings preamble → call `run_subagent` (draining the event channel into
|
||||||
|
/// a throwaway consumer so events are not blocked) → push `Completed` or
|
||||||
|
/// `Failed`.
|
||||||
///
|
///
|
||||||
/// Return: the agent's text output, or an error on failure.
|
/// Return: the agent's text output, or an error on failure.
|
||||||
fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::Result<String> {
|
fn spawn_single_agent(
|
||||||
|
agent_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
findings_snapshot: Vec<String>,
|
||||||
|
live: Option<&LiveStateFn>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
use crate::app::subagent::context::build_subagent_context;
|
use crate::app::subagent::context::build_subagent_context;
|
||||||
use crate::app::subagent::engine::run_subagent;
|
use crate::app::subagent::engine::run_subagent;
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
|
|
||||||
let def = AgentDefinition::new("workflow-agent".to_string(), "coder".to_string())
|
let started_at = chrono::Utc::now().timestamp_millis();
|
||||||
.with_max_steps(usize::MAX);
|
|
||||||
|
// Notify UI: this agent is now running
|
||||||
|
if let Some(f) = live {
|
||||||
|
f(agent_id.to_string(), AgentStatus {
|
||||||
|
state: AgentState::Running,
|
||||||
|
started_at: Some(started_at),
|
||||||
|
completed_at: None,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string())
|
||||||
|
.with_max_steps(50);
|
||||||
let mut ctx = build_subagent_context(def);
|
let mut ctx = build_subagent_context(def);
|
||||||
|
|
||||||
let findings_section = if findings_snapshot.is_empty() {
|
let findings_section = if findings_snapshot.is_empty() {
|
||||||
@@ -86,8 +121,41 @@ fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::R
|
|||||||
|
|
||||||
ctx.system_prompt = format!("{}{}", prompt, findings_section);
|
ctx.system_prompt = format!("{}{}", prompt, findings_section);
|
||||||
|
|
||||||
let (tx, _rx) = tokio::sync::mpsc::channel(32);
|
// Create an mpsc channel and drain events in a background thread so
|
||||||
run_subagent(ctx, tx)
|
// run_subagent's blocking_send never blocks (previously the _rx was
|
||||||
|
// dropped immediately, which would cause blocking_send to panic/fail
|
||||||
|
// on a closed channel).
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
|
let _drain_thread = std::thread::spawn(move || {
|
||||||
|
// Drain all events; we don't surface them individually to the UI
|
||||||
|
// (the live state callbacks handle coarse-grained status).
|
||||||
|
let mut rx = rx;
|
||||||
|
while rx.blocking_recv().is_some() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = run_subagent(ctx, tx);
|
||||||
|
|
||||||
|
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
|
// Notify UI: agent completed or failed
|
||||||
|
if let Some(f) = live {
|
||||||
|
match &result {
|
||||||
|
Ok(_) => f(agent_id.to_string(), AgentStatus {
|
||||||
|
state: AgentState::Completed,
|
||||||
|
started_at: Some(started_at),
|
||||||
|
completed_at: Some(completed_at),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
Err(e) => f(agent_id.to_string(), AgentStatus {
|
||||||
|
state: AgentState::Failed,
|
||||||
|
started_at: Some(started_at),
|
||||||
|
completed_at: Some(completed_at),
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||||
@@ -95,14 +163,16 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
|||||||
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
|
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
|
||||||
/// concurrency cap for parallel branches.
|
/// concurrency cap for parallel branches.
|
||||||
///
|
///
|
||||||
/// Flow: match the primitive ->
|
/// Flow: match the primitive →
|
||||||
/// `Agent` -> `spawn_single_agent`
|
/// `Agent` → `spawn_single_agent`
|
||||||
/// `Parallel` -> spawn threads up to `concurrency_cap`, join
|
/// `Parallel` → spawn threads up to `concurrency_cap` (semaphore-gated),
|
||||||
/// `Pipeline` -> spawn threads sequentially, collect in order
|
/// collect results in submission order
|
||||||
/// `Phase` -> recurse (pass-through wrapper)
|
/// `Pipeline` → execute stages sequentially; findings flow between stages
|
||||||
|
/// `Phase` → recurse (pass-through wrapper)
|
||||||
///
|
///
|
||||||
/// Why: parallelism is implemented with `std::thread::spawn` and a
|
/// Why: `Parallel` uses OS threads + a semaphore so the main async event
|
||||||
/// counting semaphore so the main async event loop remains unblocked.
|
/// loop remains responsive. `Pipeline` is sequential so each stage sees
|
||||||
|
/// findings deposited by the previous one.
|
||||||
///
|
///
|
||||||
/// 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.
|
||||||
@@ -111,12 +181,15 @@ pub fn execute_primitive(
|
|||||||
args: &HashMap<String, String>,
|
args: &HashMap<String, String>,
|
||||||
concurrency_cap: usize,
|
concurrency_cap: usize,
|
||||||
continue_on_error: bool,
|
continue_on_error: bool,
|
||||||
|
live: Option<&LiveStateFn>,
|
||||||
) -> 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 resolved = resolve_template(prompt, args);
|
||||||
let findings_snapshot = FINDINGS.lock().map(|f| f.clone()).unwrap_or_default();
|
let findings_snapshot = FINDINGS.lock().map(|f| f.clone()).unwrap_or_default();
|
||||||
match spawn_single_agent(&resolved, findings_snapshot) {
|
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||||
|
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, live) {
|
||||||
Ok(text) => Ok(vec![text]),
|
Ok(text) => Ok(vec![text]),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if continue_on_error {
|
||||||
@@ -129,6 +202,9 @@ pub fn execute_primitive(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ScriptPrimitive::Parallel(scripts) => {
|
ScriptPrimitive::Parallel(scripts) => {
|
||||||
|
// All branches run concurrently, capped by semaphore.
|
||||||
|
// This is the primary advantage over single-turn chat: multiple
|
||||||
|
// independent subagents work simultaneously.
|
||||||
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
||||||
let results: Arc<Mutex<Vec<ParallelResult>>> =
|
let results: Arc<Mutex<Vec<ParallelResult>>> =
|
||||||
Arc::new(Mutex::new(Vec::new()));
|
Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -142,10 +218,14 @@ 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 live_clone = live.cloned();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _permit = sem.acquire();
|
let _permit = sem.acquire();
|
||||||
let result = execute_primitive(&script, &args, cap, continue_on_error);
|
let result = execute_primitive(
|
||||||
|
&script, &args, cap, continue_on_error,
|
||||||
|
live_clone.as_ref(),
|
||||||
|
);
|
||||||
if let Ok(mut locked) = results.lock() {
|
if let Ok(mut locked) = results.lock() {
|
||||||
locked.push((idx, result));
|
locked.push((idx, result));
|
||||||
}
|
}
|
||||||
@@ -170,66 +250,69 @@ pub fn execute_primitive(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ScriptPrimitive::Pipeline(scripts) => {
|
ScriptPrimitive::Pipeline(scripts) => {
|
||||||
let results_store: Arc<Mutex<Vec<Option<Vec<String>>>>> =
|
// Sequential: each stage runs only after the previous completes.
|
||||||
Arc::new(Mutex::new(vec![None; scripts.len()]));
|
//
|
||||||
let args_arc = Arc::new(args.clone());
|
// Why: parallel execution defeats the purpose of a pipeline whose
|
||||||
|
// stages are supposed to build on each other's output. Findings
|
||||||
let handles: Vec<_> = scripts
|
// written by stage N are visible to stage N+1 because we share
|
||||||
.iter()
|
// the global FINDINGS mutex.
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, script)| {
|
|
||||||
let script = script.clone();
|
|
||||||
let args = Arc::clone(&args_arc);
|
|
||||||
let store = Arc::clone(&results_store);
|
|
||||||
let cap = concurrency_cap;
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = execute_primitive(&script, &args, cap, continue_on_error);
|
|
||||||
if let Ok(mut locked) = store.lock() {
|
|
||||||
locked[idx] = Some(result.unwrap_or_else(|e| vec![format!("pipeline stage {} error: {}", idx, e)]));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for handle in handles {
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
let locked = results_store.lock().map_err(|_| anyhow::anyhow!("pipeline results lock poisoned"))?;
|
|
||||||
let mut all = Vec::new();
|
let mut all = Vec::new();
|
||||||
for outputs in locked.iter().flatten() {
|
for (idx, script) in scripts.iter().enumerate() {
|
||||||
all.extend(outputs.iter().cloned());
|
match execute_primitive(script, args, concurrency_cap, continue_on_error, live) {
|
||||||
|
Ok(outputs) => all.extend(outputs),
|
||||||
|
Err(e) => {
|
||||||
|
if continue_on_error {
|
||||||
|
all.push(format!("pipeline stage {} error: {}", idx, e));
|
||||||
|
} else {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(all)
|
Ok(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScriptPrimitive::Phase { name: _name, script } => {
|
ScriptPrimitive::Phase { name: _name, script } => {
|
||||||
execute_primitive(script, args, concurrency_cap, continue_on_error)
|
execute_primitive(script, args, concurrency_cap, continue_on_error, live)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a `WorkflowScript` with the given template arguments and produce a
|
/// Run a `WorkflowScript` with the given template arguments and produce a
|
||||||
/// summary string.
|
/// summary string. Uses no live-state callback.
|
||||||
///
|
|
||||||
/// Flow: clear the global finding store -> cap concurrency to 5 -> call
|
|
||||||
/// `execute_primitive` on the script's root primitive -> format results
|
|
||||||
/// into a one-line-per-agent summary.
|
|
||||||
///
|
///
|
||||||
/// Return: a human-readable summary string.
|
/// Return: a human-readable summary string.
|
||||||
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
|
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
|
||||||
|
run_workflow_tracked(script, args, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a `WorkflowScript` with real-time live-state callbacks so the TUI
|
||||||
|
/// panel updates as each agent transitions between Idle/Running/Done/Failed.
|
||||||
|
///
|
||||||
|
/// Flow: clear the global finding store → cap concurrency to 8 → call
|
||||||
|
/// `execute_primitive` with the live callback → format results.
|
||||||
|
///
|
||||||
|
/// Return: a human-readable summary string.
|
||||||
|
pub fn run_workflow_tracked(
|
||||||
|
script: &WorkflowScript,
|
||||||
|
args: &HashMap<String, String>,
|
||||||
|
live: Option<LiveStateFn>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
if let Ok(mut findings) = FINDINGS.lock() {
|
if let Ok(mut findings) = FINDINGS.lock() {
|
||||||
findings.clear();
|
findings.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
let concurrency_cap = if script.options.max_concurrency > 0 {
|
let concurrency_cap = if script.options.max_concurrency > 0 {
|
||||||
script.options.max_concurrency.min(5)
|
script.options.max_concurrency.min(8) // allow up to 8 parallel agents
|
||||||
} else {
|
} else {
|
||||||
5
|
4
|
||||||
};
|
};
|
||||||
|
|
||||||
let results = execute_primitive(&script.script, args, concurrency_cap, script.options.continue_on_error)?;
|
let live_ref = live.as_ref();
|
||||||
|
let results = execute_primitive(
|
||||||
|
&script.script, args, concurrency_cap,
|
||||||
|
script.options.continue_on_error, live_ref,
|
||||||
|
)?;
|
||||||
|
|
||||||
let summary = if results.is_empty() {
|
let summary = if results.is_empty() {
|
||||||
"workflow completed with no output".to_string()
|
"workflow completed with no output".to_string()
|
||||||
@@ -261,7 +344,7 @@ pub fn note_finding(text: &str) {
|
|||||||
/// Simple template engine: replace `{{key}}` placeholders with values
|
/// Simple template engine: replace `{{key}}` placeholders with values
|
||||||
/// from `args`.
|
/// from `args`.
|
||||||
///
|
///
|
||||||
/// Why: a structed template engine is unnecessary for the limited
|
/// Why: a structured template engine is unnecessary for the limited
|
||||||
/// use-case; this is intentionally simple and safe.
|
/// use-case; this is intentionally simple and safe.
|
||||||
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||||
let mut result = template.to_string();
|
let mut result = template.to_string();
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
ModelList,
|
ModelList,
|
||||||
Compact,
|
Compact,
|
||||||
|
WorkflowOpen,
|
||||||
|
WorkflowRun {
|
||||||
|
script: String,
|
||||||
|
},
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +92,14 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
}
|
}
|
||||||
"/model" => Command::ModelList,
|
"/model" => Command::ModelList,
|
||||||
"/compact" => Command::Compact,
|
"/compact" => Command::Compact,
|
||||||
|
"/workflow" if arg1.is_empty() => Command::WorkflowOpen,
|
||||||
|
"/workflow" if arg1 == "run" && !arg2.is_empty() => Command::WorkflowRun {
|
||||||
|
script: arg2.to_string(),
|
||||||
|
},
|
||||||
|
"/workflow" if arg1 == "run" => Command::WorkflowOpen,
|
||||||
|
"/workflow" => Command::WorkflowRun {
|
||||||
|
script: arg1.to_string(),
|
||||||
|
},
|
||||||
_ => Command::Unknown(cmd.to_string()),
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-6
@@ -22,12 +22,14 @@ Navigation:
|
|||||||
Up/Down History navigation
|
Up/Down History navigation
|
||||||
|
|
||||||
Input:
|
Input:
|
||||||
/help Show help
|
/help Show help
|
||||||
/clear Clear screen
|
/clear Clear screen
|
||||||
/model Select AI model provider
|
/model Select AI model provider
|
||||||
/exit Exit application
|
/workflow Open workflow panel
|
||||||
/settings Open settings
|
/workflow run <p> Run a workflow with prompt <p>
|
||||||
/session Session management
|
/mode workflow Open workflow panel
|
||||||
|
/compact Compact conversation history
|
||||||
|
/exit Exit application
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
Any text is sent to the AI assistant as a prompt.
|
Any text is sent to the AI assistant as a prompt.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod seqthink;
|
|||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod shell_filter;
|
pub mod shell_filter;
|
||||||
pub mod utility;
|
pub mod utility;
|
||||||
|
pub mod spawn;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
/// Common interface every agent-invocable tool implements: name, JSON schema, and execution.
|
/// Common interface every agent-invocable tool implements: name, JSON schema, and execution.
|
||||||
@@ -150,6 +151,8 @@ 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::spawn::SpawnAgents),
|
||||||
|
Box::new(super::tool::spawn::SpawnPipeline),
|
||||||
Box::new(super::tool::memory::remember::Remember),
|
Box::new(super::tool::memory::remember::Remember),
|
||||||
Box::new(super::tool::memory::forget::Forget),
|
Box::new(super::tool::memory::forget::Forget),
|
||||||
Box::new(super::tool::memory::recall::Recall),
|
Box::new(super::tool::memory::recall::Recall),
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! `spawn_agents` tool — simple interface for the main agent to fan out work
|
||||||
|
//! to multiple subagents running in parallel.
|
||||||
|
//!
|
||||||
|
//! Unlike `workflow_run` (which requires a JSON-encoded `WorkflowScript`),
|
||||||
|
//! `spawn_agents` accepts a plain list of prompt strings and automatically
|
||||||
|
//! runs them as a `Parallel` workflow. The agent just says what each
|
||||||
|
//! subagent should do, not how to encode the script.
|
||||||
|
//!
|
||||||
|
//! Also provides a pipeline variant: `spawn_pipeline` runs agents
|
||||||
|
//! sequentially so each stage sees the previous stage's findings.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use super::{Tool, ToolCtx};
|
||||||
|
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||||
|
|
||||||
|
/// Fan out a list of prompts to independent parallel subagents.
|
||||||
|
pub struct SpawnAgents;
|
||||||
|
|
||||||
|
impl Tool for SpawnAgents {
|
||||||
|
fn name(&self) -> &'static str { "spawn_agents" }
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Fan out independent subtasks to multiple subagents running in PARALLEL. \
|
||||||
|
Pass a list of prompt strings — each becomes one autonomous subagent with \
|
||||||
|
access to all tools. Use this whenever a task has independent parts that do \
|
||||||
|
not need each other's output (e.g. analysing multiple files simultaneously, \
|
||||||
|
writing multiple independent modules, parallel verification). \
|
||||||
|
Results from all agents are returned together. \
|
||||||
|
Use spawn_pipeline instead when each stage needs the previous stage's output."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agents": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of prompt strings, one per subagent. Each subagent runs independently and in parallel.",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"minItems": 2
|
||||||
|
},
|
||||||
|
"max_concurrency": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of agents to run simultaneously (default: 4, max: 8).",
|
||||||
|
"default": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["agents"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let agents: Vec<String> = args.get("agents")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| anyhow!("missing required argument: agents"))?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if agents.is_empty() {
|
||||||
|
return Err(anyhow!("agents list must not be empty"));
|
||||||
|
}
|
||||||
|
if agents.len() == 1 {
|
||||||
|
return Err(anyhow!("use a single agent tool call for one task; spawn_agents is for 2+ parallel tasks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_concurrency = args.get("max_concurrency")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v.min(8) as usize)
|
||||||
|
.unwrap_or(4);
|
||||||
|
|
||||||
|
let agent_count = agents.len();
|
||||||
|
let primitives: Vec<ScriptPrimitive> = agents
|
||||||
|
.into_iter()
|
||||||
|
.map(ScriptPrimitive::Agent)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let wf = WorkflowScript {
|
||||||
|
name: format!("parallel-{}-agents", agent_count),
|
||||||
|
description: format!("Auto-spawned parallel workflow with {} agents", agent_count),
|
||||||
|
script: ScriptPrimitive::Parallel(primitives),
|
||||||
|
options: ScriptOptions {
|
||||||
|
max_concurrency,
|
||||||
|
continue_on_error: true,
|
||||||
|
timeout_ms: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = crate::app::workflow::engine::execute_primitive(
|
||||||
|
&wf.script,
|
||||||
|
&HashMap::new(),
|
||||||
|
max_concurrency,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
format_results(results, "parallel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run agents sequentially in a pipeline — each stage sees previous findings.
|
||||||
|
pub struct SpawnPipeline;
|
||||||
|
|
||||||
|
impl Tool for SpawnPipeline {
|
||||||
|
fn name(&self) -> &'static str { "spawn_pipeline" }
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Run subagents SEQUENTIALLY in a pipeline — each stage sees findings \
|
||||||
|
shared by previous stages via note_finding. Use when stages build on each \
|
||||||
|
other (e.g. 'research -> plan -> implement -> test'). \
|
||||||
|
Use spawn_agents instead when tasks are truly independent and order does not matter."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"stages": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Ordered list of prompt strings. Each stage runs after the previous one completes. Stages can call note_finding() to pass data to later stages.",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"minItems": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["stages"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let stages: Vec<String> = args.get("stages")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| anyhow!("missing required argument: stages"))?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if stages.is_empty() {
|
||||||
|
return Err(anyhow!("stages list must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let primitives: Vec<ScriptPrimitive> = stages
|
||||||
|
.into_iter()
|
||||||
|
.map(ScriptPrimitive::Agent)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let wf = WorkflowScript {
|
||||||
|
name: "pipeline".to_string(),
|
||||||
|
description: "Auto-spawned pipeline workflow".to_string(),
|
||||||
|
script: ScriptPrimitive::Pipeline(primitives),
|
||||||
|
options: ScriptOptions {
|
||||||
|
max_concurrency: 1,
|
||||||
|
continue_on_error: false,
|
||||||
|
timeout_ms: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = crate::app::workflow::engine::execute_primitive(
|
||||||
|
&wf.script,
|
||||||
|
&HashMap::new(),
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
format_results(results, "pipeline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a list of agent results into a readable summary string.
|
||||||
|
fn format_results(results: Vec<String>, mode: &str) -> Result<String> {
|
||||||
|
if results.is_empty() {
|
||||||
|
return Ok(format!("{} workflow completed with no output", mode));
|
||||||
|
}
|
||||||
|
let formatted: Vec<String> = results
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, r)| format!("=== Agent {} ===\n{}", i + 1, r.trim()))
|
||||||
|
.collect();
|
||||||
|
Ok(formatted.join("\n\n"))
|
||||||
|
}
|
||||||
+142
-86
@@ -1,111 +1,167 @@
|
|||||||
//! Workflow status panel rendering.
|
//! Workflow status panel rendering.
|
||||||
//!
|
//!
|
||||||
//! Flow: `draw_workflow_panel` reads `state.session_runtime` and
|
//! Flow: `draw_workflow_panel` reads `state.workflow_engine` and renders a
|
||||||
//! `state.workflow_engine` and renders a compact `List` of counters
|
//! rich panel showing agent statuses, findings count, session counters,
|
||||||
//! (messages, completed/pending tool calls, active bash jobs, agents,
|
//! and usage hints.
|
||||||
//! findings) plus the current auto-run phase.
|
|
||||||
//!
|
//!
|
||||||
//! Why: shows a placeholder panel when there is no active session
|
//! Why: the panel is useful even without a running session (shows engine
|
||||||
//! runtime, and only emits rows for counters that are nonzero, to keep
|
//! state and instructions), and only shows non-zero counters to keep it
|
||||||
//! the panel compact during simple single-turn sessions.
|
//! compact.
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Style, Modifier};
|
use ratatui::style::{Style, Modifier};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
|
use crate::app::workflow::engine::AgentState;
|
||||||
|
|
||||||
/// Render the workflow status panel summarizing the active session runtime.
|
/// Render the workflow status panel.
|
||||||
///
|
///
|
||||||
/// Flow: bail out with a "No active session" placeholder if
|
/// Flow: build header lines (title + usage hints) → if agents exist,
|
||||||
/// `state.session_runtime` is None → otherwise build a list of status
|
/// list each with its lifecycle state colour-coded → show findings
|
||||||
/// lines (message count, completed/pending tool calls, active bash jobs,
|
/// count and session counters → fall back to an instruction paragraph
|
||||||
/// agent/finding counts, auto-run phase) → render as a List widget.
|
/// when no agents have been spawned yet.
|
||||||
///
|
|
||||||
/// Why: rows for pending tool queue, bash jobs, agents, and findings are
|
|
||||||
/// only shown when their count is nonzero, to keep the panel compact
|
|
||||||
/// during simple single-turn sessions.
|
|
||||||
///
|
///
|
||||||
/// Return: nothing; draws directly into `frame` at `area`.
|
/// Return: nothing; draws directly into `frame` at `area`.
|
||||||
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::PRIMARY))
|
||||||
.title(" Workflow ");
|
.title(Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)));
|
||||||
|
|
||||||
let session_runtime = match &state.session_runtime {
|
let inner = block.inner(area);
|
||||||
Some(r) => r,
|
frame.render_widget(block, area);
|
||||||
None => {
|
|
||||||
let empty = Block::default().borders(Borders::ALL).title(" Workflow ");
|
|
||||||
let paragraph = ratatui::widgets::Paragraph::new(Line::from(Span::raw("No active session")))
|
|
||||||
.block(empty);
|
|
||||||
frame.render_widget(paragraph, area);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let tool_count = session_runtime.tool_call_results.len();
|
// Split inner into header (hints) and body (agent list / status)
|
||||||
let pending_count = session_runtime.pending_tool_queue.len();
|
let chunks = Layout::default()
|
||||||
let bash_count = session_runtime.bash_jobs.len();
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // header / hints
|
||||||
|
Constraint::Min(4), // agent list or placeholder
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
let agent_count = state.workflow_engine.agents.len();
|
// ── Header: usage hints ─────────────────────────────────────────────────
|
||||||
let findings_count = state.workflow_engine.findings.len();
|
let hint_lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled("<prompt>", Style::default().fg(Theme::DIM)),
|
||||||
|
Span::styled(" · Esc to close", Style::default().fg(Theme::DIM)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Status: ", Style::default().fg(Theme::DIM)),
|
||||||
|
if state.turn_in_flight() {
|
||||||
|
Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))
|
||||||
|
} else {
|
||||||
|
Span::styled("● Idle", Style::default().fg(Theme::SUCCESS))
|
||||||
|
},
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
format!("Agents: {} Findings: {}",
|
||||||
|
state.workflow_engine.agents.len(),
|
||||||
|
state.workflow_engine.findings.len(),
|
||||||
|
),
|
||||||
|
Style::default().fg(Theme::DIM),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
let header = Paragraph::new(hint_lines);
|
||||||
|
frame.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
let mut items = Vec::new();
|
// ── Body: agent list or placeholder ─────────────────────────────────────
|
||||||
|
if state.workflow_engine.agents.is_empty() {
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
// No agents yet — show session counters and a welcome message
|
||||||
format!(" Messages: {}", session_runtime.messages.len()),
|
let session_lines = build_session_lines(state);
|
||||||
Style::default().fg(Theme::TEXT),
|
let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false });
|
||||||
))));
|
frame.render_widget(placeholder, chunks[1]);
|
||||||
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
|
||||||
format!(" Tool calls completed: {}", tool_count),
|
|
||||||
Style::default().fg(Theme::SUCCESS),
|
|
||||||
))));
|
|
||||||
|
|
||||||
if pending_count > 0 {
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
|
||||||
format!(" Pending tool queue: {}", pending_count),
|
|
||||||
Style::default().fg(Theme::WARNING),
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
if bash_count > 0 {
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
|
||||||
format!(" Active bash jobs: {}", bash_count),
|
|
||||||
Style::default().fg(Theme::WARNING),
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent_count > 0 {
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
|
||||||
format!(" Agents: {}", agent_count),
|
|
||||||
Style::default().fg(Theme::INFO),
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
if findings_count > 0 {
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
|
||||||
format!(" Findings: {}", findings_count),
|
|
||||||
Style::default().fg(Theme::WARNING),
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
let phase_status = if state.turn_in_flight() {
|
|
||||||
"Auto-running"
|
|
||||||
} else {
|
} else {
|
||||||
"Awaiting input"
|
// Build agent status list
|
||||||
};
|
let items: Vec<ListItem> = state.workflow_engine.agents.iter().map(|agent| {
|
||||||
|
let (state_str, state_color) = match agent.status.state {
|
||||||
|
AgentState::Idle => ("Idle", Theme::DIM),
|
||||||
|
AgentState::Running => ("Running…", Theme::WARNING),
|
||||||
|
AgentState::Completed => ("Done ✓", Theme::SUCCESS),
|
||||||
|
AgentState::Failed => ("Failed ✗", Theme::ERROR),
|
||||||
|
};
|
||||||
|
let duration_str = match (agent.status.started_at, agent.status.completed_at) {
|
||||||
|
(Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)),
|
||||||
|
(Some(_), None) => " (running)".to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {:8} ", state_str),
|
||||||
|
Style::default().fg(state_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{}{}", agent.name, duration_str),
|
||||||
|
Style::default().fg(Theme::TEXT),
|
||||||
|
),
|
||||||
|
if let Some(ref err) = agent.status.error {
|
||||||
|
Span::styled(format!(" — {}", err), Style::default().fg(Theme::ERROR))
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
}).collect();
|
||||||
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
let list = List::new(items)
|
||||||
format!(" Phase: {}", phase_status),
|
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
Style::default().fg(Theme::PRIMARY),
|
frame.render_widget(list, chunks[1]);
|
||||||
))));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let list = List::new(items)
|
/// Build a compact list of session counters for the placeholder view.
|
||||||
.block(block)
|
fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Line<'static>> {
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
frame.render_widget(list, area);
|
lines.push(Line::from(Span::styled(
|
||||||
|
" No workflow running.",
|
||||||
|
Style::default().fg(Theme::DIM),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(Span::raw("")));
|
||||||
|
|
||||||
|
if let Some(ref rt) = state.session_runtime {
|
||||||
|
let tool_count = rt.tool_call_results.len();
|
||||||
|
let pending = rt.pending_tool_queue.len();
|
||||||
|
let bash_count = rt.bash_jobs.len();
|
||||||
|
let msg_count = rt.messages.len();
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Messages ", Style::default().fg(Theme::DIM)),
|
||||||
|
Span::styled(msg_count.to_string(), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Tool calls", Style::default().fg(Theme::DIM)),
|
||||||
|
Span::styled(format!(" {}", tool_count), Style::default().fg(Theme::SUCCESS)),
|
||||||
|
]));
|
||||||
|
if pending > 0 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Pending ", Style::default().fg(Theme::DIM)),
|
||||||
|
Span::styled(format!(" {}", pending), Style::default().fg(Theme::WARNING)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if bash_count > 0 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Bash jobs ", Style::default().fg(Theme::DIM)),
|
||||||
|
Span::styled(format!(" {}", bash_count), Style::default().fg(Theme::WARNING)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" (no active session)",
|
||||||
|
Style::default().fg(Theme::DIM),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::raw("")));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" Run a workflow to see agents here.",
|
||||||
|
Style::default().fg(Theme::DIM).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user