Files
zesdex/src/tool/workflow.rs
T

141 lines
5.6 KiB
Rust

//! 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<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.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
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<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();
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<String> {
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::<String>(),
);
}
Ok(format!("finding recorded: {}", text.chars().take(80).collect::<String>()))
}
}