Multi-agent
Agents that call other agents, and what that costs.
It is the same file the platform's own tests are written against — LGFlow/docs/guides/multi-agent.md — rendered here rather than copied, so it cannot fall behind the software it describes. It is written for somebody working on the platform and uses the internal project names: LGFlow is Symphonix Core, LGStudio is Symphonix Studio, and LGMonitor or LGStudioMonitor is the Symphonix Control Panel. Names that begin LGFlow and appear in code — LGFlowValidationError and the rest — are exported identifiers and are spelled exactly as the software spells them.
v0.19.
One flow can only be about one thing. Ask a single agent to answer questions about the database, the company's documents and the company itself and its prompt becomes a list of apologies. Three flows, each good at one thing, and a fourth that decides which is which — that is orchestration, and v0.19 is the smallest thing that makes it work.
Agent = Flow + Contract#
There is no new kind of file, and no second runtime. An agent is a flow, and what makes it callable is the contract it has declared since v0.7:
{
"description": "Answers a question about the relational database.",
"inputs": ["question"],
"outputs": ["answer", "queries"]
}That is the call signature. An orchestrator picks between agents by reading their descriptions, calls one with its declared inputs, and gets back its declared outputs — nothing more. The flow's other channels (the schema it read, the rows it fetched, the calls it queued) are its own and stay its own.
Registering a flow with no description, or no outputs, is refused. An agent that cannot say what it does cannot be chosen; one that gives nothing back is not worth choosing.
The registry is the project's, not the flow's#
A flow says it may call sql. Where sql lives is the deployment's business:
// registry.json
{
"agents": {
"sql": { "flow": "./agents/sql/flow.json" },
"documents": { "flow": "./agents/documents/flow.json" },
"general": { "flow": "./agents/general/flow.json" }
}
}import { buildGraph, loadFlow, loadAgentRegistry } from "lgflow";
const agents = await loadAgentRegistry("./registry.json");
const graph = buildGraph(await loadFlow("./flow.json"), handlers, { llm, agents });This is the same split the DSL already makes for requires.env,
requires.services and requires.settings: the flow names a capability and the
deployment supplies it. Put the paths in the flow and the flow breaks the moment
the folder is laid out differently.
Each entry's description comes from the flow's own unless the entry overrides it — one description, in the flow, rather than two that drift.
Delegating#
{ "id": "delegate", "type": "agent",
"agents": ["sql", "documents", "general"],
"agent": "{{decision.agent}}",
"input": "{{decision.input}}",
"output": "agent_result" }agentsis the permission. It is required, and a node without it may call nothing. A name outside the list is refused whether or not the project registers such an agent — "there is no such agent" and *"that one is not yours"* are different answers, and the caller is told which.agentis usually one template reading whatever a router wrote. A fixed name works too, for a child that never changes — it must still be listed.inputis checked against the child's contract before the child is built: every declared input must be there and hold something, and anything undeclared is refused by name.outputis one channel holding the agent's declared outputs plusagent, naming who answered. One channel rather than a mapping, because a flow that will not know which agent it called until the run happens cannot name that agent's channels when it is written.
Choosing is an ordinary llm node#
There is no router kind for this and no free text to parse. A v0.15 llm node with a schema produces the decision, and the agent node acts on it:
{ "id": "decide", "type": "llm", "output": "decision",
"prompt": "The desks:\n{{catalogue}}\n\nThe question: {{question}}",
"schema": {
"type": "object",
"properties": {
"agent": { "type": "string", "description": "sql, documents or general." },
"input": { "type": "object", "description": "What to ask that desk.",
"properties": { "question": { "type": "string", "description": "The question." } },
"required": ["question"] }
},
"required": ["agent", "input"]
} }Fill catalogue from the registry with describeAgents, narrowed to the same
list the node allows — an agent the flow may not call should not be offered to
the model either, or it spends its turn choosing something that will be refused.
flow or agent?#
flow node | agent node | |
|---|---|---|
| which child | known when the flow is written | chosen while the run happens |
| named by | an address, relative to the file | a name, resolved by the registry |
| checked | when the flow is validated | when the call is made |
| output | mapped channel by channel | one channel, the contract's outputs |
Underneath they are the same execution — the same compiler, the same state
isolation, the same prefixing of steps and model calls, the same handling of a
child that stops to ask the person something. Use flow whenever you know
which child it is: it is checked earlier and reads plainer. Reach for agent
when the choice is the point.
Security#
Agent names are capabilities. Four things hold, in this order, every call:
- the chosen name is a name — letters, digits,
-,_— never an address; - it is in the node's own
agentslist; - it is registered;
- it is not already running above this call.
A model never produces a path and nothing turns a name into one. A registry
lookup is a Map.get on a name that has already been checked, so nothing a
model writes can reach a file, a prototype key or an unregistered entry.
Nothing falls back. An orchestrator that asked for the payments desk and silently got the general desk would be worse than one that stopped, because the answer would look right.
Keep the allow-list to what the flow actually needs. A registry may hold
payments and admin; a research orchestrator that lists them can be talked
into using them.
Deploying one#
generateProject resolves the registry before it writes anything — from a
registry.json beside the flow, or above it — and then carries what it finds:
- every agent the flow may reach, copied to
agents/<name>/flow.json, including the ones only another agent reaches; - a
registry.jsonwhose addresses point at those copies; - each agent's code, under
src/agents/<name>/, registered as<the node>/<the agent>.<handler>— which is wherechildRunnerlooks. A desk that delegates again nests the same way:hand_over/billing.escalate/refunds.decideRefund; - a
src/agent.jsthat builds the graph with that registry.
Code is generated for every name on an agent node's list, not only the one a run happened to pick — which it picks is decided while running, so a project with code for one of them fails the first time the model chooses another.
A flow that delegates and has no registry to resolve it cannot become a project that runs, so generation refuses it rather than writing a folder that fails on its first delegation.
Recursion#
v0.19 has no budget or depth policy — that is a later version. What it does have
is the one guard that matters: the chain of agents currently running is carried
down, and an agent already in it is refused with AGENT_RECURSION rather than
looping until something else gives way. The error names the whole circle:
orders -> billing -> orders.
What a watcher is told (v0.21)#
The refusal above has always worked. What it did not do was say so in a way anybody watching a fleet could act on, and two things changed so that it does:
- It is its own kind of failure. The run reports
failure: "delegation"rather thanagent. They are different things to go and look at: a delegated desk that threw is the child's problem and may work next time, and a flow that can delegate round a circle is wrong by construction and will take that route again. A board can act on one occurrence of the second. - It travels up as itself. Everything else a child does wrong is wrapped as
AGENT_FAILEDat each level on its way out - true, and what a caller needs, because the child is where to look. A circle is not like that: no one level is at fault, every agent in it delegated exactly as its own flow says to, and wrapping it threw away the one thing anybody could act on. SoAGENT_RECURSIONis rethrown unchanged, and the run's exit carrieschain, the circle as a list of names.
See Watching agents for the capability a board asks for before it believes a quiet answer.
Reading the run back#
A flat trace of an orchestrator plus three agents is a pile. So each invocation
is traced as an enter/exit pair carrying invocationId, runId, parentRunId,
the agent's name, the duration and whether it worked; every step inside carries
the child's runId and is named <node>/<agent>/<their node>.
import { agentTree, describeAgentTree } from "lgflow";
const events = [];
const graph = buildGraph(flow, handlers, { llm, agents, trace: (e) => events.push(e) });
await graph.invoke({ question });
console.log(describeAgentTree(agentTree(events)));list_desks
decide
delegate -> sql (ok, 11ms)
write_query
run_query
say
replyagentTree returns the tree as data — each step either { type: "node", id }
or { type: "agent", agent, node, runId, parentRunId, invocationId, ok,
durationMs, error?, steps }. agentsIn(tree) flattens the invocations when all
you want is "who was called". A single-flow trace reads as a tree of one, so
nothing that existed before changes shape.
Errors#
Every refusal is an LGFlowAgentError with a code:
| code | when |
|---|---|
INVALID_REGISTRY | the registry file is not one, or the graph was built without it |
INVALID_REGISTRY_ENTRY | an entry names no flow, or its name is not a name |
DUPLICATE_AGENT | one name registered twice |
AGENT_FLOW_UNREADABLE | the flow an entry names cannot be read |
AGENT_FLOW_INVALID | it is not a valid flow, or has no contract to call |
AGENT_DECISION_INVALID | what was chosen is not a name at all |
AGENT_NOT_PERMITTED | a name outside this node's list |
UNKNOWN_AGENT | a name nothing registered |
AGENT_RECURSION | that agent is already running above this call — not re-wrapped as AGENT_FAILED on its way out, unlike every other failure below it |
AGENT_INPUT_INVALID | the input does not fit the child's contract |
AGENT_FAILED | the child ran and threw |
AGENT_OUTPUT_INVALID | the child wrote none of its declared outputs |
The examples#
examples/escalation-desk/ is the fuller one: a static flow node beside a
dynamic agent node in the same flow, a billing desk that delegates again to
refunds or invoices, and a desk the front desk may not call being refused by
name and routed to an apology by onError.
node examples/escalation-desk/agent.js "I was charged twice for order 10248"
node examples/escalation-desk/agent.js --send-to refunds "I was charged twice"hand_over -> billing (ok, 12ms)
sort
escalate -> refunds (ok, 5ms)
decide
summariseexamples/agent-orchestrator/ is the smaller one - one delegation, three
desks - in forty lines of handlers:
node examples/agent-orchestrator/agent.js "Who are our largest customers?"
node examples/agent-orchestrator/agent.js "Summarise the refund policy."
node examples/agent-orchestrator/agent.js "What does this company do?"It runs without a key — there is a small stand-in model for the two llm nodes —
so the orchestration can be watched on its own. Its registry deliberately
includes a payments desk that the orchestrator's agents list does not, to
show that being registered is not the same as being allowed.
What v0.19 is not#
No scheduling, events, webhooks or triggers. No parallel delegation. No free-form agent-to-agent conversation — delegation has a beginning, a structured request, a structured result and an end, and that is what makes it inspectable. No budgets or depth policies beyond the recursion guard. Those are later versions, or they are not coming.