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:
asepharyana
2026-07-12 17:49:34 +07:00
parent 7bdfd9c4c4
commit 53b0cb271f
14 changed files with 668 additions and 173 deletions
+22 -13
View File
@@ -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.
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. 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 complex tasks (multi-file changes, parallel analysis, independent verification), use workflow_run to orchestrate sub-agents.
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.
5. Use the tools available to explore, understand, and modify the codebase.
6. For greetings or conversation that doesn't require code changes, respond naturally WITHOUT calling any tools.
7. 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.
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.
16. 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. Use lsp_connect
to add servers for other languages.
PARALLEL SUBAGENT STRATEGY — use this automatically, without being asked:
- 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.
- When a task has sequential dependent stages (e.g. research → plan → implement → verify), call spawn_pipeline with one prompt per stage.
- Examples of when to use spawn_agents automatically:
* "Fix all lint warnings" → spawn one agent per file/module with warnings
* "Add tests for these 3 functions" → spawn 3 agents in parallel
* "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.
+11 -4
View File
@@ -37,10 +37,17 @@ Memory & Planning:
- 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.
Workflow:
- workflow_run(script, args) — Fan out work to sub-agents. Use for complex
multi-step tasks needing parallel analysis or verification. Pass inline
scripts with agent(), parallel(), and pipeline() primitives.
Workflow (USE THESE AUTOMATICALLY for multi-part tasks — no user prompt needed):
- spawn_agents(agents, max_concurrency?) — Run a list of prompts as PARALLEL subagents.
Each agent is fully autonomous with all tools. Returns combined results.
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.
Language Server Protocol (LSP) tools:
+1
View File
@@ -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;
+126 -2
View File
@@ -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,
});
}
});
}
}
}
+6
View File
@@ -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(),
+3
View File
@@ -92,6 +92,9 @@ const COMMANDS: &[&str] = &[
"/model",
"/model ls",
"/model add",
"/workflow",
"/workflow run",
"/compact",
];
impl InputState {
+7
View File
@@ -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 {
+6 -4
View File
@@ -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
View File
@@ -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();
+12
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -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),
+180
View File
@@ -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
View File
@@ -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
}