use async_trait::async_trait;
use lellm_graph::{FlowNode, Graph, GraphError, NodeContext, StateMutation, TerminalError};
use crate::hook::{AgentHook, AgentHookContext, AgentHookSnapshot};
use crate::runtime::{
AgentEvent, StopReason, ToolUseLoop, ToolUseResult, TranslationResult, translate_provider_event,
};
#[derive(Clone)]
pub struct AgentFlowNode {
name: String,
loop_: ToolUseLoop,
message_key: String,
use_react_graph: bool,
hooks: Vec<std::sync::Arc<dyn AgentHook>>,
}
impl AgentFlowNode {
pub fn new(name: impl Into<String>, loop_: ToolUseLoop) -> Self {
Self {
name: name.into(),
loop_,
message_key: "messages".to_string(),
use_react_graph: false,
hooks: Vec::new(),
}
}
pub fn message_key(mut self, key: impl Into<String>) -> Self {
self.message_key = key.into();
self
}
pub fn use_react_graph(mut self, enabled: bool) -> Self {
self.use_react_graph = enabled;
self
}
pub fn hook(mut self, hook: impl AgentHook + 'static) -> Self {
self.hooks.push(std::sync::Arc::new(hook));
self
}
fn extract_messages(&self, ctx: &NodeContext<'_>) -> Vec<lellm_core::Message> {
if let Some(value) = ctx.state().get(&self.message_key) {
if let Some(arr) = value.as_array() {
let mut messages = Vec::new();
for v in arr {
if let Ok(msg) = serde_json::from_value::<lellm_core::Message>(v.clone()) {
messages.push(msg);
}
}
messages
} else if let Ok(msg) = serde_json::from_value::<lellm_core::Message>(value.clone()) {
vec![msg]
} else {
Vec::new()
}
} else {
Vec::new()
}
}
fn apply_result(&self, ctx: &mut NodeContext<'_>, result: &ToolUseResult) {
let messages: Vec<serde_json::Value> = result
.messages
.iter()
.filter_map(|m| serde_json::to_value(m).ok())
.collect();
ctx.record(StateMutation::Put(
self.message_key.clone(),
serde_json::json!(messages),
));
ctx.record(StateMutation::Put(
format!("{}_stop_reason", self.name),
serde_json::json!(format!("{:?}", result.stop_reason)),
));
ctx.record(StateMutation::Put(
format!("{}_iterations", self.name),
serde_json::json!(result.iterations as u64),
));
ctx.record(StateMutation::Put(
format!("{}_tool_calls", self.name),
serde_json::json!(result.tool_calls_executed as u64),
));
}
fn build_react_graph(
&self,
) -> Graph<super::typed_state::AgentState, super::typed_state::AgentStateMerge> {
let config = self.loop_.config().clone();
let executor = self.loop_.executor().clone();
let invoker = std::sync::Arc::new(crate::runtime::invoker::LlmInvoker::from_config(
self.loop_.model().clone(),
self.loop_.config(),
self.loop_.deps().fallback.clone(),
));
let llm_node = crate::runtime::react::LLMNode::new(
format!("{}_llm", self.name),
invoker,
executor.clone(),
config.clone(),
);
let tool_node = crate::runtime::react::ToolNode::new(
format!("{}_tool", self.name),
executor.clone(),
config.clone(),
);
let compactor_node = crate::runtime::react::CompactorNode::new(
format!("{}_compactor", self.name),
std::sync::Arc::new(crate::runtime::LocalCompactor::new()),
config.context_budget.clone(),
);
crate::runtime::react::build_react_graph(llm_node, tool_node, compactor_node)
}
}
#[async_trait]
impl FlowNode for AgentFlowNode {
async fn execute(&self, ctx: &mut NodeContext<'_>) -> Result<(), GraphError> {
let messages = self.extract_messages(ctx);
if messages.is_empty() {
tracing::debug!(
agent = %self.name,
"no input messages found in state key '{}'",
self.message_key
);
}
let hook_ctx = AgentHookContext {
node_name: self.name.clone(),
input_message_count: messages.len(),
};
for hook in &self.hooks {
hook.before_agent(&hook_ctx);
}
if self.use_react_graph {
self.execute_with_react_graph(ctx, messages).await
} else {
self.execute_with_tool_use_loop(ctx, messages).await
}
}
}
impl AgentFlowNode {
async fn execute_with_tool_use_loop(
&self,
ctx: &mut NodeContext<'_>,
messages: Vec<lellm_core::Message>,
) -> Result<(), GraphError> {
let mut agent_stream = self.loop_.execute_stream(messages);
let mut final_result: Option<ToolUseResult> = None;
let mut error: Option<Box<dyn std::error::Error + Send + Sync>> = None;
let mut events: Vec<AgentEvent> = Vec::new();
while let Some(agent_event) = agent_stream.recv().await {
let is_terminal = matches!(
&agent_event,
AgentEvent::LoopEnd { .. } | AgentEvent::LoopError { .. }
);
events.push(agent_event.clone());
if let AgentEvent::Provider(provider_event) = &agent_event {
match translate_provider_event(provider_event) {
TranslationResult::Emit(chunk)
| TranslationResult::EmitWithText { chunk, .. }
| TranslationResult::EmitWithThinking { chunk, .. } => ctx.emit(chunk),
TranslationResult::Usage(_)
| TranslationResult::Finished
| TranslationResult::Ignore => {}
}
}
if is_terminal {
match &agent_event {
AgentEvent::LoopEnd { result } => {
final_result = Some(result.clone());
}
AgentEvent::LoopError { error: err, .. } => {
error = Some(Box::new(err.clone()));
}
_ => {}
}
}
}
if let Some(err) = error {
return Err(GraphError::Terminal(TerminalError::NodeExecutionFailed {
node: self.name.clone(),
source: err,
}));
}
if let Some(result) = final_result {
let snapshot = AgentHookSnapshot {
result: result.clone(),
events,
};
for hook in &self.hooks {
hook.after_agent(&snapshot);
}
self.apply_result(ctx, &result);
tracing::debug!(
agent = %self.name,
iterations = result.iterations,
tool_calls = result.tool_calls_executed,
stop_reason = ?result.stop_reason,
"agent execution completed (ToolUseLoop mode)"
);
} else {
return Err(GraphError::Terminal(TerminalError::NodeExecutionFailed {
node: self.name.clone(),
source: "agent stream ended without terminal event".into(),
}));
}
Ok(())
}
async fn execute_with_react_graph(
&self,
ctx: &mut NodeContext<'_>,
messages: Vec<lellm_core::Message>,
) -> Result<(), GraphError> {
let agent_state = super::typed_state::AgentState::from_messages(messages);
let graph = self.build_react_graph();
let max_steps = self.loop_.config().max_iterations * 5 + 3;
let mut exec_ctx = lellm_graph::node_context::ExecutionContext::new(
agent_state,
None,
lellm_graph::CancellationToken::new(),
);
graph.run_inline(&mut exec_ctx, max_steps).await?;
let agent_state = exec_ctx.state();
let result = super::ToolUseResult {
stop_reason: agent_state
.stop_reason
.clone()
.unwrap_or(StopReason::Complete),
response: agent_state.last_response.clone().unwrap_or_else(|| {
lellm_core::ChatResponse::new(
lellm_core::text_block(String::new()),
lellm_core::TokenUsage::default(),
serde_json::Value::Null,
)
}),
messages: agent_state.messages.clone(),
iterations: agent_state.iterations,
tool_calls_executed: agent_state.total_tool_calls,
};
let snapshot = AgentHookSnapshot {
result: result.clone(),
events: Vec::new(),
};
for hook in &self.hooks {
hook.after_agent(&snapshot);
}
self.write_agent_result(ctx, agent_state);
tracing::debug!(
agent = %self.name,
iterations = agent_state.iterations,
tool_calls = agent_state.total_tool_calls,
"agent execution completed (ReAct Graph mode, AgentState-driven)"
);
Ok(())
}
fn write_agent_result(
&self,
ctx: &mut NodeContext<'_>,
state: &super::typed_state::AgentState,
) {
let messages: Vec<serde_json::Value> = state
.messages
.iter()
.filter_map(|m| serde_json::to_value(m).ok())
.collect();
ctx.record(StateMutation::Put(
self.message_key.clone(),
serde_json::json!(messages),
));
if let Some(ref stop_reason) = state.stop_reason {
ctx.record(StateMutation::Put(
format!("{}_stop_reason", self.name),
serde_json::json!(format!("{:?}", stop_reason)),
));
}
ctx.record(StateMutation::Put(
format!("{}_iterations", self.name),
serde_json::json!(state.iterations as u64),
));
ctx.record(StateMutation::Put(
format!("{}_tool_calls", self.name),
serde_json::json!(state.total_tool_calls as u64),
));
}
}