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:
@@ -8,6 +8,7 @@ pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod key_input;
|
||||
pub mod mcp;
|
||||
pub mod workflow;
|
||||
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
|
||||
@@ -82,6 +82,9 @@ pub enum Action {
|
||||
ModelList,
|
||||
AbortTurn,
|
||||
Compact,
|
||||
RunWorkflow {
|
||||
script: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Apply an `Action` to the application state.
|
||||
@@ -106,8 +109,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
Action::SwitchMode(mode) => {
|
||||
state.misc.overlay = match mode {
|
||||
ModeKind::Chat
|
||||
| ModeKind::Bash
|
||||
| ModeKind::Workflow => Overlay::None,
|
||||
| ModeKind::Bash => Overlay::None,
|
||||
ModeKind::Workflow => Overlay::Workflow,
|
||||
ModeKind::Help => Overlay::Help,
|
||||
ModeKind::Settings => Overlay::Settings,
|
||||
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
||||
@@ -434,6 +437,30 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
} else if kind == "connectivity" {
|
||||
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 {
|
||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
}
|
||||
@@ -492,6 +519,25 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
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 {
|
||||
@@ -542,6 +588,84 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
format!("rejected lesson: {}", name)));
|
||||
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 => {
|
||||
vec![Action::Compact]
|
||||
}
|
||||
Command::WorkflowOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Workflow)]
|
||||
}
|
||||
Command::WorkflowRun { script } => {
|
||||
vec![Action::RunWorkflow { script }]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
|
||||
@@ -92,6 +92,9 @@ const COMMANDS: &[&str] = &[
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
"/workflow",
|
||||
"/workflow run",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
impl InputState {
|
||||
|
||||
@@ -103,6 +103,13 @@ pub enum TurnEvent {
|
||||
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
|
||||
Error(String),
|
||||
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 {
|
||||
|
||||
@@ -18,12 +18,13 @@ pub struct SubagentContext {
|
||||
|
||||
/// Build a `SubagentContext` from an `AgentDefinition`.
|
||||
///
|
||||
/// Flow: copy optional `allowed_tools` from the def -> fall back to the
|
||||
/// reviewer-allowlist when the def has none and the role is "reviewer" ->
|
||||
/// Flow: copy optional `allowed_tools` from the def → fall back to the
|
||||
/// 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.
|
||||
/// `max_steps` is read from the definition, defaulting to 25 if absent.
|
||||
///
|
||||
/// 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 {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
@@ -32,10 +33,11 @@ pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||
Vec::new()
|
||||
}
|
||||
});
|
||||
let max_steps = def.max_steps.unwrap_or(25);
|
||||
SubagentContext {
|
||||
system_prompt: String::new(),
|
||||
allowed_tools,
|
||||
max_steps: 25,
|
||||
max_steps,
|
||||
session_dir: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
|
||||
+141
-58
@@ -1,6 +1,14 @@
|
||||
//! Workflow engine: interprets `ScriptPrimitive` values (agent, parallel,
|
||||
//! pipeline, phase) by spawning subagents, collecting results, and
|
||||
//! 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::sync::{Arc, Mutex};
|
||||
@@ -53,21 +61,48 @@ impl WorkflowEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a single synchronous subagent with the given prompt, passing it
|
||||
/// any findings from earlier sibling agents.
|
||||
/// Shared live state used by `run_workflow_tracked` to push real-time
|
||||
/// agent status updates into the TUI's `WorkflowEngine`.
|
||||
///
|
||||
/// Flow: build an `AgentDefinition` -> build a `SubagentContext` ->
|
||||
/// inject findings into the system prompt -> call `run_subagent` on a
|
||||
/// dedicated mpsc channel.
|
||||
/// The closure receives `(agent_id, new_status)` and should update the
|
||||
/// corresponding agent in `AppStateRest::workflow_engine`.
|
||||
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.
|
||||
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::engine::run_subagent;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
let def = AgentDefinition::new("workflow-agent".to_string(), "coder".to_string())
|
||||
.with_max_steps(usize::MAX);
|
||||
let started_at = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// 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 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);
|
||||
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(32);
|
||||
run_subagent(ctx, tx)
|
||||
// Create an mpsc channel and drain events in a background thread so
|
||||
// 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>>);
|
||||
@@ -95,14 +163,16 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
|
||||
/// concurrency cap for parallel branches.
|
||||
///
|
||||
/// Flow: match the primitive ->
|
||||
/// `Agent` -> `spawn_single_agent`
|
||||
/// `Parallel` -> spawn threads up to `concurrency_cap`, join
|
||||
/// `Pipeline` -> spawn threads sequentially, collect in order
|
||||
/// `Phase` -> recurse (pass-through wrapper)
|
||||
/// Flow: match the primitive →
|
||||
/// `Agent` → `spawn_single_agent`
|
||||
/// `Parallel` → spawn threads up to `concurrency_cap` (semaphore-gated),
|
||||
/// collect results in submission order
|
||||
/// `Pipeline` → execute stages sequentially; findings flow between stages
|
||||
/// `Phase` → recurse (pass-through wrapper)
|
||||
///
|
||||
/// Why: parallelism is implemented with `std::thread::spawn` and a
|
||||
/// counting semaphore so the main async event loop remains unblocked.
|
||||
/// Why: `Parallel` uses OS threads + a semaphore so the main async event
|
||||
/// 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
|
||||
/// the order they were submitted.
|
||||
@@ -111,12 +181,15 @@ pub fn execute_primitive(
|
||||
args: &HashMap<String, String>,
|
||||
concurrency_cap: usize,
|
||||
continue_on_error: bool,
|
||||
live: Option<&LiveStateFn>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(prompt) => {
|
||||
let resolved = resolve_template(prompt, args);
|
||||
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]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -129,6 +202,9 @@ pub fn execute_primitive(
|
||||
}
|
||||
|
||||
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 results: Arc<Mutex<Vec<ParallelResult>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -142,10 +218,14 @@ pub fn execute_primitive(
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let cap = concurrency_cap;
|
||||
let live_clone = live.cloned();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
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() {
|
||||
locked.push((idx, result));
|
||||
}
|
||||
@@ -170,66 +250,69 @@ pub fn execute_primitive(
|
||||
}
|
||||
|
||||
ScriptPrimitive::Pipeline(scripts) => {
|
||||
let results_store: Arc<Mutex<Vec<Option<Vec<String>>>>> =
|
||||
Arc::new(Mutex::new(vec![None; scripts.len()]));
|
||||
let args_arc = Arc::new(args.clone());
|
||||
|
||||
let handles: Vec<_> = scripts
|
||||
.iter()
|
||||
.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"))?;
|
||||
// Sequential: each stage runs only after the previous completes.
|
||||
//
|
||||
// Why: parallel execution defeats the purpose of a pipeline whose
|
||||
// stages are supposed to build on each other's output. Findings
|
||||
// written by stage N are visible to stage N+1 because we share
|
||||
// the global FINDINGS mutex.
|
||||
let mut all = Vec::new();
|
||||
for outputs in locked.iter().flatten() {
|
||||
all.extend(outputs.iter().cloned());
|
||||
for (idx, script) in scripts.iter().enumerate() {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
/// summary string.
|
||||
///
|
||||
/// 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.
|
||||
/// summary string. Uses no live-state callback.
|
||||
///
|
||||
/// Return: a human-readable summary 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() {
|
||||
findings.clear();
|
||||
}
|
||||
|
||||
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 {
|
||||
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() {
|
||||
"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
|
||||
/// 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.
|
||||
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||
let mut result = template.to_string();
|
||||
|
||||
@@ -25,6 +25,10 @@ pub enum Command {
|
||||
},
|
||||
ModelList,
|
||||
Compact,
|
||||
WorkflowOpen,
|
||||
WorkflowRun {
|
||||
script: String,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -88,6 +92,14 @@ pub fn parse_command(text: &str) -> Command {
|
||||
}
|
||||
"/model" => Command::ModelList,
|
||||
"/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()),
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -22,12 +22,14 @@ Navigation:
|
||||
Up/Down History navigation
|
||||
|
||||
Input:
|
||||
/help Show help
|
||||
/clear Clear screen
|
||||
/model Select AI model provider
|
||||
/exit Exit application
|
||||
/settings Open settings
|
||||
/session Session management
|
||||
/help Show help
|
||||
/clear Clear screen
|
||||
/model Select AI model provider
|
||||
/workflow Open workflow panel
|
||||
/workflow run <p> Run a workflow with prompt <p>
|
||||
/mode workflow Open workflow panel
|
||||
/compact Compact conversation history
|
||||
/exit Exit application
|
||||
|
||||
Commands:
|
||||
Any text is sent to the AI assistant as a prompt.
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod seqthink;
|
||||
pub mod shell;
|
||||
pub mod shell_filter;
|
||||
pub mod utility;
|
||||
pub mod spawn;
|
||||
pub mod workflow;
|
||||
|
||||
/// 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::workflow::WorkflowRun),
|
||||
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::forget::Forget),
|
||||
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.
|
||||
//!
|
||||
//! Flow: `draw_workflow_panel` reads `state.session_runtime` and
|
||||
//! `state.workflow_engine` and renders a compact `List` of counters
|
||||
//! (messages, completed/pending tool calls, active bash jobs, agents,
|
||||
//! findings) plus the current auto-run phase.
|
||||
//! Flow: `draw_workflow_panel` reads `state.workflow_engine` and renders a
|
||||
//! rich panel showing agent statuses, findings count, session counters,
|
||||
//! and usage hints.
|
||||
//!
|
||||
//! Why: shows a placeholder panel when there is no active session
|
||||
//! runtime, and only emits rows for counters that are nonzero, to keep
|
||||
//! the panel compact during simple single-turn sessions.
|
||||
//! Why: the panel is useful even without a running session (shows engine
|
||||
//! state and instructions), and only shows non-zero counters to keep it
|
||||
//! compact.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Style, Modifier};
|
||||
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 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
|
||||
/// `state.session_runtime` is None → otherwise build a list of status
|
||||
/// lines (message count, completed/pending tool calls, active bash jobs,
|
||||
/// agent/finding counts, auto-run phase) → render as a List widget.
|
||||
///
|
||||
/// 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.
|
||||
/// Flow: build header lines (title + usage hints) → if agents exist,
|
||||
/// list each with its lifecycle state colour-coded → show findings
|
||||
/// count and session counters → fall back to an instruction paragraph
|
||||
/// when no agents have been spawned yet.
|
||||
///
|
||||
/// Return: nothing; draws directly into `frame` at `area`.
|
||||
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()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(" Workflow ");
|
||||
.border_style(Style::default().fg(Theme::PRIMARY))
|
||||
.title(Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)));
|
||||
|
||||
let session_runtime = match &state.session_runtime {
|
||||
Some(r) => r,
|
||||
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 inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let tool_count = session_runtime.tool_call_results.len();
|
||||
let pending_count = session_runtime.pending_tool_queue.len();
|
||||
let bash_count = session_runtime.bash_jobs.len();
|
||||
// Split inner into header (hints) and body (agent list / status)
|
||||
let chunks = Layout::default()
|
||||
.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();
|
||||
let findings_count = state.workflow_engine.findings.len();
|
||||
// ── Header: usage hints ─────────────────────────────────────────────────
|
||||
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();
|
||||
|
||||
items.push(ListItem::new(Line::from(Span::styled(
|
||||
format!(" Messages: {}", session_runtime.messages.len()),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
))));
|
||||
|
||||
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"
|
||||
// ── Body: agent list or placeholder ─────────────────────────────────────
|
||||
if state.workflow_engine.agents.is_empty() {
|
||||
// No agents yet — show session counters and a welcome message
|
||||
let session_lines = build_session_lines(state);
|
||||
let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false });
|
||||
frame.render_widget(placeholder, chunks[1]);
|
||||
} 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(
|
||||
format!(" Phase: {}", phase_status),
|
||||
Style::default().fg(Theme::PRIMARY),
|
||||
))));
|
||||
let list = List::new(items)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||
frame.render_widget(list, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
let list = List::new(items)
|
||||
.block(block)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||
/// Build a compact list of session counters for the placeholder view.
|
||||
fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Line<'static>> {
|
||||
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