feat: implement workflow findings sharing for inter-agent communication in workflows

This commit is contained in:
asepharyana
2026-07-13 03:12:37 +07:00
parent dc0f1c7647
commit a080957c26
11 changed files with 154 additions and 65 deletions
+13 -1
View File
@@ -38,7 +38,7 @@ pub struct GraduatedCheck {
}
/// Shared execution context passed to every `Tool::run` call: workspace roots, session
/// paths, and cached directory state.
/// paths, cached directory state, and workflow-level findings sharing.
#[derive(Clone)]
pub struct ToolCtx {
pub workspaces: Vec<PathBuf>,
@@ -51,6 +51,12 @@ pub struct ToolCtx {
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
pub turn_events: Option<Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
/// Ephemeral findings shared between sibling subagents in a workflow run.
/// Set by the workflow engine before spawning subagents; tools like
/// `note_finding` write into this vec so later pipeline stages can
/// reference earlier results. `None` means "not inside a workflow" —
/// `note_finding` becomes a no-op.
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
}
/// Find which graduated checks apply to a given file path/content pair.
@@ -88,6 +94,7 @@ pub struct ToolCtxBuilder {
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
pub turn_events: Option<Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
}
impl Default for ToolCtxBuilder {
@@ -103,6 +110,7 @@ impl Default for ToolCtxBuilder {
graduated_checks: Vec::new(),
lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())),
turn_events: None,
workflow_findings: None,
}
}
}
@@ -117,6 +125,9 @@ impl ToolCtxBuilder {
/// Set the lsp_manager.
#[allow(dead_code)]
pub fn lsp_manager(mut self, v: Arc<Mutex<crate::app::lsp::LspManager>>) -> Self { self.lsp_manager = v; self }
/// Set the workflow-level findings sharing Arc (for subagent-to-subagent
/// communication within a workflow run).
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> Self { self.workflow_findings = v; self }
/// Consume the builder and produce the final `ToolCtx`.
pub fn build(self) -> ToolCtx {
ToolCtx {
@@ -130,6 +141,7 @@ impl ToolCtxBuilder {
graduated_checks: self.graduated_checks,
lsp_manager: self.lsp_manager,
turn_events: self.turn_events,
workflow_findings: self.workflow_findings,
}
}
}
+11 -2
View File
@@ -88,7 +88,7 @@ impl Tool for SpawnAgents {
},
};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
let turn_events = turn_events.clone();
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, status| {
@@ -104,6 +104,10 @@ impl Tool for SpawnAgents {
f
});
// Create a per-invocation findings scope so subagents spawned
// by this tool call are isolated from any other concurrent
// spawn_agents or workflow_run invocations.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = crate::app::workflow::engine::execute_primitive(
&wf.script,
&HashMap::new(),
@@ -112,6 +116,7 @@ impl Tool for SpawnAgents {
live.as_ref(),
&_ctx.session_dir,
&_ctx.workspaces,
&findings,
)?;
format_results(results, "parallel")
}
@@ -173,7 +178,7 @@ impl Tool for SpawnPipeline {
},
};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
let turn_events = turn_events.clone();
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, status| {
@@ -189,6 +194,9 @@ impl Tool for SpawnPipeline {
f
});
// Per-invocation findings scope isolates this pipeline from any
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = crate::app::workflow::engine::execute_primitive(
&wf.script,
&HashMap::new(),
@@ -197,6 +205,7 @@ impl Tool for SpawnPipeline {
live.as_ref(),
&_ctx.session_dir,
&_ctx.workspaces,
&findings,
)?;
format_results(results, "pipeline")
}
+18 -7
View File
@@ -109,21 +109,32 @@ impl Tool for NoteFinding {
/// Record `text` as a finding visible to sibling agents in the run.
///
/// Flow: extract `text` argument → forward to
/// `app::workflow::engine::note_finding` → return a truncated
/// confirmation echo.
/// 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 ephemeral (not persisted to memory) and are
/// meant to be prepended to sibling agents' next tool-round context.
/// 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> {
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"))?;
crate::app::workflow::engine::note_finding(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>()))
}
}