fix: bug in next_single method and improved outcome handling for LLM node execution

This commit is contained in:
2026-05-20 16:27:25 -06:00
parent a3bfa2fbe9
commit 76549a9911
5 changed files with 65 additions and 153 deletions
+8 -38
View File
@@ -1,5 +1,5 @@
use super::agent::AgentNodeExecutor;
use super::llm::LlmNodeExecutor;
use super::llm::{LlmExecutionOutcome, LlmNodeExecutor};
use super::logging::GraphLogger;
use super::map::MapNodeExecutor;
use super::progress::{BranchProgressHandle, BranchProgressTracker};
@@ -366,17 +366,16 @@ async fn step(
Ok(StepResult::Continue(vec![next]))
}
NodeType::Llm(llm_node) => {
let primary = first_next_target(node).map(str::to_string);
let llm_routing =
LlmNodeExecutor::execute(llm_node, primary.as_deref(), state, ctx).await?;
let targets = resolve_branching_next(node, &llm_routing);
let outcome = LlmNodeExecutor::execute(llm_node, state, ctx).await?;
let targets = match outcome {
LlmExecutionOutcome::Continue => static_next_targets(node, current, "llm")?,
LlmExecutionOutcome::FellBack(target) => vec![target],
};
Ok(StepResult::Continue(targets))
}
NodeType::Rag(rag_node) => {
let primary = first_next_target(node).map(str::to_string);
let rag_routing =
RagNodeExecutor::execute(rag_node, current, primary.as_deref(), state, ctx).await?;
let targets = resolve_branching_next(node, &rag_routing);
RagNodeExecutor::execute(rag_node, current, state, ctx).await?;
let targets = static_next_targets(node, current, "rag")?;
Ok(StepResult::Continue(targets))
}
NodeType::End(end_node) => Ok(StepResult::End(resolve_end_output(end_node, state))),
@@ -406,35 +405,6 @@ fn first_next_target(node: &Node) -> Option<&str> {
.and_then(|t| t.as_slice().first().map(|s| s.as_str()))
}
// Resolves the actual frontier-advance targets after an LLM/RAG node ran.
//
// LLM/RAG executors return their chosen routing as a String — either the
// primary `next:` target (success path) or the node's `fallback:` (failure
// path with retry exhausted). We can't tell these apart from inside step()
// without an API refactor, so we compare strings: if the returned routing
// matches the first declared `next` target, treat as success and (for
// fan-out) use ALL declared targets; otherwise treat as fallback and use the
// returned target alone.
//
// Known limitation: if a fan-out node's `fallback:` is set to the same node
// id as its first `next:` target, a successful run is indistinguishable from
// a fallback run — both look like "returned the first target". The result is
// that the executor advances to all Many targets in the fallback case (which
// is the OPPOSITE of the user's likely intent). Workaround: choose a
// `fallback:` distinct from any `next:` target.
fn resolve_branching_next(node: &Node, returned_routing: &str) -> Vec<String> {
let Some(targets) = &node.next else {
return vec![returned_routing.to_string()];
};
let slice = targets.as_slice();
let first_matches = slice.first().is_some_and(|s| s == returned_routing);
if first_matches && slice.len() > 1 {
slice.to_vec()
} else {
vec![returned_routing.to_string()]
}
}
fn resolve_end_output(end_node: &EndNode, state: &mut StateManager) -> String {
apply_simple_state_updates(end_node.state_updates.as_ref(), state);
state.interpolate_lenient(&end_node.output)