|
|
|
@@ -9,14 +9,16 @@
|
|
|
|
|
//! the previous one.
|
|
|
|
|
//! - `run_workflow_tracked` accepts a `LiveState` callback that receives
|
|
|
|
|
//! real-time agent status updates for the TUI panel.
|
|
|
|
|
//! - Findings (inter-agent notes) are scoped per invocation via an
|
|
|
|
|
//! `Arc<Mutex<Vec<String>>>` threaded through `execute_primitive` and
|
|
|
|
|
//! `spawn_single_agent` rather than a global static, preventing data
|
|
|
|
|
//! leaks between concurrent workflow runs.
|
|
|
|
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use super::script::{ScriptPrimitive, WorkflowScript};
|
|
|
|
|
|
|
|
|
|
static FINDINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
|
|
|
|
|
|
|
|
|
/// The lifecycle state of an agent within a workflow run.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum AgentState {
|
|
|
|
@@ -73,9 +75,14 @@ pub type LiveStateFn = Arc<dyn Fn(String, AgentStatus) + Send + Sync>;
|
|
|
|
|
/// 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`.
|
|
|
|
|
/// findings preamble, linking the `workflow_findings` Arc so the subagent's
|
|
|
|
|
/// `note_finding` tool pushes into the same vec → call `run_subagent`
|
|
|
|
|
/// (draining the event channel into a consumer so events are not blocked)
|
|
|
|
|
/// → push `Completed` or `Failed`.
|
|
|
|
|
///
|
|
|
|
|
/// Why: the `workflow_findings` Arc is shared by all agents within the same
|
|
|
|
|
/// `execute_primitive` scope, so pipeline stages can pass data between each
|
|
|
|
|
/// other while different workflow invocations remain isolated.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the agent's text output, or an error on failure.
|
|
|
|
|
fn spawn_single_agent(
|
|
|
|
@@ -83,6 +90,7 @@ fn spawn_single_agent(
|
|
|
|
|
agent_name: &str,
|
|
|
|
|
prompt: &str,
|
|
|
|
|
findings_snapshot: Vec<String>,
|
|
|
|
|
findings: &Arc<Mutex<Vec<String>>>,
|
|
|
|
|
live: Option<&LiveStateFn>,
|
|
|
|
|
session_dir: &std::path::Path,
|
|
|
|
|
workspaces: &[std::path::PathBuf],
|
|
|
|
@@ -124,6 +132,9 @@ fn spawn_single_agent(
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ctx.system_prompt = format!("{}{}", prompt, findings_section);
|
|
|
|
|
// Link the shared findings Arc so note_finding calls within this
|
|
|
|
|
// subagent write into the same vec visible to sibling agents.
|
|
|
|
|
ctx.workflow_findings = Some(findings.clone());
|
|
|
|
|
|
|
|
|
|
// Create an mpsc channel and drain events in a background thread so
|
|
|
|
|
// run_subagent's blocking_send never blocks (previously the _rx was
|
|
|
|
@@ -131,10 +142,32 @@ fn spawn_single_agent(
|
|
|
|
|
// 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).
|
|
|
|
|
// Drain all events so run_subagent's blocking_send never blocks.
|
|
|
|
|
// Individual SubagentEvent items are not surfaced to the TUI —
|
|
|
|
|
// the live state callbacks above handle coarse-grained Running /
|
|
|
|
|
// Completed / Failed status. ToolCall / ToolResult / StepCompleted
|
|
|
|
|
// events are traced at debug level for observability.
|
|
|
|
|
use crate::app::subagent::event::SubagentEvent;
|
|
|
|
|
let mut rx = rx;
|
|
|
|
|
while rx.blocking_recv().is_some() {}
|
|
|
|
|
while let Some(event) = rx.blocking_recv() {
|
|
|
|
|
match &event {
|
|
|
|
|
SubagentEvent::ToolCall { _tool, _args } => {
|
|
|
|
|
tracing::debug!("[subagent] tool call: {}", _tool);
|
|
|
|
|
}
|
|
|
|
|
SubagentEvent::ToolResult { _tool, .. } => {
|
|
|
|
|
tracing::debug!("[subagent] tool result: {}", _tool);
|
|
|
|
|
}
|
|
|
|
|
SubagentEvent::StepCompleted { _step, .. } => {
|
|
|
|
|
tracing::trace!("[subagent] step {} completed", _step);
|
|
|
|
|
}
|
|
|
|
|
SubagentEvent::StepFailed { _step, _error } => {
|
|
|
|
|
tracing::warn!("[subagent] step {} failed: {}", _step, _error);
|
|
|
|
|
}
|
|
|
|
|
SubagentEvent::Completed { .. } => {
|
|
|
|
|
tracing::debug!("[subagent] completed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let result = run_subagent(ctx, tx);
|
|
|
|
@@ -176,7 +209,9 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
|
|
|
|
///
|
|
|
|
|
/// 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.
|
|
|
|
|
/// findings deposited by the previous one. Findings are scoped to an
|
|
|
|
|
/// `Arc<Mutex<Vec<String>>>` rather than a global static, so concurrent
|
|
|
|
|
/// workflow runs are isolated from each other.
|
|
|
|
|
///
|
|
|
|
|
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
|
|
|
|
/// the order they were submitted.
|
|
|
|
@@ -188,14 +223,15 @@ pub fn execute_primitive(
|
|
|
|
|
live: Option<&LiveStateFn>,
|
|
|
|
|
session_dir: &std::path::Path,
|
|
|
|
|
workspaces: &[std::path::PathBuf],
|
|
|
|
|
findings: &Arc<Mutex<Vec<String>>>,
|
|
|
|
|
) -> 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();
|
|
|
|
|
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
|
|
|
|
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, session_dir, workspaces) {
|
|
|
|
|
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces) {
|
|
|
|
|
Ok(text) => Ok(vec![text]),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if continue_on_error {
|
|
|
|
@@ -211,6 +247,8 @@ pub fn execute_primitive(
|
|
|
|
|
// All branches run concurrently, capped by semaphore.
|
|
|
|
|
// This is the primary advantage over single-turn chat: multiple
|
|
|
|
|
// independent subagents work simultaneously.
|
|
|
|
|
// Each branch shares the same `findings` Arc so note_finding
|
|
|
|
|
// calls within any branch are visible to all other branches.
|
|
|
|
|
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
|
|
|
|
let results: Arc<Mutex<Vec<ParallelResult>>> =
|
|
|
|
|
Arc::new(Mutex::new(Vec::new()));
|
|
|
|
@@ -227,6 +265,7 @@ pub fn execute_primitive(
|
|
|
|
|
let live_clone = live.cloned();
|
|
|
|
|
let session_dir = session_dir.to_path_buf();
|
|
|
|
|
let workspaces = workspaces.to_vec();
|
|
|
|
|
let findings = Arc::clone(findings);
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let _permit = sem.acquire();
|
|
|
|
@@ -235,6 +274,7 @@ pub fn execute_primitive(
|
|
|
|
|
live_clone.as_ref(),
|
|
|
|
|
&session_dir,
|
|
|
|
|
&workspaces,
|
|
|
|
|
&findings,
|
|
|
|
|
);
|
|
|
|
|
if let Ok(mut locked) = results.lock() {
|
|
|
|
|
locked.push((idx, result));
|
|
|
|
@@ -264,11 +304,11 @@ pub fn execute_primitive(
|
|
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
// written by stage N are visible to stage N+1 through the shared
|
|
|
|
|
// `findings` Arc (same isolation scope as parent).
|
|
|
|
|
let mut all = Vec::new();
|
|
|
|
|
for (idx, script) in scripts.iter().enumerate() {
|
|
|
|
|
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces) {
|
|
|
|
|
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings) {
|
|
|
|
|
Ok(outputs) => all.extend(outputs),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if continue_on_error {
|
|
|
|
@@ -283,7 +323,7 @@ pub fn execute_primitive(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ScriptPrimitive::Phase { name: _name, script } => {
|
|
|
|
|
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces)
|
|
|
|
|
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -304,8 +344,13 @@ pub fn run_workflow(
|
|
|
|
|
/// 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.
|
|
|
|
|
/// Flow: create an empty findings Arc (scoped to this invocation) → cap
|
|
|
|
|
/// concurrency to 8 → call `execute_primitive` with the live callback and
|
|
|
|
|
/// findings → format results.
|
|
|
|
|
///
|
|
|
|
|
/// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a
|
|
|
|
|
/// global static, so concurrent `run_workflow_tracked` calls from different
|
|
|
|
|
/// spawn_agents invocations remain fully isolated.
|
|
|
|
|
///
|
|
|
|
|
/// Return: a human-readable summary string.
|
|
|
|
|
pub fn run_workflow_tracked(
|
|
|
|
@@ -315,10 +360,6 @@ pub fn run_workflow_tracked(
|
|
|
|
|
session_dir: &std::path::Path,
|
|
|
|
|
workspaces: &[std::path::PathBuf],
|
|
|
|
|
) -> 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(10) // allow up to 10 parallel agents
|
|
|
|
|
} else {
|
|
|
|
@@ -326,10 +367,11 @@ pub fn run_workflow_tracked(
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let live_ref = live.as_ref();
|
|
|
|
|
let findings = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
|
let results = execute_primitive(
|
|
|
|
|
&script.script, args, concurrency_cap,
|
|
|
|
|
script.options.continue_on_error, live_ref,
|
|
|
|
|
session_dir, workspaces,
|
|
|
|
|
session_dir, workspaces, &findings,
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
let summary = if results.is_empty() {
|
|
|
|
@@ -351,14 +393,6 @@ pub fn run_workflow_tracked(
|
|
|
|
|
Ok(summary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Add a finding text to the global workflow findings list, making it
|
|
|
|
|
/// visible to sibling agents spawned later in the same run.
|
|
|
|
|
pub fn note_finding(text: &str) {
|
|
|
|
|
if let Ok(mut findings) = FINDINGS.lock() {
|
|
|
|
|
findings.push(text.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Simple template engine: replace `{{key}}` placeholders with values
|
|
|
|
|
/// from `args`.
|
|
|
|
|
///
|
|
|
|
|