docs: created docs for the new graph agent system
+11
@@ -4,6 +4,12 @@ Agents in Loki follow the same style as OpenAI's GPTs. They consist of 3 parts:
|
||||
* [RAG](RAG) - Pre-built knowledge bases specifically for the agent
|
||||
* [Function Calling](Tools#tools) ([#2](MCP-Servers)) - Extends the functionality of the LLM through custom functions it can call
|
||||
|
||||
> **Looking for declarative, multi-step workflows?** See
|
||||
> [Graph Agents](Graph-Agents): a YAML-driven workflow engine where each step
|
||||
> (LLM call, script, user prompt, child-agent spawn) is its own typed node.
|
||||
> Useful when an agent's behavior follows a fixed shape rather than a single
|
||||
> open-ended LLM loop.
|
||||
|
||||

|
||||
|
||||
Agent configuration files are stored in the `agents` subdirectory of your Loki configuration directory. The location of
|
||||
@@ -738,3 +744,8 @@ Loki comes packaged with some useful built-in agents:
|
||||
* `oracle`: An agent for high-level architecture, design decisions, and complex debugging
|
||||
* `sisyphus`: A powerhouse orchestrator agent for writing complex code and acting as a natural language interface for your codebase (similar to ClaudeCode, Gemini CLI, Codex, or OpenCode). Uses sub-agent spawning to delegate to `explore`, `coder`, and `oracle`.
|
||||
* `sql`: A universal SQL agent that enables you to talk to any relational database in natural language
|
||||
|
||||
Loki writes these built-in agents to your agents directory on first run and never overwrites them afterward, so any
|
||||
edits you make to them are preserved across Loki updates. To discard your local changes and reinstall the built-in
|
||||
agents from the current Loki build, run `loki --install agents` (or `.install agents` in the REPL). Agents you created
|
||||
yourself are not affected.
|
||||
|
||||
+950
@@ -0,0 +1,950 @@
|
||||
Graph-based agents are a declarative, YAML-driven workflow engine layered on
|
||||
top of Loki's existing agent system. Where a normal [agent](Agents) runs as a
|
||||
single LLM loop driven by tool calls, a **graph agent** is a directed graph of
|
||||
typed nodes. Each node performs one well-defined step (call an LLM, run a
|
||||
script, ask the user a question, spawn a child agent, etc.) and routes to the
|
||||
next node based on its result.
|
||||
|
||||
Graph agents are best for workflows that:
|
||||
|
||||
- Have a fixed shape (e.g. parse -> query -> grade -> synthesize -> verify)
|
||||
- Mix LLM calls with deterministic steps (scripts, user prompts)
|
||||
- Need explicit human-in-the-loop checkpoints
|
||||
- Benefit from per-step model / tool / temperature overrides
|
||||
|
||||
If you just want an agent that takes a goal and figures out the steps on its
|
||||
own, stick with a regular [agent](Agents).
|
||||
|
||||
---
|
||||
|
||||
# Directory Structure
|
||||
|
||||
A graph agent is defined by a single `graph.yaml`. It holds *both* the
|
||||
agent-level config (model, tools, MCP servers) *and* the workflow:
|
||||
|
||||
```
|
||||
<loki-config-dir>/agents
|
||||
└── my-graph-agent
|
||||
├── graph.yaml # agent config + workflow definition
|
||||
├── tools.sh # optional custom tools
|
||||
├── <rag-node-id>.yaml # auto-built knowledge base for a rag node
|
||||
└── scripts/ # optional script-node implementations
|
||||
├── decide.py
|
||||
└── verify.py
|
||||
```
|
||||
|
||||
`<rag-node-id>.yaml` files are generated by Loki at agent load time - one
|
||||
per `rag` node - and should not be hand-edited.
|
||||
|
||||
An agent directory must contain **either** a `config.yaml` (a normal,
|
||||
LLM-loop agent (see [Agents](Agents))) **or** a `graph.yaml` (a graph
|
||||
agent). Never both. The presence of `graph.yaml` is what marks an agent
|
||||
as a graph agent; when Loki runs it, execution is driven entirely by the
|
||||
graph.
|
||||
|
||||
**Both files present is an error.** If an agent directory contains both
|
||||
`config.yaml` and `graph.yaml`, Loki refuses to load it and tells you to
|
||||
remove one. Pick the model that fits: `config.yaml` for an open-ended
|
||||
LLM-loop agent, `graph.yaml` for a fixed-shape workflow.
|
||||
|
||||
---
|
||||
|
||||
# graph.yaml Top-Level Fields
|
||||
|
||||
```yaml
|
||||
name: my-graph-agent
|
||||
description: |
|
||||
Plain prose describing what the workflow does.
|
||||
version: "1.0"
|
||||
|
||||
# --- agent-level config ---
|
||||
model: anthropic:claude-sonnet-4-6 # default model for llm nodes
|
||||
temperature: 0.0 # default sampling temperature
|
||||
top_p: null # default sampling top-p
|
||||
global_tools: # global tools available to nodes
|
||||
- web_search_loki.sh
|
||||
mcp_servers: # MCP servers available to nodes
|
||||
- pubmed-search
|
||||
conversation_starters: # suggested prompts in the UI
|
||||
- "Look up LOINC 2160-0"
|
||||
|
||||
settings:
|
||||
max_loop_iterations: 100 # PER-NODE visit cap; default 100 (see below)
|
||||
log_state_snapshots: true # log state JSON before each node executes
|
||||
validate_before_run: true # run the graph validator on startup
|
||||
timeout: 600 # optional overall timeout in seconds
|
||||
|
||||
initial_state: # optional seed state for the run
|
||||
topic: "auth"
|
||||
|
||||
start: parse_input # required: ID of the first node to run
|
||||
|
||||
nodes:
|
||||
parse_input: { ... }
|
||||
...
|
||||
```
|
||||
|
||||
- **`version`:** Currently only `"1.0"` is accepted by the parser. Anything
|
||||
else fails at startup. This is the *graph schema* version, not your
|
||||
agent's version.
|
||||
- **Agent-level config** (`model`, `temperature`, `top_p`, `global_tools`,
|
||||
`mcp_servers`, `conversation_starters`) are all optional.
|
||||
These are the same fields a normal agent's `config.yaml` carries; in a
|
||||
graph agent they live at the top of `graph.yaml` instead. `model` /
|
||||
`temperature` / `top_p` act as the defaults for `llm` nodes that don't
|
||||
set their own. `global_tools` and `mcp_servers` define the tool universe
|
||||
that an `llm` node's `tools:` whitelist selects from (a node with no
|
||||
`tools:` field gets none of them).
|
||||
- **`can_spawn_agents` is derived, not declared.** A graph agent can spawn
|
||||
child agents iff its graph contains at least one `agent` node. You don't
|
||||
set a flag. The `agent` node's presence *is* the declaration.
|
||||
- **`max_loop_iterations`:** This is a **per-node visit cap**, not a total
|
||||
graph-step cap. If the same node id is entered more than this many times,
|
||||
execution aborts with `Node 'X' visited N times (max_loop_iterations=...)`.
|
||||
Default: 100.
|
||||
- **`timeout`:** Wall-clock cap on the entire graph run. The executor
|
||||
checks this between every node transition; nodes that block longer than
|
||||
the timeout will still finish before the check fires.
|
||||
- **`initial_state`:** A JSON-compatible object. Values are seeded into
|
||||
state before any node runs and are referenced from any node via `{{key}}`
|
||||
templates.
|
||||
|
||||
### `{{initial_prompt}}`: Automatically Seeded
|
||||
|
||||
When Loki invokes a graph agent with a user prompt (whether from the
|
||||
command line `loki -a my-agent "what is X?"`, from the REPL, or from a
|
||||
parent agent that spawned it as a sub-agent), the dispatcher automatically
|
||||
seeds the prompt text into state under the key **`initial_prompt`** before
|
||||
any node runs.
|
||||
|
||||
This means every graph agent's first node can reference the user's request
|
||||
via `{{initial_prompt}}`:
|
||||
|
||||
```yaml
|
||||
parse_input:
|
||||
id: parse_input
|
||||
type: llm
|
||||
prompt: "{{initial_prompt}}" # the user's command-line / REPL text
|
||||
...
|
||||
```
|
||||
|
||||
You do not need to (and should not) put `initial_prompt` in `initial_state` as it is overwritten by the dispatcher.
|
||||
|
||||
---
|
||||
|
||||
# Node Types
|
||||
|
||||
There are seven node types: **agent**, **script**, **approval**, **input**,
|
||||
**llm**, **rag**, and **end**. Every node has these common fields:
|
||||
|
||||
```yaml
|
||||
my_node:
|
||||
id: my_node # must match the map key
|
||||
type: <one of the seven>
|
||||
description: optional # free-form
|
||||
next: another_node # optional default next node; semantics vary per type
|
||||
```
|
||||
|
||||
The `next` field defines the default routing edge. Node types interpret it
|
||||
differently (some types ignore it in favor of internal routing; see each type
|
||||
below).
|
||||
|
||||
---
|
||||
|
||||
## agent
|
||||
|
||||
Spawns a Loki sub-agent and waits for it to finish. This is how a graph agent
|
||||
delegates a sub-goal to a fully autonomous Loki agent (with its own tool loop
|
||||
and configuration).
|
||||
|
||||
```yaml
|
||||
research_topic:
|
||||
id: research_topic
|
||||
type: agent
|
||||
agent: deep-researcher # name of an existing Loki agent
|
||||
prompt: "Research {{topic}}" # interpolated against state
|
||||
timeout: 600 # optional, in seconds (default 300)
|
||||
state_updates:
|
||||
findings: "{{output}}"
|
||||
output_schema: { ... } # optional, see "Structured Output" below
|
||||
next: render
|
||||
```
|
||||
|
||||
- **`agent`:** Name of the child agent to spawn. Must exist in
|
||||
`<loki-config-dir>/agents/`.
|
||||
- **`prompt`:** The user message sent to the child agent. Templated against
|
||||
the current graph state.
|
||||
- **`timeout`:** Hard wall-clock cap. If the child agent exceeds it, the
|
||||
whole graph fails (no built-in fallback path on agent nodes).
|
||||
- **`state_updates`:** Map of `state_key: "{{template}}"`. The child agent's
|
||||
final text is available inside this map as `{{output}}`.
|
||||
|
||||
---
|
||||
|
||||
## script
|
||||
|
||||
Runs a Bash, Python, or TypeScript script and merges its JSON-object stdout
|
||||
into state. Script files live under the agent's `scripts/` directory.
|
||||
|
||||
**Supported extensions and runtimes**:
|
||||
|
||||
| Extension | Runtime invoked | Notes |
|
||||
|-----------|----------------------------|-----------------------------------------|
|
||||
| `.sh` | `bash <script>` | |
|
||||
| `.py` | `python3 <script>` | not `python`. Must be Python 3 |
|
||||
| `.ts` | `npx tsx <script>` | requires Node + `tsx` available on PATH |
|
||||
|
||||
`.js` / `.mjs` / other extensions are **not** supported. The shebang line
|
||||
inside the script is not used for script-node dispatch (it is for normal
|
||||
custom-tools); the file extension is the source of truth.
|
||||
|
||||
```yaml
|
||||
route_after_parse:
|
||||
id: route_after_parse
|
||||
type: script
|
||||
script: scripts/route_after_parse.py
|
||||
timeout: 30 # seconds, default 30
|
||||
fallback: handle_error # optional: where to route on script failure
|
||||
state_updates: # applied after stdout merge
|
||||
last_run: "{{some_value}}"
|
||||
```
|
||||
|
||||
The script receives the current state in two forms; use whichever fits:
|
||||
|
||||
| Env var | Contents |
|
||||
|--------------------|---------------------------------------------------------------|
|
||||
| `GRAPH_STATE` | Inline JSON when serialized state is <= 32 KiB |
|
||||
| `GRAPH_STATE_FILE` | Path to a temp JSON file when serialized state exceeds 32 KiB |
|
||||
|
||||
Exactly one of the two is set per script invocation; **always check both**. The temp
|
||||
file (when used) is cleaned up automatically after the graph finishes.
|
||||
|
||||
The script must print a single JSON object on stdout. All keys merge into
|
||||
state; the reserved `_next` key is extracted and overrides the default `next`
|
||||
routing.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json, os
|
||||
|
||||
def load_state():
|
||||
if path := os.environ.get("GRAPH_STATE_FILE"):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
|
||||
state = load_state()
|
||||
codes = (state.get("loinc_codes") or "").strip()
|
||||
next_node = "query_db" if codes else "ask_for_code"
|
||||
print(json.dumps({"_next": next_node, "trimmed_codes": codes}))
|
||||
```
|
||||
|
||||
**Tolerant-fail**: if the script exits non-zero or produces invalid JSON, the
|
||||
node routes to `fallback` (if set) or to `next` (if set). Without either,
|
||||
the graph errors.
|
||||
|
||||
---
|
||||
|
||||
## approval
|
||||
|
||||
Prompts the user with a question and a list of options, then routes based on
|
||||
their answer. This is the human-in-the-loop checkpoint.
|
||||
|
||||
```yaml
|
||||
approve:
|
||||
id: approve
|
||||
type: approval
|
||||
question: |
|
||||
Final report:
|
||||
{{report}}
|
||||
|
||||
Approve?
|
||||
options:
|
||||
- "yes"
|
||||
- "no"
|
||||
routes:
|
||||
"yes": end_accepted
|
||||
"no": end_rejected
|
||||
on_other: clarify # Required - see below
|
||||
state_updates:
|
||||
decision: "{{choice}}"
|
||||
```
|
||||
|
||||
### The `on_other` field
|
||||
|
||||
This field is **required** and easy to miss. Loki's `user__ask` tool *always*
|
||||
gives the user a "type your own answer" option in addition to the listed
|
||||
options. There is no way to disable this. Without `on_other`, a user who
|
||||
types something other than the listed options would crash the graph at
|
||||
runtime.
|
||||
|
||||
`on_other` says **where to route when the user's answer does not match any
|
||||
`routes` key**. The free-form text they typed is available downstream via
|
||||
the `{{choice}}` template variable inside `state_updates`.
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Free-form means "I want to clarify"** -> `on_other: clarify_node`
|
||||
where `clarify_node` is an `input` or `llm` node that processes their text.
|
||||
- **Free-form means "rejection by default"** -> `on_other: end_rejected`.
|
||||
|
||||
---
|
||||
|
||||
## input
|
||||
|
||||
Collects a free-form string from the user.
|
||||
|
||||
```yaml
|
||||
ask_for_code:
|
||||
id: ask_for_code
|
||||
type: input
|
||||
question: "Enter a LOINC code (e.g. 6690-2):"
|
||||
default: "{{last_used_code}}" # optional, interpolated against state
|
||||
validation: "len(input) > 0" # optional, see below
|
||||
state_updates:
|
||||
loinc_code: "{{input}}"
|
||||
next: query_db
|
||||
```
|
||||
|
||||
- **`default`:** If the user submits an empty response, this template is
|
||||
used. Only `default` itself is templated, not the surrounding question
|
||||
(which is also templated).
|
||||
- **`validation`:** A length predicate of the form
|
||||
`len(input) <op> <integer>`, where `<op>` is `>`, `>=`, `<`, `<=`, or `==`.
|
||||
This is a deliberately narrow grammar; regex / type / range validation are
|
||||
not yet supported. If validation fails, the node fails (no fallback).
|
||||
- The user's text is exposed to `state_updates` as `{{input}}`.
|
||||
|
||||
---
|
||||
|
||||
## llm
|
||||
|
||||
A one-shot LLM call with an optional bounded tool-call loop. Unlike `agent`
|
||||
nodes, this does NOT spawn a sub-agent; it runs in a fresh isolated context
|
||||
with a caller-supplied system prompt and user prompt. Tool access is strictly
|
||||
opt-in: an `llm` node gets **no tools at all** unless its `tools` field
|
||||
explicitly lists them (see below).
|
||||
|
||||
```yaml
|
||||
grade_research:
|
||||
id: grade_research
|
||||
type: llm
|
||||
instructions: | # optional system prompt
|
||||
You decide whether research is needed for {{topic}}.
|
||||
prompt: | # required user prompt
|
||||
Research context:
|
||||
{{research_text}}
|
||||
|
||||
Reply with YES or NO.
|
||||
tools: [] # see below
|
||||
model: anthropic:haiku # optional override
|
||||
temperature: 0.0
|
||||
top_p: null
|
||||
max_attempts: 1 # transient-error retries (default 1)
|
||||
max_iterations: 10 # tool-call-loop turn cap (default 10)
|
||||
fallback: skip # routes here if all attempts fail
|
||||
state_updates:
|
||||
grade: "{{output}}"
|
||||
output_schema: { ... } # optional, see "Structured Output" below
|
||||
timeout: 120 # optional; node wall-clock cap in seconds (unset = no timeout)
|
||||
next: synthesize
|
||||
```
|
||||
|
||||
### The `tools` field (whitelist)
|
||||
|
||||
The `tools` field is a strict opt-in whitelist: an `llm` node receives
|
||||
**only** the tools it explicitly lists, never the agent's full tool set.
|
||||
Three modes:
|
||||
|
||||
- **Unset (field omitted)** -> **no tools**. The LLM produces output but
|
||||
cannot make any tool calls. This is identical to `tools: []`. Leaving the
|
||||
field out does _not_ inherit the agent's tools.
|
||||
- **`tools: []`** -> **no tools**. Same as unset.
|
||||
- **`tools: [a, b, mcp:server-name]`** -> only those specific tools, and
|
||||
nothing else. Entries are either exact tool names (matching `global_tools`,
|
||||
agent custom tools, or individual MCP function names) or the shorthand
|
||||
`mcp:<server-name>` (which enables all functions for that MCP server).
|
||||
|
||||
Even when `tools` lists entries, the LLM receives **exactly** that set. The
|
||||
whitelist is enforced against global tools, agent custom tools, and MCP
|
||||
alike. Each entry is validated at startup against the active agent's tool
|
||||
list; an unknown entry is a startup error.
|
||||
|
||||
### Tolerant-fail routing
|
||||
|
||||
| Outcome | Routes to |
|
||||
|------------------------------------------|----------------------------|
|
||||
| Success | `next` |
|
||||
| Failure WITH `fallback` set | `fallback` |
|
||||
| Failure WITHOUT `fallback` | `next` (output is "LLM node failed: ...") |
|
||||
|
||||
`state_updates` are always applied (success or failure). On failure,
|
||||
`{{output}}` resolves to an error description so downstream nodes can detect
|
||||
it.
|
||||
|
||||
### Retries (`max_attempts`)
|
||||
|
||||
`max_attempts` retries the LLM call **only on transient errors**. The
|
||||
failure message containing one of: `timed out`, `rate limit`, `429`,
|
||||
`Connection reset`, `Connection refused`, or `produced no output`. Any
|
||||
other error fails immediately without consuming further attempts. The
|
||||
default is `1` (no retries).
|
||||
|
||||
---
|
||||
|
||||
## rag
|
||||
|
||||
Runs a hybrid (vector + keyword) retrieval against a per-node knowledge base
|
||||
and writes the result into state. This is how a graph agent does
|
||||
Retrieval-Augmented Generation: the `rag` node retrieves context, downstream
|
||||
`llm`/`agent` nodes inject it into their prompts via normal templating.
|
||||
|
||||
```yaml
|
||||
research_context:
|
||||
id: research_context
|
||||
type: rag
|
||||
documents: # required; The knowledge sources
|
||||
- ./knowledge/
|
||||
- https://example.com/spec
|
||||
query: "{{initial_prompt}}" # templated; defaults to "{{initial_prompt}}"
|
||||
top_k: 5 # optional; default = the knowledge base's own top_k
|
||||
timeout: 120 # optional; retrieval timeout in seconds (default 120)
|
||||
state_updates: # required in practice (see below)
|
||||
rag_context: "{{output.context}}"
|
||||
rag_sources: "{{output.sources}}"
|
||||
next: answer
|
||||
|
||||
answer:
|
||||
type: llm
|
||||
prompt: |
|
||||
Use this context to answer:
|
||||
{{rag_context}}
|
||||
|
||||
Question: {{initial_prompt}}
|
||||
```
|
||||
|
||||
- **`documents`:** Knowledge sources: files, directories, URLs, or
|
||||
loader-protocol paths. **Required**. It's what makes the node a `rag`
|
||||
node. Relative paths resolve against the agent's directory.
|
||||
- **`query`:** The retrieval query, templated against state. Defaults to
|
||||
`{{initial_prompt}}`. Set it to `{{refined_query}}` to retrieve against a
|
||||
query an upstream `llm` node produced.
|
||||
- **`top_k`:** Number of chunks to retrieve. Defaults to the knowledge
|
||||
base's own configured `top_k`.
|
||||
- **`timeout`:** Retrieval timeout in seconds. Default 120.
|
||||
- **`state_updates`:** Where the result goes. A `rag` node with no
|
||||
`state_updates` discards its result (the validator warns).
|
||||
|
||||
**Knowledge-base build config** (all optional; used only when the knowledge
|
||||
base is first built):
|
||||
|
||||
- **`embedding_model`:** Embedding model for the corpus.
|
||||
- **`chunk_size`:** Document chunk size.
|
||||
- **`chunk_overlap`:** Overlap between chunks.
|
||||
- **`reranker_model`:** Reranker applied to hybrid-search results.
|
||||
- **`batch_size`:** Embedding-request batch size.
|
||||
|
||||
Each falls back to the app-level `rag_*` config when omitted. **When
|
||||
`embedding_model`, `chunk_size`, and `chunk_overlap` are all set, the
|
||||
knowledge base builds with no interactive prompts**. So a fully-specified
|
||||
`rag` node works in non-interactive runs.
|
||||
|
||||
### `{{output}}` shape
|
||||
|
||||
Inside `state_updates`, `{{output}}` is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"context": "[Source: ./knowledge/a.md]\n...chunk...",
|
||||
"sources": ["./knowledge/a.md", "https://example.com/spec"]
|
||||
}
|
||||
```
|
||||
|
||||
- `{{output.context}}`: The retrieved context block, ready to inject into a
|
||||
prompt.
|
||||
- `{{output.sources}}`: An array of source paths; `{{output.sources[0]}}`
|
||||
indexes individual sources (useful for downstream citation/verification
|
||||
nodes).
|
||||
|
||||
### Knowledge base lifecycle
|
||||
|
||||
Each `rag` node's knowledge base is built **once, at agent load time**, into
|
||||
`<agent-dir>/<node-id>.yaml`:
|
||||
|
||||
- If that file exists -> it is loaded (no prompt; works non-interactively).
|
||||
- If it's missing and the node is **fully specified** (`embedding_model` +
|
||||
`chunk_size` + `chunk_overlap` all set) -> it is built directly, no
|
||||
prompts. Works in non-interactive runs.
|
||||
- If it's missing, not fully specified, and Loki is interactive -> you are
|
||||
asked to initialize it, then prompted for the missing build values;
|
||||
declining is a hard error.
|
||||
- If it's missing, not fully specified, and Loki is non-interactive
|
||||
(no TTY) -> hard error, with a hint to set the build-config fields or run
|
||||
the agent once interactively.
|
||||
|
||||
A graph with a `rag` node whose knowledge base isn't built **cannot run**.
|
||||
This is deliberate fail-fast behavior. (In `--info` mode the agent is only
|
||||
inspected, not run, so knowledge-base building is skipped entirely.)
|
||||
|
||||
### Retrieval
|
||||
|
||||
Retrieval at execution time is fast (no re-embedding of the corpus). It's
|
||||
the same hybrid vector + keyword search normal Loki RAG uses. The corpus
|
||||
embedding/chunking cost is paid once, at load time.
|
||||
|
||||
---
|
||||
|
||||
## end
|
||||
|
||||
Terminates execution and returns a final result.
|
||||
|
||||
```yaml
|
||||
end_accepted:
|
||||
id: end_accepted
|
||||
type: end
|
||||
output: |
|
||||
Approved report:
|
||||
{{report}}
|
||||
state_updates: # optional last state mutations
|
||||
completed_at: "now"
|
||||
```
|
||||
|
||||
- **`output`:** Templated against state, printed as the graph's final
|
||||
result.
|
||||
- Multiple `end` nodes are fine; you pick which one routes here based on
|
||||
upstream conditions.
|
||||
|
||||
---
|
||||
|
||||
# State and Template Syntax
|
||||
|
||||
Graph state is a `serde_json::Value` map. Templates use `{{path}}` syntax
|
||||
inside any string field.
|
||||
|
||||
| Form | Resolves to |
|
||||
|-------------------------------|----------------------------------------------|
|
||||
| `{{key}}` | top-level value |
|
||||
| `{{a.b.c}}` | nested object path |
|
||||
| `{{arr[0]}}` | array index |
|
||||
| `{{matrix[0][1]}}` | nested array indices |
|
||||
| `{{users[0].name}}` | object field via index |
|
||||
| `{{a.b.arr[2].field}}` | mixed path |
|
||||
|
||||
Rendering rules per value type:
|
||||
|
||||
- **String** -> as-is
|
||||
- **Number / bool / null** -> stringified (`true`, `42`, `null`)
|
||||
- **Array / Object** -> JSON-encoded compactly (`["a","b"]`, `{"k":"v"}`)
|
||||
|
||||
Missing keys / paths behave differently per template-evaluation site:
|
||||
|
||||
- Inside a node's primary fields (`prompt`, `instructions`, `question`,
|
||||
`output`) -> strict mode, missing keys raise an error.
|
||||
- Inside `state_updates` values -> lenient mode, missing keys become empty
|
||||
strings.
|
||||
|
||||
---
|
||||
|
||||
# state_updates
|
||||
|
||||
Every node type (except `end`, which has a slightly different shape) accepts
|
||||
an optional `state_updates` map:
|
||||
|
||||
```yaml
|
||||
state_updates:
|
||||
some_key: "{{template}}"
|
||||
other_key: "literal text with {{var}}"
|
||||
```
|
||||
|
||||
After the node body executes, each template is interpolated against state and
|
||||
the result is stored under the corresponding key. Three scoped variables are
|
||||
available *only inside `state_updates`*:
|
||||
|
||||
| Variable | Available in | Resolves to |
|
||||
|--------------|--------------------|----------------------------------------------------------------|
|
||||
| `{{output}}` | `agent`, `llm` | The node's primary text output (or parsed JSON value if `output_schema` is set) |
|
||||
| `{{choice}}` | `approval` | The option the user picked, or their free-form text |
|
||||
| `{{input}}` | `input` | The user's text (or interpolated `default` if they submitted empty) |
|
||||
|
||||
These variables are cleared after `state_updates` runs, so they don't leak
|
||||
into the next node's templates.
|
||||
|
||||
> **End nodes are different.** An `end` node's `state_updates` runs with
|
||||
> plain lenient interpolation. There is no scoped `{{output}}` because
|
||||
> there is no node-body output to scope. After `state_updates` apply, the
|
||||
> `end` node's own `output` template is interpolated against the resulting
|
||||
> state and returned as the graph's final result.
|
||||
|
||||
---
|
||||
|
||||
# Routing & Tolerant-Fail
|
||||
|
||||
Nodes route via three mechanisms in priority order:
|
||||
|
||||
1. **Script `_next` override:** `script` nodes can set `"_next": "node_id"`
|
||||
in their stdout JSON to dynamically choose the next node.
|
||||
2. **Internal routing:** `approval` routes via its `routes` map (or
|
||||
`on_other` when the answer matches no listed option).
|
||||
3. **Default `next` edge:** the `next` field on the node.
|
||||
|
||||
### Routing requirements per node type
|
||||
|
||||
| Node type | Needs `next`? |
|
||||
|-------------|---------------------------------------------------------------------------------------------------|
|
||||
| `agent` | **Yes** - `next` is required (unless the agent node is unreachable). Error at runtime if missing. |
|
||||
| `script` | Either `_next` from script output OR static `next` (or `fallback` on failure). Error if neither. |
|
||||
| `approval` | No - routing is via `routes` and `on_other`. `next` is ignored. |
|
||||
| `input` | **Yes** - `next` is the success route. |
|
||||
| `llm` | **Yes** - `next` is the success route (and the default for failures without `fallback`). |
|
||||
| `rag` | **Yes** - `next` is required. Error at runtime if missing. |
|
||||
| `end` | No - terminal. |
|
||||
|
||||
### Tolerant-fail contract
|
||||
|
||||
Currently honored by `script` and `llm` nodes:
|
||||
|
||||
- Success -> default routing
|
||||
- Failure with `fallback` set -> `fallback` target
|
||||
- Failure without `fallback` -> default routing, with the error description
|
||||
exposed in state so the next node can react
|
||||
|
||||
`agent` and `input` nodes do NOT have a tolerant-fail `fallback` path;
|
||||
their failures propagate as graph failures.
|
||||
|
||||
---
|
||||
|
||||
# Structured Output (`output_schema`)
|
||||
|
||||
Both `llm` and `agent` nodes can specify an `output_schema` field: a JSON
|
||||
Schema (written inline in YAML) describing the expected shape of the node's
|
||||
output:
|
||||
|
||||
```yaml
|
||||
extract_task:
|
||||
type: llm
|
||||
prompt: 'Parse: "{{raw_task}}"'
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
action: { type: string }
|
||||
items:
|
||||
type: array
|
||||
items: { type: string }
|
||||
time_minutes: { type: ["integer", "null"] }
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, medium, high]
|
||||
required: [action, items, priority]
|
||||
```
|
||||
|
||||
When `output_schema` is set:
|
||||
|
||||
1. The node body runs normally.
|
||||
2. The raw text output is **tried as JSON first** (with light cleanup of
|
||||
markdown code fences); the fast path. If parsing succeeds, that's the
|
||||
structured output.
|
||||
3. Otherwise Loki invokes a built-in `__structured_output__` role
|
||||
(constructed inline; not visible in the user's role list) to extract a
|
||||
JSON object matching the schema. One repair retry on extractor failure.
|
||||
4. When the parsed value is a JSON **object**, its **top-level keys
|
||||
auto-merge into state permanently** (a non-object result is still
|
||||
reachable via `{{output}}` but has no top-level keys to merge).
|
||||
5. `{{output}}` (inside `state_updates`) resolves to the full parsed value.
|
||||
6. Explicit `state_updates` win over auto-merge if the same key is set in
|
||||
both.
|
||||
|
||||
After the example above, downstream nodes can use `{{action}}`, `{{items}}`,
|
||||
`{{items[0]}}`, `{{priority}}`, etc. directly.
|
||||
|
||||
### LLM nodes vs Agent nodes: schema-hint injection
|
||||
|
||||
This is the **most important behavioral difference** between the two node
|
||||
types when `output_schema` is set:
|
||||
|
||||
- **LLM nodes**: Loki automatically appends a schema hint to the prompt
|
||||
(to the system prompt if `instructions` is set, otherwise to the user
|
||||
prompt). The hint tells the model to respond with JSON matching the
|
||||
schema. This means the main LLM call usually emits valid JSON directly ->
|
||||
the fast path succeeds -> the extractor LLM call is skipped entirely
|
||||
(cheaper, faster, more reliable).
|
||||
- **Agent nodes**: Loki does NOT inject any schema hint. Agents are
|
||||
multi-turn with their own tool-use loop; stuffing a schema into the
|
||||
initial prompt risks the agent fixating on JSON output instead of doing
|
||||
its actual work. The agent runs to completion freely, and the extractor
|
||||
converts its final text to JSON afterward.
|
||||
|
||||
If you need an agent to emit JSON-shaped output, include schema language in
|
||||
its prompt yourself. The auto-injected hint for LLM nodes uses this form:
|
||||
|
||||
```
|
||||
Respond with a JSON object that matches this schema. Output ONLY the JSON
|
||||
object with no surrounding prose or markdown fences.
|
||||
|
||||
Schema:
|
||||
{...}
|
||||
```
|
||||
|
||||
### Tolerant-fail for extraction
|
||||
|
||||
- **LLM node**: extraction failure = node failure -> routes via `fallback`
|
||||
or `next`.
|
||||
- **Agent node**: extraction failure propagates as a graph error (agent
|
||||
nodes have no `fallback`).
|
||||
|
||||
---
|
||||
|
||||
# Worked Example
|
||||
|
||||
A compact illustrative graph -`input` -> `llm` (with `output_schema`) ->
|
||||
`end` - exercising structured output and all template-path forms. For a
|
||||
**full-featured reference** covering every node type and field, see the
|
||||
heavily-commented `graph.example.yaml` at the root of the Loki repository.
|
||||
|
||||
Illustrative `graph.yaml`:
|
||||
|
||||
```yaml
|
||||
name: structured-test
|
||||
version: "1.0"
|
||||
start: ask_task
|
||||
|
||||
nodes:
|
||||
ask_task:
|
||||
id: ask_task
|
||||
type: input
|
||||
question: "Describe a task in free-form text."
|
||||
validation: "len(input) > 0"
|
||||
state_updates:
|
||||
raw_task: "{{input}}"
|
||||
next: extract_task
|
||||
|
||||
extract_task:
|
||||
id: extract_task
|
||||
type: llm
|
||||
instructions: |
|
||||
You are a task parser. If a field cannot be determined, use a sensible
|
||||
default (empty array, null, or "medium" for priority).
|
||||
prompt: 'Parse this task description: "{{raw_task}}"'
|
||||
tools: []
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
action: { type: string }
|
||||
items:
|
||||
type: array
|
||||
items: { type: string }
|
||||
time_minutes: { type: ["integer", "null"] }
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, medium, high]
|
||||
details:
|
||||
type: object
|
||||
properties:
|
||||
urgent: { type: boolean }
|
||||
deadline: { type: ["string", "null"] }
|
||||
required: [urgent]
|
||||
required: [action, items, priority, details]
|
||||
next: done
|
||||
|
||||
done:
|
||||
id: done
|
||||
type: end
|
||||
output: |
|
||||
Action: {{action}}
|
||||
Priority: {{priority}}
|
||||
Time: {{time_minutes}} min
|
||||
Urgent? {{details.urgent}}
|
||||
First item: {{items[0]}}
|
||||
All items: {{items}}
|
||||
```
|
||||
|
||||
With the sample input `Buy groceries: milk, eggs, bread. About 15 minutes. Urgent.`
|
||||
|
||||
Sample state after `extract_task`:
|
||||
|
||||
```json
|
||||
{
|
||||
"raw_task": "Buy groceries: milk, eggs, bread. About 15 minutes. Urgent.",
|
||||
"action": "buy",
|
||||
"items": ["milk", "eggs", "bread"],
|
||||
"time_minutes": 15,
|
||||
"priority": "high",
|
||||
"details": { "urgent": true, "deadline": null }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Validation
|
||||
|
||||
When `validate_before_run: true` (the default), Loki validates the graph at
|
||||
startup.
|
||||
|
||||
**Errors (abort startup)**:
|
||||
|
||||
- Start node missing or pointing to a non-existent node
|
||||
- Any `next` / `routes` / `fallback` / `on_other` target pointing to a
|
||||
non-existent node
|
||||
- Any cycle in declared static edges (cycles are always errors. The
|
||||
per-node `max_loop_iterations` is a runtime safety net for dynamically-
|
||||
routed loops, not a license for static cycles)
|
||||
- Graph has zero `end` nodes. Execution would never terminate
|
||||
- `approval` option without a matching `routes` entry
|
||||
- `script` file path does not exist relative to the agent's directory
|
||||
- `agent` node references an agent name that doesn't exist in the
|
||||
loki agents directory, or that exists but has neither a `config.yaml`
|
||||
nor a `graph.yaml`
|
||||
- `rag` node with no `documents` (at least one knowledge source is required)
|
||||
- `llm` node referencing an unknown tool or `mcp:<server>` in its `tools`
|
||||
whitelist, or an unknown `model`. Validated against the agent's tool,
|
||||
MCP-server, and model sets
|
||||
|
||||
**Warnings (printed, execution continues)**:
|
||||
|
||||
- Any node unreachable from the start via declared static edges
|
||||
- No `end` node reachable from the start via declared static edges
|
||||
- `approval` `routes` entry without a matching option
|
||||
- `rag` node with no `state_updates` (its retrieval result goes nowhere)
|
||||
|
||||
> **Why some of these are warnings and not errors:** the validator only
|
||||
> follows **declared static edges** (`next`, `routes`, `fallback`,
|
||||
> `on_other`). Script nodes can also route dynamically at
|
||||
> runtime via `_next` in their JSON output, and those edges are invisible
|
||||
> to static analysis. To avoid false positives against dynamically-routed
|
||||
> graphs, "unreachable" and "no reachable end" are reported as warnings,
|
||||
> not errors.
|
||||
|
||||
---
|
||||
|
||||
# Invocation Entry Points
|
||||
|
||||
A graph agent can be entered from three places, all of which seed the
|
||||
caller's prompt into state as `{{initial_prompt}}`:
|
||||
|
||||
1. **Top-level CLI:** `loki -a my-graph-agent "user prompt here"`
|
||||
2. **REPL:** When the active agent has a `graph.yaml`, every user
|
||||
message in the REPL runs the graph fresh; the message becomes
|
||||
`{{initial_prompt}}`
|
||||
3. **Child-agent spawn:** When another (graph or normal) agent invokes
|
||||
this one via Loki's sub-agent mechanism, the parent's request becomes
|
||||
`{{initial_prompt}}` for the child graph
|
||||
|
||||
After the graph finishes, any sub-agents this graph spawned via
|
||||
`agent`-type nodes are cancelled, so a graph cannot leak background tool
|
||||
loops. The graph's final `end` node output is what's returned to the
|
||||
caller.
|
||||
|
||||
---
|
||||
|
||||
# Streaming and Observability
|
||||
|
||||
Graph execution has two observability channels:
|
||||
|
||||
**1. stderr narration:** Dimmed `▸` lines you follow along with in real
|
||||
time, regardless of log level:
|
||||
|
||||
```
|
||||
▸ graph: my-agent (start: extract_task)
|
||||
▸ extract_task (llm)
|
||||
▸ llm call: model=<active> tools=<none>
|
||||
▸ extract_task -> done
|
||||
▸ done (end)
|
||||
▸ graph done in 2.41s
|
||||
```
|
||||
|
||||
**2. `tracing` logs:** Structured `info!`/`debug!`/`warn!`/`error!`
|
||||
records gated by `RUST_LOG` (see [Configuration](#configuration) below).
|
||||
This is the developer-facing channel and includes:
|
||||
|
||||
- Graph start / completion / failure
|
||||
- Per-node entry and routing decisions (`debug`)
|
||||
- A **performance summary** at completion — every node's visit count,
|
||||
total/avg/max wall-clock time, slowest first:
|
||||
```
|
||||
[graph:my-agent] performance summary (slowest first):
|
||||
[graph:my-agent] deep_research: 1 visit(s), total 8200ms, avg 8200ms, max 8200ms
|
||||
[graph:my-agent] extract_task: 1 visit(s), total 1400ms, avg 1400ms, max 1400ms
|
||||
```
|
||||
|
||||
**State snapshots**: when `log_state_snapshots: true` (the default), before
|
||||
each node runs Loki logs the state's byte size and key list at `debug`
|
||||
level, and the *full* state at `trace` level. The full state is
|
||||
deliberately kept at `trace` because graph state can contain secrets so
|
||||
be careful sharing `trace`-level logs.
|
||||
|
||||
## Configuration
|
||||
|
||||
Control the `tracing` channel with `RUST_LOG`:
|
||||
|
||||
```sh
|
||||
RUST_LOG=loki::graph=debug loki -a my-agent "..." # graph debug logs
|
||||
RUST_LOG=loki::graph=trace loki -a my-agent "..." # + full state snapshots
|
||||
RUST_LOG=loki::graph=info loki -a my-agent "..." # start/end/perf summary
|
||||
```
|
||||
|
||||
The stderr `▸` narration is always shown and is not affected by `RUST_LOG`.
|
||||
|
||||
---
|
||||
|
||||
# Limitations / Gotchas
|
||||
|
||||
A short, honest list of things that bite people:
|
||||
|
||||
- **A graph agent is `graph.yaml`-only**. It must not also have a
|
||||
`config.yaml`. Both files present is a hard load error.
|
||||
- **Graph agents do not support sessions**. A graph manages its own state
|
||||
(`GraphState`), so there is no conversational history to persist.
|
||||
Explicitly requesting a session is a hard error. `--session` on the
|
||||
CLI, a session name passed to `.agent` in the REPL, or running
|
||||
`.session` while inside a graph agent. Any app-level `agent_session`
|
||||
default is silently skipped for graph agents rather than applied.
|
||||
- **RAG is per-node, not agent-wide**. Graph agents do RAG via `rag`
|
||||
nodes (each with its own knowledge base); there is no agent-wide
|
||||
`documents` field at the `graph.yaml` top level.
|
||||
- **A `rag` node's knowledge base is built once, at load time**. Changing
|
||||
a `rag` node's `documents` does not rebuild it. Delete
|
||||
`<agent-dir>/<node-id>.yaml` to force a fresh build on next run.
|
||||
- **`on_other` is required on every `approval` node** because `user__ask`
|
||||
always permits free-form responses (see [the approval section](#approval)).
|
||||
- **`validation` on `input` nodes is length-only**. The grammar is
|
||||
`len(input) <op> <integer>` with `<op>` in `> >= < <= ==`. No regex, no
|
||||
type coercion, no range checks. Use a follow-up `script` node for richer
|
||||
validation.
|
||||
- **An `input` node's `default` is not re-validated.** When the user
|
||||
submits an empty response and the `default` is substituted in, that
|
||||
substituted value is *not* checked against `validation`. Make sure any
|
||||
`default` you set would itself satisfy the `validation` predicate.
|
||||
- **Tool whitelist is `llm`-only**. `agent` nodes always use the child
|
||||
agent's full tool universe. They ignore any `tools:` field. This is by
|
||||
design: child agents own their tool surface.
|
||||
- **`{{output}}`, `{{choice}}`, `{{input}}` are scoped to `state_updates`**.
|
||||
Outside `state_updates` (e.g. in another node's `prompt`), these
|
||||
scoped variables are not available unless the previous node explicitly
|
||||
stored them via `state_updates`. `end` nodes do NOT get a scoped
|
||||
`{{output}}`. They have no node body output to scope.
|
||||
- **Schema-hint auto-injection happens for `llm` nodes only**, not
|
||||
`agent` nodes (see [Structured Output](#structured-output-output_schema)).
|
||||
- **Script-output JSON must be an object**, not an array or primitive,
|
||||
even if you only want to set `_next`.
|
||||
- **Cycles in declared static edges are always errors**. The per-node
|
||||
`max_loop_iterations` is a runtime *safety net* for cycles built via
|
||||
dynamic `script._next` routing, not permission to write static cycles.
|
||||
- **Schema version is fixed at `"1.0"`** today. Any other value is a
|
||||
startup error.
|
||||
- **Script extensions are exactly `.sh`, `.py`, `.ts`**. No JavaScript,
|
||||
no Ruby, no Lua. Python must be available as `python3` and TypeScript
|
||||
requires `npx tsx` on PATH.
|
||||
|
||||
---
|
||||
|
||||
# See Also
|
||||
|
||||
- [`graph.example.yaml`](https://github.com/Dark-Alex-17/loki/blob/main/graph.example.yaml) - A fully-commented, full-featured reference
|
||||
graph agent at the root of the Loki repository (every top-level field,
|
||||
every node type).
|
||||
- [Agents](Agents) - non-graph agent system (config.yaml + LLM loop)
|
||||
- [Custom Tools](Custom-Tools) - building `tools.sh` / `tools.py` /
|
||||
`tools.ts` files for use in graph nodes
|
||||
- [Roles](Roles) - note that the built-in `__structured_output__` role used
|
||||
by `output_schema` is intentionally internal and is not user-visible
|
||||
- [MCP Servers](MCP-Servers) - `mcp:<server>` shorthand inside an `llm`
|
||||
node's `tools:` whitelist
|
||||
+5
@@ -34,6 +34,11 @@
|
||||
- [Sub-Agent Spawning](Agents#7-sub-agent-spawning-system)
|
||||
- [User Interaction Tools](Agents#8-user-interaction-tools)
|
||||
- [Built-In Agents](Agents#built-in-agents)
|
||||
- [Graph Agents](Graph-Agents)
|
||||
- [Node Types](Graph-Agents#node-types)
|
||||
- [State & Templates](Graph-Agents#state-and-template-syntax)
|
||||
- [Structured Output](Graph-Agents#structured-output-output_schema)
|
||||
- [Limitations](Graph-Agents#limitations--gotchas)
|
||||
|
||||
## Knowledge & Automation
|
||||
- [RAG](RAG)
|
||||
|
||||
Reference in New Issue
Block a user