2026-07-12 11:28:39 +07:00
//! Tools for orchestrating multi-agent workflow runs.
//!
//! Flow: the LLM emits a `workflow_run` tool call with a JSON-encoded
//! `WorkflowScript` (Agent/Parallel/Pipeline/Phase primitives) which is
//! deserialized and handed to `app::workflow::engine::run_workflow` for
//! execution. Sibling agents spawned within the same run can share
//! ephemeral text via the `note_finding` tool, which forwards to
//! `app::workflow::engine::note_finding`.
//!
//! Why: decomposing a task into a workflow script lets the harness fan
//! out independent subtasks (parallel/pipeline/phased) instead of the
//! agent handling everything inline; simple tasks should skip this tool
//! entirely per its own description string.
2026-07-11 13:16:10 +07:00
use serde_json ::{ json , Value };
use anyhow ::{ Result , anyhow };
use super ::Tool ;
use super ::ToolCtx ;
2026-07-12 11:28:39 +07:00
/// Tool that parses and executes a JSON-encoded workflow script (Agent/Parallel/Pipeline/Phase).
2026-07-11 13:16:10 +07:00
pub struct WorkflowRun ;
impl Tool for WorkflowRun {
fn name ( & self ) -> & 'static str {
"workflow_run"
}
fn description ( & self ) -> & 'static str {
2026-07-11 23:45:13 +07:00
"Execute a workflow script that can spawn multiple subagents in parallel, pipeline, or phased stages. Use when a task benefits from decomposition into independent subtasks. Simple tasks should be handled inline without this tool."
2026-07-11 13:16:10 +07:00
}
fn parameters ( & self ) -> Value {
json! ({
"type" : "object" ,
"properties" : {
"script" : {
"type" : "string" ,
2026-07-11 23:45:13 +07:00
"description" : "JSON-encoded workflow script with name, description, script (Agent/Parallel/Pipeline/Phase primitives), and options (max_concurrency, continue_on_error)"
2026-07-11 13:16:10 +07:00
},
"args" : {
"type" : "object" ,
2026-07-11 23:45:13 +07:00
"description" : "Optional string key-value arguments passed to the workflow script for template substitution ({{key}} placeholders)"
2026-07-11 13:16:10 +07:00
}
},
"required" : [ "script" ]
})
}
2026-07-12 11:28:39 +07:00
/// Parse the `script`/`args` tool arguments and execute the workflow.
///
/// Flow: extract `script` string → deserialize into `WorkflowScript` →
/// collect optional `args` object into a `HashMap<String, String>` for
/// `{{key}}` template substitution → delegate to
/// `app::workflow::engine::run_workflow`.
///
/// Why: template args are silently filtered to string values only
/// (non-string values are dropped rather than erroring).
///
/// Return: the workflow engine's output string, or an error if the
/// script argument is missing or fails to parse as JSON.
2026-07-11 13:16:10 +07:00
fn run ( & self , _ctx : & ToolCtx , args : & Value ) -> Result < String > {
2026-07-11 20:21:59 +07:00
let script_str = args . get ( "script" )
2026-07-11 13:16:10 +07:00
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow! ( "missing required argument: script" )) ? ;
2026-07-11 20:21:59 +07:00
let workflow_script : crate ::app ::workflow ::script ::WorkflowScript =
serde_json ::from_str ( script_str )
. map_err ( | e | anyhow! ( "failed to parse workflow script: {}" , e )) ? ;
let workflow_args : std ::collections ::HashMap < String , String > = args . get ( "args" )
. and_then ( | v | v . as_object ())
. map ( | obj | {
obj . iter (). filter_map ( | ( k , v ) | {
v . as_str (). map ( | s | ( k . clone (), s . to_string ()))
}). collect ()
})
. unwrap_or_default ();
2026-07-12 18:09:03 +07:00
crate ::app ::workflow ::engine ::run_workflow (
& workflow_script , & workflow_args , & _ctx . session_dir , & _ctx . workspaces ,
)
2026-07-11 13:16:10 +07:00
}
}
2026-07-11 23:45:13 +07:00
2026-07-12 11:28:39 +07:00
/// Tool that shares a text finding with sibling agents in the current workflow run.
2026-07-11 23:45:13 +07:00
pub struct NoteFinding ;
impl Tool for NoteFinding {
fn name ( & self ) -> & 'static str {
"note_finding"
}
fn description ( & self ) -> & 'static str {
"Share a finding with sibling agents in the same workflow_run. Findings are ephemeral to the current run and will be prepended to other agents' next tool-round context. Does not persist to memory."
}
fn parameters ( & self ) -> Value {
json! ({
"type" : "object" ,
"properties" : {
"text" : {
"type" : "string" ,
"description" : "The finding to share with sibling agents"
}
},
"required" : [ "text" ]
})
}
2026-07-12 11:28:39 +07:00
/// Record `text` as a finding visible to sibling agents in the run.
///
2026-07-13 03:12:37 +07:00
/// Flow: extract `text` argument → push into
/// `ctx.workflow_findings` (the per-invocation Arc threaded through
/// `execute_primitive`) → return a truncated confirmation echo.
2026-07-12 11:28:39 +07:00
///
2026-07-13 03:12:37 +07:00
/// Why: findings are scoped per workflow invocation, not global,
/// so concurrent workflow runs are isolated from each other.
/// If no workflow findings Arc is set (called outside a workflow),
/// the call is silently ignored.
2026-07-12 11:28:39 +07:00
///
/// Return: confirmation string containing up to the first 80 chars
/// of the recorded text.
2026-07-13 03:12:37 +07:00
fn run ( & self , ctx : & ToolCtx , args : & Value ) -> Result < String > {
2026-07-11 23:45:13 +07:00
let text = args . get ( "text" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow! ( "missing required argument: text" )) ? ;
2026-07-13 03:12:37 +07:00
if let Some ( ref findings ) = ctx . workflow_findings {
if let Ok ( mut f ) = findings . lock () {
f . push ( text . to_string ());
}
} else {
tracing ::debug! (
"[note_finding] called outside a workflow run — discarding: {}" ,
text . chars (). take ( 80 ). collect ::< String > (),
);
}
2026-07-11 23:45:13 +07:00
Ok ( format! ( "finding recorded: {} " , text . chars (). take ( 80 ). collect ::< String > ()))
}
}