feat: wired together graph execution and agent graph dispatch

This commit is contained in:
2026-05-14 11:10:45 -06:00
parent e0b85fc936
commit 01912bcef3
11 changed files with 270 additions and 12 deletions
+24
View File
@@ -9,6 +9,7 @@ use super::state::StateManager;
use super::types::AgentNode;
use crate::config::RequestContext;
use crate::function::supervisor::run_agent_for_graph;
use crate::utils::dimmed_text;
use anyhow::{Context, Result};
use serde_json::Value;
use std::time::Duration;
@@ -31,6 +32,14 @@ impl AgentNodeExecutor {
.interpolate(&node.prompt)
.with_context(|| format!("Failed to interpolate prompt for agent '{}'", node.agent))?;
eprintln!(
"{}",
dimmed_text(&format!("▸ spawning agent '{}' with prompt:", node.agent))
);
for line in indent_prompt(&prompt, 6) {
eprintln!("{}", dimmed_text(&line));
}
let timeout_dur = Duration::from_secs(node.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS));
let output = timeout(
@@ -53,6 +62,21 @@ impl AgentNodeExecutor {
}
}
fn indent_prompt(prompt: &str, prefix_spaces: usize) -> Vec<String> {
const MAX_LINES: usize = 12;
let pad = " ".repeat(prefix_spaces);
let mut out: Vec<String> = prompt
.lines()
.take(MAX_LINES)
.map(|line| format!("{pad}{line}"))
.collect();
let total = prompt.lines().count();
if total > MAX_LINES {
out.push(format!("{pad}... ({} more lines)", total - MAX_LINES));
}
out
}
/// Exposes the agent's output as `{{output}}` for template evaluation, then
/// applies every key/template in `state_updates`. The temporary `output`
/// state key is removed at the end so it doesn't leak into subsequent
+59
View File
@@ -0,0 +1,59 @@
//! Helpers for running the active agent through its `graph.yaml` instead
//! of the LLM loop. Used at every agent-execution entry point: top-level
//! CLI (`start_directive`), REPL (`ask`), and child-agent spawn
//! (`run_child_agent`).
use super::{GraphExecutor, GraphParser, agent_has_graph};
use crate::config::RequestContext;
use crate::config::paths;
use crate::utils::AbortSignal;
use anyhow::{Context, Result};
use serde_json::Value;
/// If the active agent owns a `graph.yaml`, returns its name. Lets
/// callers branch between graph and LLM-loop execution without
/// re-implementing the lookup.
pub fn active_agent_graph_name(ctx: &RequestContext) -> Option<String> {
let name = ctx.agent.as_ref()?.name().to_string();
agent_has_graph(&name).then_some(name)
}
/// Run the active agent's graph end-to-end and return the resolved
/// End-node output. The caller's prompt is seeded into the graph state
/// as `initial_prompt` so nodes can reference it via
/// `{{initial_prompt}}`. Any sub-agents the graph spawned via the
/// supervisor are cancelled on return.
pub async fn run_active_agent_graph(
ctx: &mut RequestContext,
prompt: &str,
abort_signal: AbortSignal,
) -> Result<String> {
let agent_name = active_agent_graph_name(ctx)
.ok_or_else(|| anyhow::anyhow!("Active agent has no graph.yaml"))?;
log::info!("Agent '{agent_name}' has graph.yaml; routing to graph executor");
let agent_dir = paths::agent_data_dir(&agent_name);
let graph_path = paths::agent_graph_path(&agent_name);
let parser = GraphParser::new(&agent_dir);
let mut graph = parser
.load_from_file(&graph_path)
.with_context(|| format!("Failed to load graph.yaml for agent '{agent_name}'"))?;
graph
.initial_state
.insert("initial_prompt".into(), Value::String(prompt.to_string()));
let executor = GraphExecutor::new(graph, agent_dir);
let output = executor
.execute(ctx, abort_signal)
.await
.with_context(|| format!("Graph execution failed for agent '{agent_name}'"))?;
if let Some(supervisor) = ctx.supervisor.clone() {
supervisor.read().cancel_all();
}
Ok(output)
}
+31 -8
View File
@@ -14,8 +14,8 @@ use super::types::{EndNode, Graph, Node, NodeType};
use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
use super::validator::GraphValidator;
use crate::config::RequestContext;
use crate::utils::AbortSignal;
use anyhow::{Context, Result, bail, anyhow};
use crate::utils::{AbortSignal, dimmed_text};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -74,6 +74,10 @@ impl GraphExecutor {
let mut current = graph.start.clone();
info!("[graph:{}] start at '{}'", graph.name, current);
eprintln!(
"{}",
dimmed_text(&format!("▸ graph: {} (start: {})", graph.name, current))
);
let output = loop {
if abort_signal.aborted() {
@@ -102,14 +106,18 @@ impl GraphExecutor {
);
}
let node = graph.get_node(&current).ok_or_else(|| {
anyhow!("Node '{}' not found in graph '{}'", current, graph.name)
})?;
let node = graph
.get_node(&current)
.ok_or_else(|| anyhow!("Node '{}' not found in graph '{}'", current, graph.name))?;
debug!(
"[graph:{}] entering '{}' (visit {})",
graph.name, current, visits
);
eprintln!(
"{}",
dimmed_text(&format!("{} ({})", current, node_type_label(node)))
);
let next = step(
node,
@@ -134,6 +142,13 @@ impl GraphExecutor {
current,
start.elapsed()
);
eprintln!(
"{}",
dimmed_text(&format!(
"▸ graph done in {:.2}s",
start.elapsed().as_secs_f64()
))
);
break out;
}
}
@@ -148,6 +163,16 @@ enum StepResult {
End(String),
}
fn node_type_label(node: &Node) -> &'static str {
match &node.node_type {
NodeType::Agent(_) => "agent",
NodeType::Script(_) => "script",
NodeType::Approval(_) => "approval",
NodeType::Input(_) => "input",
NodeType::End(_) => "end",
}
}
async fn step(
node: &Node,
state: &mut StateManager,
@@ -179,9 +204,7 @@ async fn step(
}
};
let next = dynamic.or_else(|| node.next.clone()).ok_or_else(|| {
anyhow!(
"script node '{current}' did not emit `_next` and has no static `next`"
)
anyhow!("script node '{current}' did not emit `_next` and has no static `next`")
})?;
Ok(StepResult::Continue(next))
}
+2
View File
@@ -2,6 +2,7 @@
//! JSON state, composed of agent/script/approval/input/end nodes.
pub mod agent;
pub mod dispatch;
pub mod executor;
pub mod parser;
pub mod script;
@@ -11,6 +12,7 @@ pub mod user_interaction;
pub mod validator;
pub use agent::AgentNodeExecutor;
pub use dispatch::{active_agent_graph_name, run_active_agent_graph};
pub use executor::GraphExecutor;
pub use parser::{GraphParser, agent_has_graph, load_agent_graph};
pub use script::ScriptExecutor;
+23
View File
@@ -9,6 +9,7 @@
use super::state::{StateManager, StateRepresentation};
use super::types::ScriptNode;
use crate::function::Language;
use crate::utils::dimmed_text;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use std::path::{Path, PathBuf};
@@ -45,6 +46,11 @@ impl ScriptExecutor {
bail!("Script file not found: '{}'", script_path.display());
}
eprintln!(
"{}",
dimmed_text(&format!("▸ running script '{}'", node.script))
);
let language = detect_language(&script_path)?;
let state_repr = state_manager.serialize_state()?;
@@ -106,6 +112,23 @@ impl ScriptExecutor {
)
})?;
if let Ok(parsed) = serde_json::from_str::<serde_json::Map<String, Value>>(json_output) {
let keys: Vec<&str> = parsed
.keys()
.filter(|k| k.as_str() != "_next")
.map(|s| s.as_str())
.collect();
if !keys.is_empty() {
eprintln!(
"{}",
dimmed_text(&format!("▸ merged: {}", keys.join(", ")))
);
}
if let Some(n) = &next {
eprintln!("{}", dimmed_text(&format!("▸ script set _next = '{n}'")));
}
}
apply_state_updates(node, state_manager);
Ok(next)
+3 -2
View File
@@ -118,8 +118,9 @@ pub enum NodeType {
/// `agent`-type node: spawn an agent with a templated prompt. The agent
/// uses the full tool stack from its own directory (`global_tools` and
/// `mcp_servers` in `config.yaml` plus any per-agent `tools.{sh,py,ts,js}`
/// script). There is no per-node tool override; to use different tool
/// sets, create agent variants. See `docs/graph-agents/agent-tools.md`.
/// script); there is no per-node tool override here. For tool-filtered
/// one-shot LLM steps, use an `llm`-type node (future). To use different
/// tool sets via agent variants, see `docs/graph-agents/agent-tools.md`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentNode {
pub agent: String,