//! 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. use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; /// Tool that parses and executes a JSON-encoded workflow script (Agent/Parallel/Pipeline/Phase). pub struct WorkflowRun; impl Tool for WorkflowRun { fn name(&self) -> &'static str { "workflow_run" } fn description(&self) -> &'static str { "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." } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "script": { "type": "string", "description": "JSON-encoded workflow script with name, description, script (Agent/Parallel/Pipeline/Phase primitives), and options (max_concurrency, continue_on_error)" }, "args": { "type": "object", "description": "Optional string key-value arguments passed to the workflow script for template substitution ({{key}} placeholders)" } }, "required": ["script"] }) } /// Parse the `script`/`args` tool arguments and execute the workflow. /// /// Flow: extract `script` string → deserialize into `WorkflowScript` → /// collect optional `args` object into a `HashMap` 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. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let script_str = args.get("script") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: script"))?; 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 = 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(); crate::app::workflow::engine::run_workflow( &workflow_script, &workflow_args, &_ctx.session_dir, &_ctx.workspaces, ) } } /// Tool that shares a text finding with sibling agents in the current workflow run. 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"] }) } /// Record `text` as a finding visible to sibling agents in the run. /// /// Flow: extract `text` argument → push into /// `ctx.workflow_findings` (the per-invocation Arc threaded through /// `execute_primitive`) → return a truncated confirmation echo. /// /// 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. /// /// Return: confirmation string containing up to the first 80 chars /// of the recorded text. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let text = args.get("text") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: text"))?; 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::(), ); } Ok(format!("finding recorded: {}", text.chars().take(80).collect::())) } }