feat: wired together graph execution and agent graph dispatch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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(¤t).ok_or_else(|| {
|
||||
anyhow!("Node '{}' not found in graph '{}'", current, graph.name)
|
||||
})?;
|
||||
let node = graph
|
||||
.get_node(¤t)
|
||||
.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,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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user