- Updated README.md to reflect the addition of 3 new built-in tools, bringing the total to 37. - Revised architecture documentation to indicate the increase in tool count. - Enhanced backend documentation with updated line counts for various modules. - Modified data documentation to change edit log format from JSON to JSONL. - Updated dependencies documentation to reflect version upgrades for several crates. - Improved prompts for auto-reviewer, division implementer, planner, tester, and quality reviewer to enforce stricter coding standards regarding linter bypasses. - Refactored code in various modules to improve clarity and performance, including updates to error handling and tool execution logic. - Added comprehensive tests for IPC frame serialization and deserialization.
284 lines
11 KiB
Rust
284 lines
11 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>()))
|
|
}
|
|
}
|
|
|
|
/// Tool that delegates work to the company-style division pipeline.
|
|
///
|
|
/// The main agent (CEO) calls this tool to pass a user request through the
|
|
/// full company organization: Strategy → Engineering → Quality → Security
|
|
/// → Documentation. Returns an executive summary.
|
|
///
|
|
/// Use this for any complex or multi-step task. For simple tasks, handle
|
|
/// inline or use the quick variant.
|
|
pub struct CompanyPipeline;
|
|
|
|
impl Tool for CompanyPipeline {
|
|
fn name(&self) -> &'static str {
|
|
"company_pipeline"
|
|
}
|
|
|
|
fn description(&self) -> &'static str {
|
|
"Delegate a task to the full company division pipeline: Strategy (plan+diagrams) → Engineering (implement) → Quality (review+test) → Security (audit) → Documentation (docs). Use this for ALL non-trivial tasks instead of doing them yourself. The pipeline returns an executive summary."
|
|
}
|
|
|
|
fn parameters(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"request": {
|
|
"type": "string",
|
|
"description": "The task description to delegate to the company pipeline"
|
|
},
|
|
"mode": {
|
|
"type": "string",
|
|
"enum": ["full", "quick"],
|
|
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks",
|
|
"default": "full"
|
|
},
|
|
"specialists": {
|
|
"type": "object",
|
|
"description": "Mapping from division name (Strategy, Engineering, Quality, Security, Documentation) to list of custom specialists. Each specialist is defined by a pair of [label, focus_description]. This parameter is mandatory. The CEO/main agent must fully define the specialized roles and focuses for every division in the pipeline to run.",
|
|
"additionalProperties": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"minItems": 2,
|
|
"maxItems": 2
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"required": ["request", "specialists"]
|
|
})
|
|
}
|
|
|
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
|
let request = args.get("request")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow!("missing required argument: request"))?;
|
|
|
|
let mode = args.get("mode")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("full");
|
|
|
|
let custom_specialists: std::collections::HashMap<String, Vec<(String, String)>> = args.get("specialists")
|
|
.and_then(|v| v.as_object())
|
|
.map(|obj| {
|
|
obj.iter().map(|(k, v)| {
|
|
let specs = v.as_array().map(|arr| {
|
|
arr.iter().filter_map(|item| {
|
|
let pair = item.as_array()?;
|
|
let label = pair.first()?.as_str()?.to_string();
|
|
let focus = pair.get(1)?.as_str()?.to_string();
|
|
Some((label, focus))
|
|
}).collect()
|
|
}).unwrap_or_default();
|
|
(k.clone(), specs)
|
|
}).collect()
|
|
})
|
|
.ok_or_else(|| anyhow!("missing required argument: specialists"))?;
|
|
|
|
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
|
match mode {
|
|
"quick" => {
|
|
crate::app::workflow::company::run_company_pipeline_quick(
|
|
request,
|
|
&ctx.session_dir,
|
|
&ctx.workspaces,
|
|
ctx.turn_events.as_ref(),
|
|
&no_abort,
|
|
&custom_specialists,
|
|
)
|
|
}
|
|
_ => {
|
|
crate::app::workflow::company::run_company_pipeline(
|
|
request,
|
|
&ctx.session_dir,
|
|
&ctx.workspaces,
|
|
ctx.turn_events.as_ref(),
|
|
&no_abort,
|
|
&custom_specialists,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tool that retrieves all findings shared by sibling agents in the current workflow run.
|
|
pub struct ReadFindings;
|
|
|
|
impl Tool for ReadFindings {
|
|
fn name(&self) -> &'static str {
|
|
"read_findings"
|
|
}
|
|
|
|
fn description(&self) -> &'static str {
|
|
"Retrieve all findings shared by sibling agents in the current workflow run. Use this to get real-time context updates from other divisions/subagents working in parallel."
|
|
}
|
|
|
|
fn parameters(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
})
|
|
}
|
|
|
|
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
|
if let Some(ref findings) = ctx.workflow_findings {
|
|
let f = findings.lock().map_err(|e| anyhow!("poisoned lock: {e}"))?;
|
|
if f.is_empty() {
|
|
Ok("No findings recorded yet in this workflow run.".to_string())
|
|
} else {
|
|
let formatted = f
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
Ok(format!("Findings in this workflow run:\n{formatted}"))
|
|
}
|
|
} else {
|
|
Ok("No findings database available (called outside a workflow run).".to_string())
|
|
}
|
|
}
|
|
}
|
|
|