Symphonix Documentation · Core 0.61.0

Multi-agent

Agents that call other agents, and what that costs.

This is the engineering reference, rendered from the repository

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" }

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 nodeagent node
which childknown when the flow is writtenchosen while the run happens
named byan address, relative to the filea name, resolved by the registry
checkedwhen the flow is validatedwhen the call is made
outputmapped channel by channelone 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:

  1. the chosen name is a name — letters, digits, -, _ — never an address;
  2. it is in the node's own agents list;
  3. it is registered;
  4. 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:

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:

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
reply

agentTree 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:

codewhen
INVALID_REGISTRYthe registry file is not one, or the graph was built without it
INVALID_REGISTRY_ENTRYan entry names no flow, or its name is not a name
DUPLICATE_AGENTone name registered twice
AGENT_FLOW_UNREADABLEthe flow an entry names cannot be read
AGENT_FLOW_INVALIDit is not a valid flow, or has no contract to call
AGENT_DECISION_INVALIDwhat was chosen is not a name at all
AGENT_NOT_PERMITTEDa name outside this node's list
UNKNOWN_AGENTa name nothing registered
AGENT_RECURSIONthat 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_INVALIDthe input does not fit the child's contract
AGENT_FAILEDthe child ran and threw
AGENT_OUTPUT_INVALIDthe 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
  summarise

examples/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.