Skip to content
Radiant LabsRadiant Labs

Core concepts

Graphs

Graphs connect systems into agent behavior with sequential, conditional, parallel, and looping control flow.

A graph connects systems into agent behavior — defining what runs, in what order, under what conditions.

use polaris_ai::graph::Graph;
async fn parse_input() -> String { todo!() }
async fn call_llm() -> String { todo!() }
async fn format_response() -> String { todo!() }
let mut graph = Graph::new();
graph
.add_system(parse_input)
.add_system(call_llm)
.add_system(format_response);

Graph::new() creates an empty graph. The add_system method appends a system node and chains it sequentially to the previous one. The first system you add becomes the graph’s entry point. The result is a linear pipeline: parse_input runs, then call_llm, then format_response.

parse_input ──▶ call_llm ──▶ format_response

add_conditional_branch adds binary if/else routing based on a predicate over the previous system’s output. The predicate receives &T and returns bool. If true, the first branch runs; otherwise, the second branch runs.

use polaris_ai::graph::Graph;
use polaris_ai::models::llm::LlmResponse;
async fn act() -> LlmResponse { todo!() }
async fn execute_tools() { }
async fn respond() { }
let mut graph = Graph::new();
graph.add_system(act);
graph.add_conditional_branch::<LlmResponse, _, _, _>(
"has_tool_calls",
|response| response.has_tool_calls(),
|tool_branch| {
tool_branch.add_system(execute_tools);
},
|done_branch| {
done_branch.add_system(respond);
},
);

The type parameter LlmResponse tells the predicate what output type to read from the context. The two closures each receive a fresh &mut Graph for building their respective subgraphs.

┌─ true ──▶ execute_tools
act ──▶ has_tool_calls?
└─ false ─▶ respond

add_loop repeats a subgraph until a termination predicate returns true. The predicate evaluates an output type produced inside the loop body.

use polaris_ai::graph::Graph;
struct ReactState {
is_complete: bool,
}
async fn init_loop() -> ReactState { todo!() }
async fn reason() -> ReactState { todo!() }
async fn act() -> ReactState { todo!() }
let mut graph = Graph::new();
graph.add_system(init_loop);
graph.add_loop::<ReactState, _, _>(
"react_loop",
|state| state.is_complete,
|g| {
g.add_system(reason)
.add_system(act);
},
);

The loop body executes repeatedly. After each iteration, the framework evaluates the termination predicate against the most recent ReactState output. When is_complete is true, execution continues past the loop.

┌─────────────────────┐
▼ │
init_loop ──▶ reason ──▶ act ──────┘
│ (exit when is_complete == true)
▼
(continue)

For a simple bounded loop without a predicate, use add_loop_n with a maximum iteration count:

graph.add_loop_n("retry", 3, |g| { g.add_system(attempt); });

add_switch provides multi-way routing based on a discriminator function. The discriminator returns a &'static str case key, and execution follows the matching branch.

use polaris_ai::graph::Graph;
struct RouterOutput { action: &'static str }
async fn route() -> RouterOutput { todo!() }
async fn use_tool() { }
async fn clarify() { }
async fn respond() { }
let mut graph = Graph::new();
graph.add_system(route);
graph.add_switch::<RouterOutput, _, _, _>(
"route_action",
|output| output.action,
vec![
("tool", Box::new(|g: &mut Graph| { g.add_system(use_tool); })
as Box<dyn FnOnce(&mut Graph)>),
("clarify", Box::new(|g: &mut Graph| { g.add_system(clarify); })
as Box<dyn FnOnce(&mut Graph)>),
],
Some(Box::new(|g: &mut Graph| { g.add_system(respond); })),
);

The last argument is an optional default branch, used when no case key matches. Switch is useful when the previous system produces more than two possible outcomes.

add_parallel runs multiple branches concurrently. All branches must complete before execution continues.

use polaris_ai::graph::Graph;
async fn fetch_context() { }
async fn fetch_tools() { }
async fn merge_results() { }
let mut graph = Graph::new();
graph.add_parallel("gather", vec![
|g: &mut Graph| { g.add_system(fetch_context); },
|g: &mut Graph| { g.add_system(fetch_tools); },
]);
graph.add_system(merge_results);

Each closure builds an independent subgraph. The branches run simultaneously, and execution resumes from the parallel node’s outgoing edge once all branches finish.

pipe passes &mut Graph to a function, letting you compose reusable graph fragments inline without constructing a separate Graph object.

use polaris_ai::graph::Graph;
async fn log_metrics() { }
async fn check_health() { }
async fn fetch_data() { }
async fn respond() { }
fn monitoring(g: &mut Graph) {
g.add_system(log_metrics)
.add_system(check_health);
}
let mut graph = Graph::new();
graph
.add_system(fetch_data)
.pipe(monitoring)
.add_system(respond);

The monitoring function adds its systems directly to the current graph. This is different from the branch closures in add_loop or add_conditional_branch, which build into isolated subgraphs.

Use add_scope to embed a subgraph with an explicit context policy. This is the boundary for reusing a sub-agent while controlling which resources it receives. Outputs merge back to the caller; the context policy does not filter those outputs.

use polaris_ai::graph::{Graph, node::ContextPolicy};
async fn summarize() -> String { "A summary".into() }
let mut research = Graph::new();
research.add_system(summarize);
let mut graph = Graph::new();
graph.add_scope("research", research, ContextPolicy::new().share_rest());

add_dynamic selects among named candidate graphs at runtime. A DynamicSlot declares the required graph signature and context policy; candidates must satisfy that contract. add_dynamic_registry instead reads candidates from a session-local SubgraphRegistry, allowing the candidate set to change between executions. These primitives support replaceable agent components; they do not implement an automatic improvement or training loop.

See the public graph reference for candidate registration and policy examples.

You execute a graph with GraphExecutor. The executor traverses the graph from its entry point, running each system with a SystemContext that holds resources, outputs, and parent scopes.

use polaris_ai::graph::{Graph, GraphExecutor};
use polaris_ai::system::param::SystemContext;
async fn reason() -> i32 { 1 }
async fn act() -> i32 { 2 }
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut graph = Graph::new();
graph.add_system(reason).add_system(act);
let mut ctx = SystemContext::new();
let executor = GraphExecutor::new();
let result = executor.execute(&graph, &mut ctx, None, None).await?;
Ok(())
}

The execute method returns an ExecutionResult with the number of nodes executed and the total duration. In practice, you rarely call GraphExecutor directly – the Sessions plugin and the Agent trait handle execution for you.