Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions docs/multi-agent-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,109 @@ const edges = [
- Parallel edges create direct graph connections (no tools)

4. **Performance**: Parallel execution can significantly speed up processing when agents perform independent work.

## Conversation-scoped handoffs (host opt-in)

An ordinary handoff changes execution within the current turn only. Set
`handoffScope: 'conversation'` on a handoff edge to request that a host continue
future user messages with the destination. This is trusted graph configuration,
not a model argument. It does **not** change a conversation or mutate an agent.

```typescript
const run = await Run.create({
runId: responseMessageId,
graphConfig: {
type: 'multi-agent',
agents,
edges: [
{
from: 'router',
to: 'specialist',
edgeType: 'handoff',
handoffScope: 'conversation',
},
],
entryAgentId: 'router',
maxHandoffs: 8,
compileOptions: { checkpointer },
},
});
await run.processStream({ messages }, config);
const outcome = run.getHandoffOutcome();
if (outcome?.status === 'candidate') {
// Host responsibility: authorize the target and commit with generation/revision
// fencing. Only publish a changed client selection after the write succeeds.
await commitAuthorizedConversationAgent(outcome);
}
```

`getHandoffOutcome()` is available after streaming, including normal cleanup:

- `candidate`: the last explicit conversation-scoped handoff in a successful,
unambiguous top-level run. Includes `agentId` and an idempotent `transitionId`.
- `unchanged`: no conversation-scoped handoff occurred.
- `ambiguous`: there were conversation-scoped handoffs and parallel execution.
There is no timing-based winner. Multiple inferred starting nodes and graphs
containing direct fan-out conservatively prevent promotion, even if a dynamic
handoff bypassed that fan-out.
- `incomplete`: interruption, cancellation, failure, budget exhaustion, a child
scope, or incomplete legacy checkpoint provenance. Never auto-promote it.

Each outcome carries a logical-turn `executionId`, its initial `entryAgentId`,
and structured admitted `transitions`. These are execution facts, not proof of a
host-side database commit. A pause can contain already-admitted transitions but
still cannot produce a promotable candidate. Standard (non-multi-agent) runs
return `undefined`.

For A ⇒ B → C, where ⇒ is conversation-scoped and → is turn-scoped, the
candidate is B. For A ⇒ B ⇒ C it is C. Direct edges never promote their targets.
Isolated subagents do not inherit the parent's routing owner or handoff budget.

### Starting the next user turn

After the host commits B, construct a **fresh** run with `entryAgentId: 'B'`.
The explicit entry overrides topology-inferred roots, without changing the saved
edges or relying on agent array order. Only nodes reachable from that entry are
compiled. A grouped direct edge requiring an unreachable predecessor is rejected
rather than starting a workflow that cannot satisfy its join. Without an explicit
entry, existing inferred starting-node behavior is preserved.

Do not change entry or budget to resume a paused run. Rebuild its original graph,
with the same checkpointer and checkpoint namespace, then use `Run.resume()` or
`processStream(new Command({ resume: value }), config)`. The checkpoint records
routing state independently of displayed messages. Resume checks entry and budget
compatibility, preserves admitted transitions, and does not charge a replay twice.
A fresh `processStream({ messages }, ...)` resets the routing ledger, including
when reusing a checkpointed thread. Stop-hook continuations share its budget.

Never reconstruct routing state from transfer tool names, transcript order, or
agent-update events. Never accept client-supplied `handoffState` as authoritative.
Hosts remain responsible for rebuilding the original graph configuration and
serializing ownership of concurrent requests/resumes for a conversation.

### Bounding handoffs

`maxHandoffs` is an optional non-negative safe integer, shared by all members and
parallel branches of this graph within one logical turn. Zero forbids handoffs.
Absent preserves the existing recursion-only behavior. Set it independently of
`recursionLimit`: ordinary tool calls are not handoffs.

The budget is checked after the tool batch settles and before its routing Commands
schedule recipients. An oversized batch is rejected in full with the exported
`HandoffLimitError`; `getHaltReason()` reports `handoff_limit` and the outcome is
`incomplete`. Already executed ordinary sibling tools are not rolled back. False
conditional transfers consume no budget. Cycles are allowed, but a configured
finite budget bounds them. There is no conversation-lifetime cap.

### Checkpoint compatibility and rollout

Old hosts may ignore the additive outcome API and retain current behavior. They
must not enable automatic conversation switching until persistence, authorization,
and client reconciliation are implemented.

A new SDK can resume a legacy checkpoint without a handoff budget, but reports
`incomplete` / `legacy_checkpoint` rather than infer a candidate from partial
history. Enabling a budget on such a checkpoint fails closed because earlier
handoffs cannot be counted reliably. Finish that legacy turn before enabling the
feature. Rollback must not send feature-enabled paused runs to older SDK workers
that do not enforce their checkpointed budget.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@librechat/agents",
"version": "3.9.2",
"version": "3.9.3",
"reova": {
"enabled": true,
"endpoint": "https://telemetry.reo.dev/data"
Expand Down
8 changes: 8 additions & 0 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
} from '@/graphs/graphFactory';
import type { OverflowRecoveryPlan } from '@/llm/contextOverflowRecovery';
import type { FallbackErrorContext } from '@/llm/invoke';
import type { HandoffRouting } from './handoff';
import type { HookRegistry } from '@/hooks';
import type * as t from '@/types';
import {
Expand Down Expand Up @@ -185,6 +186,7 @@ import { getTruncationStopReason } from '@/llm/truncation';
import { createSchemaOnlyTools } from '@/tools/schema';
import { AgentContext } from '@/agents/AgentContext';
import { createFakeStreamingLLM } from '@/llm/fake';
import { handoffStateAnnotation } from './handoff';
import { handleToolCalls } from '@/tools/handlers';
import { isThinkingEnabled } from '@/llm/request';
import { resolveMaxSeals } from '@/llm/preempt';
Expand Down Expand Up @@ -1354,6 +1356,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
subagentUsageSink?: t.SubagentUsageSink;
/** See {@link t.StandardGraphInput.subagentScope}. */
subagentScope: boolean;
handoffRouting?: HandoffRouting;
/** See {@link t.StandardGraphInput.subagentTasks}. */
subagentTasks: t.SubagentTaskConfig | undefined;
/** See {@link t.StandardGraphInput.subagentExecutionContext}. */
Expand Down Expand Up @@ -2871,6 +2874,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
}

const node = new CustomToolNode<t.BaseGraphState>({
handoffRouting: this.handoffRouting,
tools: allTools,
toolMap: allToolMap,
trace: traceToolNode,
Expand Down Expand Up @@ -2960,6 +2964,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
: currentToolMap;

const node = new CustomToolNode<t.BaseGraphState>({
handoffRouting: this.handoffRouting,
tools: allTraditionalTools,
toolMap: traditionalToolMap,
trace: traceToolNode,
Expand Down Expand Up @@ -5434,6 +5439,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
): Promise<Partial<t.AgentSubgraphState>> => {
this.config = config;
this.restoreRunStepResumeState(state.runStepState);
this.handoffRouting?.restore(state.handoffState);
const result = await invoke();
/** An ordinary run on a checkpointed thread inherits the last
* compaction's summary in state; it is not this run's output. */
Expand Down Expand Up @@ -5512,6 +5518,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
default: () => undefined,
}),
runStepState: this.createRunStepStateAnnotation(),
handoffState: handoffStateAnnotation(),
});

const readChargeCredits = ():
Expand Down Expand Up @@ -5727,6 +5734,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
default: () => undefined,
}),
runStepState: this.createRunStepStateAnnotation(),
handoffState: handoffStateAnnotation(),
});
const compactingAgentNode = async (
state: t.AgentSubgraphState,
Expand Down
Loading
Loading