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
|
* [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
|
* [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
|
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
|
* `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`.
|
* `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
|
* `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)
|
- [Sub-Agent Spawning](Agents#7-sub-agent-spawning-system)
|
||||||
- [User Interaction Tools](Agents#8-user-interaction-tools)
|
- [User Interaction Tools](Agents#8-user-interaction-tools)
|
||||||
- [Built-In Agents](Agents#built-in-agents)
|
- [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
|
## Knowledge & Automation
|
||||||
- [RAG](RAG)
|
- [RAG](RAG)
|
||||||
|
|||||||
Reference in New Issue
Block a user