@illuma-ai/agents 1.1.25 → 1.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/agents/AgentContext.cjs +20 -3
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +5 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/HandoffRegistry.cjs +104 -0
- package/dist/cjs/graphs/HandoffRegistry.cjs.map +1 -0
- package/dist/cjs/graphs/MultiAgentGraph.cjs +224 -47
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/stream.cjs +4 -4
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/types/graph.cjs.map +1 -1
- package/dist/cjs/utils/events.cjs +3 -0
- package/dist/cjs/utils/events.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +20 -3
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +5 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/HandoffRegistry.mjs +102 -0
- package/dist/esm/graphs/HandoffRegistry.mjs.map +1 -0
- package/dist/esm/graphs/MultiAgentGraph.mjs +224 -47
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/stream.mjs +4 -4
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/esm/utils/events.mjs +3 -0
- package/dist/esm/utils/events.mjs.map +1 -1
- package/dist/types/graphs/HandoffRegistry.d.ts +80 -0
- package/dist/types/graphs/MultiAgentGraph.d.ts +23 -3
- package/dist/types/graphs/index.d.ts +1 -0
- package/dist/types/types/graph.d.ts +6 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +20 -5
- package/src/graphs/Graph.ts +8 -0
- package/src/graphs/HandoffRegistry.ts +168 -0
- package/src/graphs/MultiAgentGraph.ts +274 -67
- package/src/graphs/__tests__/HandoffRegistry.test.ts +407 -0
- package/src/graphs/index.ts +1 -0
- package/src/stream.ts +4 -6
- package/src/tools/approval/__tests__/constants.test.ts +3 -3
- package/src/types/graph.ts +6 -0
- package/src/utils/events.ts +3 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for async handoff execution.
|
|
3
|
+
*
|
|
4
|
+
* Enables the OpenClaw-style autonomous orchestration pattern:
|
|
5
|
+
* 1. Orchestrator spawns children (non-blocking)
|
|
6
|
+
* 2. Orchestrator stays alive to reason, spawn more, or check status
|
|
7
|
+
* 3. Orchestrator collects results when ready
|
|
8
|
+
*
|
|
9
|
+
* Scoped per MultiAgentGraph instance — each orchestrator graph gets its own registry.
|
|
10
|
+
*/
|
|
11
|
+
class HandoffRegistry {
|
|
12
|
+
records = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* Register a spawned handoff child.
|
|
15
|
+
* The promise runs in the background — not awaited here.
|
|
16
|
+
*/
|
|
17
|
+
spawn(params) {
|
|
18
|
+
const record = {
|
|
19
|
+
id: params.id,
|
|
20
|
+
name: params.name,
|
|
21
|
+
task: params.task,
|
|
22
|
+
spawnedAt: Date.now(),
|
|
23
|
+
status: 'running',
|
|
24
|
+
promise: params.promise,
|
|
25
|
+
};
|
|
26
|
+
// Wire up the promise to update the record on completion
|
|
27
|
+
params.promise
|
|
28
|
+
.then((result) => {
|
|
29
|
+
const resultText = params.extractResult(result.messages, params.id);
|
|
30
|
+
const truncated = params.truncateResult(resultText, params.maxResultChars);
|
|
31
|
+
record.status = 'completed';
|
|
32
|
+
record.resultText = truncated;
|
|
33
|
+
record.durationMs = Date.now() - record.spawnedAt;
|
|
34
|
+
record.resultMessageCount = result.messages.length;
|
|
35
|
+
params.onComplete?.(record);
|
|
36
|
+
})
|
|
37
|
+
.catch((err) => {
|
|
38
|
+
record.status = 'failed';
|
|
39
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
40
|
+
record.durationMs = Date.now() - record.spawnedAt;
|
|
41
|
+
params.onComplete?.(record);
|
|
42
|
+
});
|
|
43
|
+
this.records.set(params.id, record);
|
|
44
|
+
}
|
|
45
|
+
/** List all pending (running) handoffs */
|
|
46
|
+
listPending() {
|
|
47
|
+
return Array.from(this.records.values()).filter((r) => r.status === 'running');
|
|
48
|
+
}
|
|
49
|
+
/** List all completed handoffs (not yet collected) */
|
|
50
|
+
listCompleted() {
|
|
51
|
+
return Array.from(this.records.values()).filter((r) => r.status === 'completed' || r.status === 'failed');
|
|
52
|
+
}
|
|
53
|
+
/** List all handoffs regardless of status */
|
|
54
|
+
listAll() {
|
|
55
|
+
return Array.from(this.records.values());
|
|
56
|
+
}
|
|
57
|
+
/** Get a specific handoff by ID */
|
|
58
|
+
get(id) {
|
|
59
|
+
return this.records.get(id);
|
|
60
|
+
}
|
|
61
|
+
/** Check if any handoffs are still running */
|
|
62
|
+
hasPending() {
|
|
63
|
+
return this.listPending().length > 0;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Wait for ALL pending handoffs to complete.
|
|
67
|
+
* Returns all completed records (including previously completed ones).
|
|
68
|
+
*/
|
|
69
|
+
async waitForAll() {
|
|
70
|
+
const pending = this.listPending();
|
|
71
|
+
if (pending.length > 0) {
|
|
72
|
+
await Promise.allSettled(pending.map((r) => r.promise));
|
|
73
|
+
}
|
|
74
|
+
return this.listAll();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Wait for ANY pending handoff to complete.
|
|
78
|
+
* Returns the newly completed record(s).
|
|
79
|
+
*/
|
|
80
|
+
async waitForAny() {
|
|
81
|
+
const pending = this.listPending();
|
|
82
|
+
if (pending.length === 0) {
|
|
83
|
+
return this.listCompleted();
|
|
84
|
+
}
|
|
85
|
+
// Race all pending promises — at least one will resolve
|
|
86
|
+
await Promise.race(pending.map((r) => r.promise.then(() => r).catch(() => r)));
|
|
87
|
+
// Small yield to let promise handlers update records
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
89
|
+
return this.listCompleted();
|
|
90
|
+
}
|
|
91
|
+
/** Clear all records (for cleanup between graph invocations) */
|
|
92
|
+
clear() {
|
|
93
|
+
this.records.clear();
|
|
94
|
+
}
|
|
95
|
+
/** Number of total tracked handoffs */
|
|
96
|
+
get size() {
|
|
97
|
+
return this.records.size;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { HandoffRegistry };
|
|
102
|
+
//# sourceMappingURL=HandoffRegistry.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"HandoffRegistry.mjs","sources":["../../../src/graphs/HandoffRegistry.ts"],"sourcesContent":["import type { BaseMessage } from '@langchain/core/messages';\nimport type * as t from '@/types';\n\n/**\n * Tracks the lifecycle of a spawned handoff child agent.\n * Mirrors OpenClaw's SubagentRunRecord pattern.\n */\nexport type HandoffRecord = {\n /** Unique handoff ID (destination agentId) */\n id: string;\n /** Display name of the child agent */\n name: string;\n /** Task description / instructions passed to child */\n task: string;\n /** When the handoff was spawned */\n spawnedAt: number;\n /** Current status */\n status: 'pending' | 'running' | 'completed' | 'failed';\n /** The background promise executing the child subgraph */\n promise: Promise<t.BaseGraphState>;\n /** Resolved result text (populated on completion) */\n resultText?: string;\n /** Error message (populated on failure) */\n error?: string;\n /** Duration in ms (populated on completion/failure) */\n durationMs?: number;\n /** Number of messages in child's output */\n resultMessageCount?: number;\n};\n\n/**\n * Registry for async handoff execution.\n *\n * Enables the OpenClaw-style autonomous orchestration pattern:\n * 1. Orchestrator spawns children (non-blocking)\n * 2. Orchestrator stays alive to reason, spawn more, or check status\n * 3. Orchestrator collects results when ready\n *\n * Scoped per MultiAgentGraph instance — each orchestrator graph gets its own registry.\n */\nexport class HandoffRegistry {\n private records: Map<string, HandoffRecord> = new Map();\n\n /**\n * Register a spawned handoff child.\n * The promise runs in the background — not awaited here.\n */\n spawn(params: {\n id: string;\n name: string;\n task: string;\n promise: Promise<t.BaseGraphState>;\n extractResult: (messages: BaseMessage[], agentId: string) => string;\n truncateResult: (text: string, maxChars: number) => string;\n maxResultChars: number;\n /** Callback when child completes (for SSE events) */\n onComplete?: (record: HandoffRecord) => void;\n }): void {\n const record: HandoffRecord = {\n id: params.id,\n name: params.name,\n task: params.task,\n spawnedAt: Date.now(),\n status: 'running',\n promise: params.promise,\n };\n\n // Wire up the promise to update the record on completion\n params.promise\n .then((result) => {\n const resultText = params.extractResult(\n result.messages,\n params.id\n );\n const truncated = params.truncateResult(\n resultText,\n params.maxResultChars\n );\n record.status = 'completed';\n record.resultText = truncated;\n record.durationMs = Date.now() - record.spawnedAt;\n record.resultMessageCount = result.messages.length;\n params.onComplete?.(record);\n })\n .catch((err) => {\n record.status = 'failed';\n record.error = err instanceof Error ? err.message : String(err);\n record.durationMs = Date.now() - record.spawnedAt;\n params.onComplete?.(record);\n });\n\n this.records.set(params.id, record);\n }\n\n /** List all pending (running) handoffs */\n listPending(): HandoffRecord[] {\n return Array.from(this.records.values()).filter(\n (r) => r.status === 'running'\n );\n }\n\n /** List all completed handoffs (not yet collected) */\n listCompleted(): HandoffRecord[] {\n return Array.from(this.records.values()).filter(\n (r) => r.status === 'completed' || r.status === 'failed'\n );\n }\n\n /** List all handoffs regardless of status */\n listAll(): HandoffRecord[] {\n return Array.from(this.records.values());\n }\n\n /** Get a specific handoff by ID */\n get(id: string): HandoffRecord | undefined {\n return this.records.get(id);\n }\n\n /** Check if any handoffs are still running */\n hasPending(): boolean {\n return this.listPending().length > 0;\n }\n\n /**\n * Wait for ALL pending handoffs to complete.\n * Returns all completed records (including previously completed ones).\n */\n async waitForAll(): Promise<HandoffRecord[]> {\n const pending = this.listPending();\n if (pending.length > 0) {\n await Promise.allSettled(pending.map((r) => r.promise));\n }\n return this.listAll();\n }\n\n /**\n * Wait for ANY pending handoff to complete.\n * Returns the newly completed record(s).\n */\n async waitForAny(): Promise<HandoffRecord[]> {\n const pending = this.listPending();\n if (pending.length === 0) {\n return this.listCompleted();\n }\n\n // Race all pending promises — at least one will resolve\n await Promise.race(\n pending.map((r) =>\n r.promise.then(() => r).catch(() => r)\n )\n );\n\n // Small yield to let promise handlers update records\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n return this.listCompleted();\n }\n\n /** Clear all records (for cleanup between graph invocations) */\n clear(): void {\n this.records.clear();\n }\n\n /** Number of total tracked handoffs */\n get size(): number {\n return this.records.size;\n }\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -11,6 +11,7 @@ import { summarize, createEmergencySummary } from '../messages/summarize.mjs';
|
|
|
11
11
|
import { StandardGraph } from './Graph.mjs';
|
|
12
12
|
import { safeDispatchCustomEvent } from '../utils/events.mjs';
|
|
13
13
|
import { getApprovalGateNodeId, createApprovalGateNode } from '../nodes/ApprovalGateNode.mjs';
|
|
14
|
+
import { HandoffRegistry } from './HandoffRegistry.mjs';
|
|
14
15
|
|
|
15
16
|
/** Pattern to extract instructions from transfer ToolMessage content */
|
|
16
17
|
const TRANSFER_INSTRUCTIONS_PATTERN = /(?:Instructions?|Context):\s*(.+)/is;
|
|
@@ -58,6 +59,12 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
58
59
|
* Used by auto-continuation to know which agent's context to preserve after handoff.
|
|
59
60
|
*/
|
|
60
61
|
lastActiveAgentId;
|
|
62
|
+
/**
|
|
63
|
+
* Registry for async handoff execution.
|
|
64
|
+
* Enables OpenClaw-style autonomous orchestration: spawn children non-blocking,
|
|
65
|
+
* orchestrator stays alive to reason and collect results when ready.
|
|
66
|
+
*/
|
|
67
|
+
handoffRegistry = new HandoffRegistry();
|
|
61
68
|
/**
|
|
62
69
|
* When set, the graph routes START to this agent instead of the default starting nodes.
|
|
63
70
|
* Enables multi-turn resumption: follow-up messages go to the agent that last handled
|
|
@@ -260,6 +267,21 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
260
267
|
}
|
|
261
268
|
agentContext.graphTools.push(...transferTools);
|
|
262
269
|
console.debug(`[MultiAgentGraph] Transfer tools for "${agentId}": [${transferTools.map((t) => t.name).join(', ')}]`);
|
|
270
|
+
// Inject orchestration guidance for agents with transfer tools
|
|
271
|
+
const childDescs = edges.flatMap((e) => {
|
|
272
|
+
const dests = Array.isArray(e.to) ? e.to : [e.to];
|
|
273
|
+
return dests.map((d) => {
|
|
274
|
+
const ctx = this.agentContexts.get(d);
|
|
275
|
+
const name = ctx?.name ?? d;
|
|
276
|
+
const desc = ctx?.description ? ` — ${ctx.description}` : '';
|
|
277
|
+
return `${name}${desc}`;
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
const guidance = this.buildOrchestratorGuidance(childDescs, transferTools.length);
|
|
281
|
+
const existing = agentContext.additionalInstructions ?? '';
|
|
282
|
+
agentContext.additionalInstructions = existing
|
|
283
|
+
? `${existing}\n\n${guidance}`
|
|
284
|
+
: guidance;
|
|
263
285
|
}
|
|
264
286
|
}
|
|
265
287
|
/**
|
|
@@ -453,6 +475,41 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
453
475
|
}
|
|
454
476
|
return tools;
|
|
455
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Builds orchestration guidance injected into the system message of agents
|
|
480
|
+
* that have handoff or transfer tools (i.e., orchestrator agents).
|
|
481
|
+
*
|
|
482
|
+
* Modeled after OpenClaw's battle-tested subagent orchestration patterns:
|
|
483
|
+
* - Push-based completion (results auto-return from child agents)
|
|
484
|
+
* - Multi-round execution for dependent tasks
|
|
485
|
+
* - Explicit rules against hallucinating data or acting on unavailable context
|
|
486
|
+
*
|
|
487
|
+
* @param childDescs - Display names (with optional descriptions) of child agents
|
|
488
|
+
* @param toolCount - Number of handoff/transfer tools available
|
|
489
|
+
*/
|
|
490
|
+
buildOrchestratorGuidance(childDescs, toolCount) {
|
|
491
|
+
return [
|
|
492
|
+
'## Agent Orchestration',
|
|
493
|
+
'',
|
|
494
|
+
`You have ${toolCount} specialist agent(s) available for delegation:`,
|
|
495
|
+
...childDescs.map((d) => `- ${d}`),
|
|
496
|
+
'',
|
|
497
|
+
'If a task is more complex or takes longer, delegate it to a specialist agent. Completion is push-based: it will auto-return its result when done.',
|
|
498
|
+
'Use `check_agents` to check status without waiting. Use `collect_results` to wait for and retrieve agent outputs.',
|
|
499
|
+
'Default workflow: spawn work, continue reasoning, and call `collect_results` when ready.',
|
|
500
|
+
'Coordinate agent work and synthesize results before responding to the user.',
|
|
501
|
+
'For non-trivial multi-step work, keep a short plan updated for the user.',
|
|
502
|
+
'Do not poll `check_agents` in a loop; only check status on-demand (for debugging or when explicitly asked).',
|
|
503
|
+
'',
|
|
504
|
+
'### Delegation Rules',
|
|
505
|
+
'- Delegate one clear, specific task per agent call.',
|
|
506
|
+
'- Independent tasks MAY be spawned in parallel (multiple calls in one turn, then one `collect_results`).',
|
|
507
|
+
'- Dependent tasks MUST be spawned in separate rounds — spawn, collect, analyze, then spawn the next with REAL data from prior results.',
|
|
508
|
+
'- NEVER fabricate, guess, or use placeholder data. Only pass real data from collected agent results.',
|
|
509
|
+
'- After collecting results, analyze them before proceeding. Explain what the agent found and what you will do next.',
|
|
510
|
+
'- If an agent fails, analyze the error and retry with clearer instructions before reporting failure.',
|
|
511
|
+
].join('\n');
|
|
512
|
+
}
|
|
456
513
|
/**
|
|
457
514
|
* Builds a meaningful default description for a transfer tool when no explicit
|
|
458
515
|
* edge.description is provided. Uses the destination agent's name and description
|
|
@@ -500,14 +557,92 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
500
557
|
agentContext.graphTools = [];
|
|
501
558
|
}
|
|
502
559
|
agentContext.graphTools.push(...handoffTools);
|
|
503
|
-
|
|
560
|
+
/**
|
|
561
|
+
* Add orchestrator coordination tools: collect_results and check_agents.
|
|
562
|
+
* These enable the OpenClaw-style autonomous loop:
|
|
563
|
+
* spawn → reason → check/collect → reason → spawn more → synthesize
|
|
564
|
+
*/
|
|
565
|
+
const handoffReg = this.handoffRegistry;
|
|
566
|
+
agentContext.graphTools.push(tool(async () => {
|
|
567
|
+
if (!handoffReg.hasPending() && handoffReg.size === 0) {
|
|
568
|
+
return 'No agents have been spawned yet.';
|
|
569
|
+
}
|
|
570
|
+
/** Wait for all pending handoffs to complete */
|
|
571
|
+
const records = await handoffReg.waitForAll();
|
|
572
|
+
const parts = [];
|
|
573
|
+
for (const record of records) {
|
|
574
|
+
if (record.status === 'completed') {
|
|
575
|
+
parts.push(`## ${record.name} (completed in ${record.durationMs}ms)\n${record.resultText}`);
|
|
576
|
+
}
|
|
577
|
+
else if (record.status === 'failed') {
|
|
578
|
+
parts.push(`## ${record.name} (FAILED after ${record.durationMs}ms)\nError: ${record.error}`);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
parts.push(`## ${record.name} (still running, ${Date.now() - record.spawnedAt}ms elapsed)`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return parts.join('\n\n---\n\n');
|
|
585
|
+
}, {
|
|
586
|
+
name: 'collect_results',
|
|
587
|
+
schema: { type: 'object', properties: {}, required: [] },
|
|
588
|
+
description: 'Wait for all spawned agents to complete and collect their results. ' +
|
|
589
|
+
'Call this after spawning one or more agents to get their output.',
|
|
590
|
+
}), tool(async () => {
|
|
591
|
+
const all = handoffReg.listAll();
|
|
592
|
+
if (all.length === 0) {
|
|
593
|
+
return 'No agents tracked.';
|
|
594
|
+
}
|
|
595
|
+
const lines = all.map((r) => {
|
|
596
|
+
const elapsed = Date.now() - r.spawnedAt;
|
|
597
|
+
if (r.status === 'running') {
|
|
598
|
+
return `- **${r.name}**: running (${elapsed}ms elapsed) — task: ${r.task.substring(0, 100)}`;
|
|
599
|
+
}
|
|
600
|
+
else if (r.status === 'completed') {
|
|
601
|
+
return `- **${r.name}**: completed (${r.durationMs}ms, ${r.resultText?.length ?? 0} chars)`;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
return `- **${r.name}**: failed (${r.durationMs}ms) — ${r.error}`;
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
const pending = all.filter((r) => r.status === 'running').length;
|
|
608
|
+
const completed = all.filter((r) => r.status === 'completed').length;
|
|
609
|
+
const failed = all.filter((r) => r.status === 'failed').length;
|
|
610
|
+
return [
|
|
611
|
+
`**Agent Status**: ${pending} running, ${completed} completed, ${failed} failed`,
|
|
612
|
+
'',
|
|
613
|
+
...lines,
|
|
614
|
+
].join('\n');
|
|
615
|
+
}, {
|
|
616
|
+
name: 'check_agents',
|
|
617
|
+
schema: { type: 'object', properties: {}, required: [] },
|
|
618
|
+
description: 'Check the status of all spawned agents without waiting. ' +
|
|
619
|
+
'Shows which agents are running, completed, or failed.',
|
|
620
|
+
}));
|
|
621
|
+
console.debug(`[MultiAgentGraph] Handoff tools for "${agentId}": [${handoffTools.map((t) => t.name).join(', ')}, collect_results, check_agents]`);
|
|
622
|
+
// Inject autonomous orchestration guidance for agents with handoff tools.
|
|
623
|
+
// Modeled after OpenClaw's battle-tested subagent orchestration patterns.
|
|
624
|
+
const childDescs = edges.flatMap((e) => {
|
|
625
|
+
const dests = Array.isArray(e.to) ? e.to : [e.to];
|
|
626
|
+
return dests.map((d) => {
|
|
627
|
+
const ctx = this.agentContexts.get(d);
|
|
628
|
+
const name = ctx?.name ?? d;
|
|
629
|
+
const desc = ctx?.description ? ` — ${ctx.description}` : '';
|
|
630
|
+
return `${name}${desc}`;
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
const orchestrationGuidance = this.buildOrchestratorGuidance(childDescs, handoffTools.length);
|
|
634
|
+
const existing = agentContext.additionalInstructions ?? '';
|
|
635
|
+
agentContext.additionalInstructions = existing
|
|
636
|
+
? `${existing}\n\n${orchestrationGuidance}`
|
|
637
|
+
: orchestrationGuidance;
|
|
504
638
|
}
|
|
505
639
|
}
|
|
506
640
|
/**
|
|
507
641
|
* Create handoff tools for an edge (handles multiple destinations).
|
|
508
|
-
* Each handoff tool
|
|
509
|
-
*
|
|
510
|
-
*
|
|
642
|
+
* Each handoff tool spawns the child agent's compiled subgraph asynchronously
|
|
643
|
+
* and returns immediately. The orchestrator uses `collect_results` to retrieve
|
|
644
|
+
* outputs and `check_agents` to monitor status — matching OpenClaw's
|
|
645
|
+
* push-based autonomous orchestration pattern.
|
|
511
646
|
*
|
|
512
647
|
* @param edge - The graph edge defining the handoff
|
|
513
648
|
* @param sourceAgentId - The ID of the parent/supervisor agent
|
|
@@ -524,11 +659,12 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
524
659
|
const hasPromptInput = edge.prompt != null && typeof edge.prompt === 'string';
|
|
525
660
|
const promptInputDescription = hasPromptInput ? edge.prompt : undefined;
|
|
526
661
|
const promptKey = edge.promptKey ?? 'instructions';
|
|
527
|
-
/** Capture registry
|
|
528
|
-
const
|
|
662
|
+
/** Capture registry references — Map populated in createWorkflow() */
|
|
663
|
+
const subgraphReg = this.subgraphRegistry;
|
|
664
|
+
const handoffReg = this.handoffRegistry;
|
|
529
665
|
tools.push(tool(async (rawInput, config) => {
|
|
530
666
|
const input = rawInput;
|
|
531
|
-
const subgraph =
|
|
667
|
+
const subgraph = subgraphReg.get(destination);
|
|
532
668
|
if (!subgraph) {
|
|
533
669
|
throw new Error(`Handoff target "${destination}" subgraph not found in registry. ` +
|
|
534
670
|
'This is a bug: createWorkflow() should have populated the subgraph registry.');
|
|
@@ -536,52 +672,75 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
536
672
|
const state = getCurrentTaskInput();
|
|
537
673
|
let childMessages = MultiAgentGraph.prepareHandoffMessages([...state.messages]);
|
|
538
674
|
/** Inject instructions as HumanMessage if provided by the parent LLM */
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
675
|
+
const taskDescription = (hasPromptInput && promptKey in input && input[promptKey] != null)
|
|
676
|
+
? String(input[promptKey])
|
|
677
|
+
: '';
|
|
678
|
+
if (taskDescription) {
|
|
542
679
|
childMessages = [
|
|
543
680
|
...childMessages,
|
|
544
|
-
new HumanMessage(
|
|
681
|
+
new HumanMessage(taskDescription),
|
|
545
682
|
];
|
|
546
683
|
}
|
|
547
684
|
const childState = {
|
|
548
685
|
messages: childMessages,
|
|
549
686
|
};
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
687
|
+
const childContext = this.agentContexts.get(destination);
|
|
688
|
+
const destName = destContext?.name ?? destination;
|
|
689
|
+
console.debug(`[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" SPAWN (async)\n` +
|
|
690
|
+
` messages: ${childMessages.length}\n` +
|
|
691
|
+
` childTools: ${childContext?.tools?.length ?? 0} instances\n` +
|
|
692
|
+
` childToolDefs: ${childContext?.toolDefinitions?.length ?? 0} definitions`);
|
|
693
|
+
/**
|
|
694
|
+
* Dispatch transition BEFORE spawning the child subgraph so that
|
|
695
|
+
* callbacks.js sets multiAgentTrace.isMultiAgent = true before the
|
|
696
|
+
* child's ON_RUN_STEP events fire.
|
|
697
|
+
*/
|
|
698
|
+
await safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
|
|
699
|
+
sourceAgentId: sourceAgentId,
|
|
700
|
+
sourceAgentName: this.agentContexts.get(sourceAgentId)?.name ?? sourceAgentId,
|
|
701
|
+
destinationAgentId: destination,
|
|
702
|
+
destinationAgentName: destName,
|
|
703
|
+
edgeType: EdgeType.HANDOFF,
|
|
704
|
+
timestamp: Date.now(),
|
|
705
|
+
}, config);
|
|
706
|
+
/**
|
|
707
|
+
* Spawn child execution as a background promise (non-blocking).
|
|
708
|
+
* The orchestrator gets an immediate response and can reason,
|
|
709
|
+
* spawn more agents, or call collect_results when ready.
|
|
710
|
+
*
|
|
711
|
+
* The config is passed through so SSE callbacks, abort signal,
|
|
712
|
+
* and configurable data still propagate to the child.
|
|
713
|
+
*/
|
|
714
|
+
const childPromise = subgraph.invoke(childState, config);
|
|
715
|
+
const capturedConfig = config;
|
|
716
|
+
const parentAgentId = sourceAgentId;
|
|
717
|
+
const parentCtx = this.agentContexts.get(sourceAgentId);
|
|
718
|
+
handoffReg.spawn({
|
|
719
|
+
id: destination,
|
|
720
|
+
name: destName,
|
|
721
|
+
task: taskDescription || '(no explicit instructions)',
|
|
722
|
+
promise: childPromise,
|
|
723
|
+
extractResult: MultiAgentGraph.extractHandoffResult,
|
|
724
|
+
truncateResult: MultiAgentGraph.truncateHandoffResult,
|
|
725
|
+
maxResultChars,
|
|
726
|
+
onComplete: (record) => {
|
|
727
|
+
console.debug(`[MultiAgentGraph] Handoff "${parentAgentId}" -> "${destination}" ${record.status.toUpperCase()} ` +
|
|
728
|
+
`(${record.durationMs}ms, ${record.resultText?.length ?? 0} chars)`);
|
|
729
|
+
/** Dispatch completion event for UI update */
|
|
730
|
+
safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
|
|
731
|
+
sourceAgentId: destination,
|
|
732
|
+
sourceAgentName: destName,
|
|
733
|
+
destinationAgentId: parentAgentId,
|
|
734
|
+
destinationAgentName: parentCtx?.name ?? parentAgentId,
|
|
735
|
+
edgeType: EdgeType.HANDOFF,
|
|
736
|
+
timestamp: Date.now(),
|
|
737
|
+
isCompletion: true,
|
|
738
|
+
durationMs: record.durationMs,
|
|
739
|
+
resultLength: record.resultText?.length ?? 0,
|
|
740
|
+
}, capturedConfig).catch(() => { });
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
return `[Agent "${destName}" spawned. Use collect_results to get the output when ready.]`;
|
|
585
744
|
}, {
|
|
586
745
|
name: toolName,
|
|
587
746
|
schema: hasPromptInput
|
|
@@ -1371,7 +1530,25 @@ class MultiAgentGraph extends StandardGraph {
|
|
|
1371
1530
|
}
|
|
1372
1531
|
}
|
|
1373
1532
|
}
|
|
1374
|
-
/**
|
|
1533
|
+
/**
|
|
1534
|
+
* No Command routing needed — dispatch ON_AGENT_TRANSITION for all
|
|
1535
|
+
* destinations so callbacks.js can register child agents for event
|
|
1536
|
+
* isolation BEFORE they start streaming.
|
|
1537
|
+
*/
|
|
1538
|
+
const allDests = new Set([...transferDestinations, ...sequenceDestinations]);
|
|
1539
|
+
if (allDests.size > 0) {
|
|
1540
|
+
const edgeType = hasTransferEdges ? EdgeType.TRANSFER : EdgeType.SEQUENCE;
|
|
1541
|
+
for (const dest of allDests) {
|
|
1542
|
+
await safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
|
|
1543
|
+
sourceAgentId: agentId,
|
|
1544
|
+
sourceAgentName: this.agentContexts.get(agentId)?.name ?? agentId,
|
|
1545
|
+
destinationAgentId: dest,
|
|
1546
|
+
destinationAgentName: this.agentContexts.get(dest)?.name ?? dest,
|
|
1547
|
+
edgeType,
|
|
1548
|
+
timestamp: Date.now(),
|
|
1549
|
+
}, config);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1375
1552
|
return result;
|
|
1376
1553
|
};
|
|
1377
1554
|
/** Wrapped agent as a node with its possible destinations */
|