use std::path::PathBuf;
use std::sync::Arc;
use super::engine::{DagEngine, NodeLauncher, NodeOutcome};
use theway_core::{AgentTool, StreamFn};
use theway_llm_provider::Model;
use tokio_util::sync::CancellationToken;
use crate::multiagent::jobs::SubagentJobRegistry;
use crate::multiagent::runner::{AgentRunOptions, filter_tool_set, resolve_run_model, run_agent};
use crate::multiagent::types::{AgentRunParams, AgentRunResolver, ToolSetResolver};
struct NodeJob {
engine: DagEngine,
run_id: String,
node_id: String,
launch: AgentRunParams,
tools: Vec<Arc<dyn AgentTool>>,
model: Option<Model>,
stream_fn: Option<StreamFn>,
task_text: String,
thinking: Option<String>,
timeout: Option<u64>,
attempt: u32,
launch_gen: u64,
registry: SubagentJobRegistry,
}
pub struct NodeLauncherImpl {
engine: Arc<DagEngine>,
model: Option<Model>,
stream_fn: Option<StreamFn>,
cwd: PathBuf,
registry: SubagentJobRegistry,
tools_resolver: ToolSetResolver,
launch_resolver: AgentRunResolver,
}
impl NodeLauncher for NodeLauncherImpl {
fn launch(&self, run_id: &str, node_id: &str, cancel: CancellationToken) {
let Some(run) = self.engine.get_run(run_id) else {
return;
};
let Some(node) = run.node(node_id) else {
return;
};
let Some(mut launch) = (self.launch_resolver)(&node.agent) else {
self.engine.on_node_completed(
run_id,
node_id,
NodeOutcome {
success: false,
error: Some(format!("unknown agent \"{}\"", node.agent)),
duration_ms: 0,
attempt: 0,
total_attempts: 0,
input_tokens: 0,
output_tokens: 0,
output: None,
},
);
return;
};
if let Some(n) = node.max_iterations {
launch.max_iterations = n;
}
let tools = (self.tools_resolver)(&node.agent);
let tools = match node.tools.as_deref() {
None => tools,
Some(allow) => match filter_tool_set(tools, allow) {
Ok(filtered) => filtered,
Err(err) => {
self.engine.on_node_completed(
run_id,
node_id,
NodeOutcome {
success: false,
error: Some(err),
duration_ms: 0,
attempt: 0,
total_attempts: 0,
input_tokens: 0,
output_tokens: 0,
output: None,
},
);
return;
}
},
};
if cancel.is_cancelled() {
return;
}
tracing::debug!(
run_id,
node_id,
agent = launch.name,
description = launch.description,
max_iterations = launch.max_iterations,
cwd = %self.cwd.display(),
"launching DAG node subagent"
);
let thinking = match node.thinking.as_deref() {
Some(raw) => match raw.parse::<theway_core::ThinkingLevel>() {
Ok(_) => Some(raw.to_string()),
Err(_) => {
self.engine.on_node_completed(
run_id,
node_id,
NodeOutcome {
success: false,
error: Some(format!(
"invalid thinking level: {raw} (allowed: off, minimal, low, medium, high, xhigh, max)"
)),
duration_ms: 0,
attempt: 0,
total_attempts: 0,
input_tokens: 0,
output_tokens: 0,
output: None,
},
);
return;
}
},
None => None,
};
let model = match resolve_run_model(
self.model.as_ref(),
node.provider.as_deref(),
node.model.as_deref(),
) {
Ok(model) => model,
Err(err) => {
self.engine.on_node_completed(
run_id,
node_id,
NodeOutcome {
success: false,
error: Some(err),
duration_ms: 0,
attempt: 0,
total_attempts: 0,
input_tokens: 0,
output_tokens: 0,
output: None,
},
);
return;
}
};
let job = NodeJob {
engine: self.engine.as_ref().clone(),
run_id: run_id.to_string(),
node_id: node_id.to_string(),
launch,
tools,
model,
stream_fn: self.stream_fn.clone(),
task_text: node.task.clone(),
thinking,
timeout: node.timeout,
attempt: node.attempt.saturating_add(1),
launch_gen: node.launch_gen,
registry: self.registry.clone(),
};
tokio::spawn(run_node(job, cancel));
}
}
pub fn node_launcher(
engine: Arc<DagEngine>,
model: impl Into<Option<Model>>,
stream_fn: Option<StreamFn>,
cwd: PathBuf,
registry: SubagentJobRegistry,
tools_resolver: ToolSetResolver,
launch_resolver: AgentRunResolver,
) -> Arc<NodeLauncherImpl> {
Arc::new(NodeLauncherImpl {
engine,
model: model.into(),
stream_fn,
cwd,
registry,
tools_resolver,
launch_resolver,
})
}
async fn run_node(job: NodeJob, cancel: CancellationToken) {
let engine = job.engine.clone();
let run_id = job.run_id.clone();
let node_id = job.node_id.clone();
let attempt = job.attempt;
let launch_gen = job.launch_gen;
let session_id = engine.get_run(&run_id).and_then(|r| r.session_id);
let observation_parent = engine.node_operation_id(&run_id, &node_id);
let engine_cb = engine.clone();
let run_id_cb = run_id.clone();
let node_id_cb = node_id.clone();
let Some(node_model) = job.model else {
engine.on_node_completed(
&run_id,
&node_id,
NodeOutcome {
success: false,
error: Some(
"no model set for this session; select a model in the TUI before launching DAG nodes (or set provider + model on the node)"
.to_string(),
),
duration_ms: 0,
attempt,
total_attempts: attempt,
input_tokens: 0,
output_tokens: 0,
output: None,
},
);
let _ = (engine_cb, run_id_cb, node_id_cb);
return;
};
let result = run_agent(AgentRunOptions {
launch: job.launch,
tools: job.tools,
prompt: job.task_text,
model: node_model,
stream_fn: job.stream_fn,
timeout: job.timeout,
thinking: job.thinking,
registry: job.registry,
source: "dag".into(),
run_id: Some(run_id.clone()),
node_id: Some(node_id.clone()),
session_id,
observation_parent,
cancel: cancel.clone(),
system_prompt_extra: None,
on_turn_end: Some(Arc::new(move |text, input, output| {
engine_cb.on_node_update(
&run_id_cb,
&node_id_cb,
launch_gen,
Some(input),
Some(output),
Some(text.to_string()),
);
})),
})
.await;
if cancel.is_cancelled() {
return;
}
let output = if result.text.is_empty() {
None
} else {
Some(cap_chars(&result.text, MAX_OUTPUT_CHARS))
};
engine.on_node_update(
&run_id,
&node_id,
launch_gen,
Some(result.input_tokens),
Some(result.output_tokens),
output.clone(),
);
engine.on_node_completed(
&run_id,
&node_id,
NodeOutcome {
success: result.success,
error: result.error,
duration_ms: result.duration_ms,
attempt,
total_attempts: attempt,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
output,
},
);
}
const MAX_OUTPUT_CHARS: usize = 8 * 1024;
fn cap_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_string()
} else {
text.chars().take(max).collect()
}
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("multiagent/graph/node_launcher");