@yeaft/webchat-agent 0.1.613 → 0.1.614

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.613",
3
+ "version": "0.1.614",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -163,6 +163,9 @@ export class Engine {
163
163
  /** @type {object|null} — Config override for internal tasks (recall, consolidation, dream) using fastModel */
164
164
  #fastConfig;
165
165
 
166
+ /** @type {((agentId: string, evt: object) => void) | null} */
167
+ #subAgentEventSink = null;
168
+
166
169
  /**
167
170
  * task-325a — abort state.
168
171
  *
@@ -451,9 +454,39 @@ export class Engine {
451
454
  inboundEnvelope: vpCtx?.inboundEnvelope,
452
455
  taskId: vpCtx?.taskId,
453
456
  taskMembers: vpCtx?.taskMembers,
457
+ // Sub-agent plumbing — Agent tool needs these to spawn a child
458
+ // Engine that inherits the parent's adapter / stores / toolset.
459
+ parentEngineDeps: {
460
+ adapter: this.#adapter,
461
+ trace: this.#trace,
462
+ config: this.#config,
463
+ memoryStore: this.#memoryStore,
464
+ memoryShardStore: this.#memoryShardStore,
465
+ parentToolRegistry: this.#toolRegistry,
466
+ skillManager: this.#skillManager,
467
+ mcpManager: this.#mcpManager,
468
+ yeaftDir: this.#yeaftDir,
469
+ parentName: vpCtx?.senderVpId || 'parent',
470
+ parentVpId: vpCtx?.senderVpId || null,
471
+ parentVpPersona: vpCtx?.vpPersona || null,
472
+ onEvent: this.#subAgentEventSink || null,
473
+ language: this.#config?.language || 'en',
474
+ },
454
475
  };
455
476
  }
456
477
 
478
+ /**
479
+ * Set a sub-agent event sink. Called by web-bridge so every event
480
+ * yielded by a sub-engine gets surfaced to the frontend tagged with
481
+ * the parent's conversation/turn so the UI can render it inside the
482
+ * spawning sub-agent card.
483
+ *
484
+ * @param {(agentId: string, evt: object) => void} sink
485
+ */
486
+ setSubAgentEventSink(sink) {
487
+ this.#subAgentEventSink = typeof sink === 'function' ? sink : null;
488
+ }
489
+
457
490
  /**
458
491
  * Perform memory recall for a given prompt.
459
492
  * Uses recallR6 (R6 shard-based recall) when memoryShardStore is available,
@@ -1308,7 +1341,7 @@ export class Engine {
1308
1341
  }
1309
1342
 
1310
1343
  // Execute tool calls and feed results back
1311
- const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers });
1344
+ const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona });
1312
1345
 
1313
1346
  // task-325a: track whether we aborted mid tool-loop so we can
1314
1347
  // break out of the outer while-loop cleanly once the current
@@ -0,0 +1,320 @@
1
+ /**
2
+ * runner.js — Sub-agent execution driver.
3
+ *
4
+ * Lifecycle (per agent record in the global registry from `tools/agent.js`):
5
+ *
6
+ * created → running → idle (mission turn finished, awaiting parent feedback)
7
+ * ↘ ↘
8
+ * failed → running again (on SendMessage)
9
+ * ↘ ↘
10
+ * closed completed (terminal — set by CloseAgent or last end_turn)
11
+ *
12
+ * Each sub-agent owns:
13
+ * - its own Engine instance (shares parent adapter/trace/config/stores)
14
+ * - its own ToolRegistry: parent's minus [Agent, SendMessage, WaitAgent,
15
+ * CloseAgent, ListAgents, RouteForward, AskUser]
16
+ * - its own messages buffer (`agent.engineMessages`) so a turn can resume
17
+ * after SendMessage
18
+ *
19
+ * The runner is fire-and-forget: `startSubAgent(agent, deps)` schedules a
20
+ * microtask that drives the loop and returns immediately. Parents observe
21
+ * via `WaitAgent` (polls status) or via `deps.onEvent(agentId, evt)`
22
+ * which is invoked for every sub-engine event for live UI streaming.
23
+ *
24
+ * Errors are caught; the agent is marked `failed` with `error` set, and
25
+ * resolved through `WaitAgent` per the option-A protocol (parent decides
26
+ * how to react).
27
+ */
28
+
29
+ import { Engine } from '../engine.js';
30
+ import { ToolRegistry } from '../tools/registry.js';
31
+ import { buildSpawnedPreamble } from './spawned-prompt.js';
32
+
33
+ const RESTRICTED_TOOLS = new Set([
34
+ 'Agent',
35
+ 'SendMessage',
36
+ 'WaitAgent',
37
+ 'CloseAgent',
38
+ 'ListAgents',
39
+ 'RouteForward',
40
+ 'AskUser',
41
+ ]);
42
+
43
+ /**
44
+ * Build a child ToolRegistry by copying every tool from the parent
45
+ * registry except those in RESTRICTED_TOOLS.
46
+ *
47
+ * @param {ToolRegistry|null} parentRegistry
48
+ * @returns {ToolRegistry}
49
+ */
50
+ export function buildChildToolRegistry(parentRegistry) {
51
+ const child = new ToolRegistry();
52
+ if (!parentRegistry || typeof parentRegistry.getAllTools !== 'function') {
53
+ return child;
54
+ }
55
+ for (const t of parentRegistry.getAllTools()) {
56
+ if (RESTRICTED_TOOLS.has(t.name)) continue;
57
+ child.register(t);
58
+ }
59
+ return child;
60
+ }
61
+
62
+ /**
63
+ * Public read-only check — used by tests and callers that want to
64
+ * sanity-check the constraint without poking the registry.
65
+ */
66
+ export function isRestrictedToolName(name) {
67
+ return RESTRICTED_TOOLS.has(name);
68
+ }
69
+
70
+ /**
71
+ * Fire-and-forget: kick off the sub-agent loop. Mutates `agent` in place
72
+ * (status, result, error, engineMessages, abortController).
73
+ *
74
+ * @param {object} agent — record from getAgentRegistry()
75
+ * @param {{
76
+ * adapter: object,
77
+ * trace: object,
78
+ * config: object,
79
+ * conversationStore?: object,
80
+ * memoryStore?: object,
81
+ * memoryShardStore?: object,
82
+ * parentToolRegistry?: ToolRegistry,
83
+ * skillManager?: object,
84
+ * mcpManager?: object,
85
+ * yeaftDir?: string,
86
+ * parentName?: string,
87
+ * parentVpId?: string,
88
+ * parentVpPersona?: object,
89
+ * onEvent?: (agentId: string, evt: object) => void,
90
+ * language?: 'en'|'zh',
91
+ * }} deps
92
+ */
93
+ export function startSubAgent(agent, deps = {}) {
94
+ if (!agent || typeof agent !== 'object') return;
95
+ if (agent.__driverStarted) return; // idempotent
96
+ agent.__driverStarted = true;
97
+
98
+ // Build sub-engine wired to the parent's adapter/stores/config but with
99
+ // a restricted toolset. We DO NOT pass a conversationStore: sub-agent
100
+ // turns must not pollute the user-facing conversation history. The
101
+ // memory stores are shared so memory recall still works for the
102
+ // sub-agent (matches parent VP persona memory).
103
+ const childRegistry = buildChildToolRegistry(deps.parentToolRegistry);
104
+ const subEngine = new Engine({
105
+ adapter: deps.adapter,
106
+ trace: deps.trace,
107
+ config: { ...deps.config, _readOnly: true },
108
+ conversationStore: null,
109
+ memoryStore: deps.memoryStore || null,
110
+ memoryShardStore: deps.memoryShardStore || null,
111
+ toolRegistry: childRegistry,
112
+ skillManager: deps.skillManager || null,
113
+ mcpManager: deps.mcpManager || null,
114
+ yeaftDir: deps.yeaftDir || null,
115
+ });
116
+
117
+ agent.subEngine = subEngine;
118
+ agent.engineMessages = agent.engineMessages || [];
119
+
120
+ // Compose the system-prompt-overlay we want injected. We piggyback on
121
+ // the existing `vpPersona` parameter that #buildSystemPrompt threads
122
+ // through to buildWorkerPrompt — appending our spawned-preamble at the
123
+ // end of the persona block guarantees it lands inside Layer A and is
124
+ // subject to the same persona caching guarantees.
125
+ const preamble = buildSpawnedPreamble({
126
+ parentName: deps.parentName || 'parent',
127
+ parentVpId: deps.parentVpId || null,
128
+ agentName: agent.name,
129
+ mission: agent.mission || agent.task || '',
130
+ language: deps.language || deps.config?.language || 'en',
131
+ });
132
+
133
+ const baseVpPersona =
134
+ deps.parentVpPersona && typeof deps.parentVpPersona === 'object'
135
+ ? { ...deps.parentVpPersona }
136
+ : {};
137
+ // Append the preamble onto whatever the parent persona body looked
138
+ // like. If the parent had no persona, we still hand the LLM a clean
139
+ // sub-agent identity block so it knows the scope.
140
+ baseVpPersona.persona =
141
+ [(baseVpPersona.persona || '').trim(), preamble.trim()]
142
+ .filter(Boolean)
143
+ .join('\n\n');
144
+ // renderVpPersona requires a displayName to emit the persona block.
145
+ // If the parent did not provide one, synthesize one from agent name
146
+ // so the spawned-preamble actually surfaces in the system prompt.
147
+ if (!baseVpPersona.displayName || !String(baseVpPersona.displayName).trim()) {
148
+ baseVpPersona.displayName = `${deps.parentName || 'Parent'}/${agent.name || 'sub-agent'}`;
149
+ }
150
+ baseVpPersona.subAgent = {
151
+ parentVpId: deps.parentVpId || null,
152
+ agentId: agent.id,
153
+ agentName: agent.name,
154
+ };
155
+
156
+ agent.subVpPersona = baseVpPersona;
157
+
158
+ // Background driver — pumps queued user messages through engine.query
159
+ // turn by turn until status flips to closed/completed/failed.
160
+ driveSubAgent(agent, subEngine, baseVpPersona, deps).catch((err) => {
161
+ if (agent.status === 'closed' || agent.status === 'completed') return;
162
+ agent.status = 'failed';
163
+ agent.error = err && err.message ? err.message : String(err);
164
+ agent.diagnostics.push({ type: 'driver_error', error: agent.error, at: Date.now() });
165
+ if (typeof deps.onEvent === 'function') {
166
+ try { deps.onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: agent.error }); } catch { /* ignore */ }
167
+ }
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Drive one mission turn at a time. Each iteration:
173
+ * 1. Pull the next pending user message from the queue (or, on first
174
+ * turn, the mission itself).
175
+ * 2. Run engine.query, forwarding every event to deps.onEvent (tagged
176
+ * with agentId).
177
+ * 3. Capture the final assistant text → agent.lastResult, mark idle.
178
+ * 4. Wait for either a new SendMessage (status=='running' again) OR
179
+ * CloseAgent (status=='closed') OR the mission to be marked
180
+ * completed by parent.
181
+ */
182
+ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
183
+ const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
184
+
185
+ // Helper: append a user message and either start or resume.
186
+ const dequeueNextUserPrompt = () => {
187
+ if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
188
+ return agent.pendingPrompts.shift() || null;
189
+ };
190
+
191
+ // Seed: mission becomes the first user prompt.
192
+ if (!agent.pendingPrompts) agent.pendingPrompts = [];
193
+ if (agent.mission && !agent.__missionSeeded) {
194
+ agent.pendingPrompts.push(agent.mission);
195
+ agent.__missionSeeded = true;
196
+ }
197
+
198
+ agent.status = 'running';
199
+ if (onEvent) {
200
+ try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, agentName: agent.name, status: 'running' }); } catch { /* ignore */ }
201
+ }
202
+
203
+ while (agent.status !== 'closed' && agent.status !== 'completed' && agent.status !== 'failed') {
204
+ const prompt = dequeueNextUserPrompt();
205
+ if (!prompt) {
206
+ // Nothing to do — go idle and wait for SendMessage / CloseAgent.
207
+ agent.status = 'idle';
208
+ if (onEvent) {
209
+ try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'idle' }); } catch { /* ignore */ }
210
+ }
211
+ await waitUntilResumed(agent);
212
+ // Either we have a new prompt now (back to running) or status is closed.
213
+ if (agent.status === 'closed') break;
214
+ agent.status = 'running';
215
+ continue;
216
+ }
217
+
218
+ let assistantText = '';
219
+ let endedNormally = false;
220
+ let streamError = null;
221
+ try {
222
+ const stream = subEngine.query({
223
+ prompt,
224
+ messages: agent.engineMessages,
225
+ signal: agent.abortController?.signal,
226
+ scenario: 'chat',
227
+ vpPersona,
228
+ });
229
+ for await (const evt of stream) {
230
+ // Forward every sub-engine event to the parent observer with
231
+ // the agent identity attached. Frontend renders these inside
232
+ // the sub-agent's collapsed card.
233
+ if (onEvent) {
234
+ try { onEvent(agent.id, { ...evt, agentId: agent.id, agentName: agent.name }); } catch { /* ignore listener errors */ }
235
+ }
236
+ if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
237
+ assistantText += evt.text;
238
+ }
239
+ if (evt && evt.type === 'error' && evt.error) {
240
+ streamError = evt.error.message || String(evt.error);
241
+ }
242
+ if (evt && evt.type === 'stop') {
243
+ if (evt.stopReason === 'end_turn' || evt.stopReason === 'stop_sequence') {
244
+ endedNormally = true;
245
+ }
246
+ }
247
+ }
248
+ } catch (err) {
249
+ agent.status = 'failed';
250
+ agent.error = err && err.message ? err.message : String(err);
251
+ agent.diagnostics.push({ type: 'query_error', error: agent.error, at: Date.now() });
252
+ if (onEvent) {
253
+ try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: agent.error }); } catch { /* ignore */ }
254
+ }
255
+ return;
256
+ }
257
+
258
+ if (streamError) {
259
+ // Engine surfaced an error event (e.g. adapter failure) instead of
260
+ // throwing — treat the same as a thrown error.
261
+ agent.status = 'failed';
262
+ agent.error = streamError;
263
+ agent.diagnostics.push({ type: 'stream_error', error: streamError, at: Date.now() });
264
+ if (onEvent) {
265
+ try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: streamError }); } catch { /* ignore */ }
266
+ }
267
+ return;
268
+ }
269
+
270
+ // Persist the turn into the local message buffer so subsequent
271
+ // SendMessage continuations see context.
272
+ agent.engineMessages.push({ role: 'user', content: prompt });
273
+ if (assistantText) {
274
+ agent.engineMessages.push({ role: 'assistant', content: assistantText });
275
+ }
276
+ agent.lastResult = assistantText;
277
+ agent.usage.turns = (agent.usage.turns || 0) + 1;
278
+
279
+ if (!endedNormally) {
280
+ // Adapter aborted/errored without end_turn — mark failed.
281
+ agent.status = 'failed';
282
+ agent.error = agent.error || 'sub-agent stream ended without end_turn';
283
+ if (onEvent) {
284
+ try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: agent.error }); } catch { /* ignore */ }
285
+ }
286
+ return;
287
+ }
288
+
289
+ // Turn complete. Stash the result for WaitAgent and emit a turn-end
290
+ // event for the UI. Loop re-enters: if more pendingPrompts queued
291
+ // by SendMessage, run the next; else go idle.
292
+ agent.result = assistantText;
293
+ if (onEvent) {
294
+ try { onEvent(agent.id, { type: 'sub_agent_turn_end', agentId: agent.id, agentName: agent.name, content: assistantText }); } catch { /* ignore */ }
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Resume signal — resolves when:
301
+ * - a new prompt was pushed onto agent.pendingPrompts (SendMessage), OR
302
+ * - the agent was closed (CloseAgent / abort)
303
+ *
304
+ * This is a tight poll because sub-agent I/O is interactive and there
305
+ * are at most a handful of these alive in a session.
306
+ */
307
+ function waitUntilResumed(agent) {
308
+ return new Promise((resolve) => {
309
+ const tick = () => {
310
+ if (agent.status === 'closed' || agent.status === 'completed' || agent.status === 'failed') {
311
+ return resolve();
312
+ }
313
+ if (Array.isArray(agent.pendingPrompts) && agent.pendingPrompts.length > 0) {
314
+ return resolve();
315
+ }
316
+ setTimeout(tick, 50);
317
+ };
318
+ tick();
319
+ });
320
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * spawned-prompt.js — Build the spawned-sub-agent preamble.
3
+ *
4
+ * A sub-agent inherits its parent VP's full worker system prompt
5
+ * (so the persona/voice carries over) and gets ONE additional preamble
6
+ * block appended that tells it:
7
+ * - it is a sub-agent of {parentName}
8
+ * - its concrete mission (free markdown from the caller)
9
+ * - it MUST NOT spawn further sub-agents, route to other VPs,
10
+ * or interrupt the user (those tools are unregistered already
11
+ * but the constraint is still spelled out for clarity)
12
+ * - how to report back: free markdown, end_turn when mission complete
13
+ *
14
+ * This module is deliberately tiny — the heavy lifting (persona,
15
+ * Layer A summaries, etc.) is reused from `prompts.js` via the parent
16
+ * Engine's existing system-prompt build path.
17
+ */
18
+
19
+ /**
20
+ * @param {object} args
21
+ * @param {string} args.parentName — display name of spawning VP / Engine
22
+ * @param {string} args.parentVpId — vpId if available (for traceability)
23
+ * @param {string} args.agentName — sub-agent's own name (from Agent tool)
24
+ * @param {string} args.mission — free markdown describing the task
25
+ * @param {'en'|'zh'} [args.language='en']
26
+ * @returns {string} preamble block (already ## headed, ready to concat)
27
+ */
28
+ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, language = 'en' } = {}) {
29
+ const m = (mission || '').trim();
30
+ if (language === 'zh') {
31
+ const lines = [
32
+ '## 你是 sub-agent',
33
+ `- 派出方:${parentName || 'parent'}${parentVpId ? ` (${parentVpId})` : ''}`,
34
+ `- 你的名字:${agentName || 'sub-agent'}`,
35
+ '- 你继承了派出方的人格与风格,但你不是 ta。你只负责完成下面的子任务。',
36
+ '',
37
+ '## 你的子任务',
38
+ m || '(无具体任务说明)',
39
+ '',
40
+ '## 行为约束',
41
+ '- 不要再 spawn sub-agent(你已经没有 Agent / SendMessage / WaitAgent / CloseAgent 工具)。',
42
+ '- 不要 route_forward 给别的 VP,不要 ask_user。',
43
+ '- 完成时直接以 markdown 自由文本回复(end_turn)。建议结构:"## 结果" / "## 关键发现" / "## 遗留问题"。',
44
+ '- 失败/不可行也要明确说出来,不要假装完成。父 VP 会读你的最终消息。',
45
+ ];
46
+ return lines.join('\n');
47
+ }
48
+ const lines = [
49
+ '## You are a sub-agent',
50
+ `- Spawned by: ${parentName || 'parent'}${parentVpId ? ` (${parentVpId})` : ''}`,
51
+ `- Your name: ${agentName || 'sub-agent'}`,
52
+ "- You inherit the spawner's persona and voice, but you are not them. You exist only to finish the sub-task below.",
53
+ '',
54
+ '## Your sub-task',
55
+ m || '(no mission body provided)',
56
+ '',
57
+ '## Constraints',
58
+ '- Do NOT spawn further sub-agents (Agent / SendMessage / WaitAgent / CloseAgent are not in your toolset).',
59
+ '- Do NOT use route_forward to other VPs. Do NOT ask_user.',
60
+ '- When done, reply in free markdown (end_turn). Suggested structure: "## Result" / "## Key findings" / "## Open questions".',
61
+ '- If the mission is infeasible or you fail, say so plainly. The parent will read your final message.',
62
+ ];
63
+ return lines.join('\n');
64
+ }
@@ -23,6 +23,7 @@
23
23
  import { defineTool } from './types.js';
24
24
  import { randomUUID } from 'crypto';
25
25
  import { getPersona, listPersonaIds } from '../personas.js';
26
+ import { startSubAgent } from '../sub-agent/runner.js';
26
27
 
27
28
  /** In-memory sub-agent registry. */
28
29
  const agents = new Map();
@@ -273,13 +274,35 @@ Guidelines:
273
274
 
274
275
  agents.set(agentId, agent);
275
276
 
277
+ // PR-M1: actually spawn the sub-agent driver. This is fire-and-forget;
278
+ // it returns immediately. The parent observes via WaitAgent (poll) or
279
+ // via the engine's sub-agent event sink (live UI streaming).
280
+ const deps = ctx?.parentEngineDeps;
281
+ if (deps && deps.adapter) {
282
+ try {
283
+ startSubAgent(agent, deps);
284
+ } catch (err) {
285
+ agent.status = 'failed';
286
+ agent.error = err && err.message ? err.message : String(err);
287
+ agent.diagnostics.push({ type: 'spawn_error', error: agent.error, at: Date.now() });
288
+ return JSON.stringify({
289
+ error: `Failed to start sub-agent: ${agent.error}`,
290
+ agentId,
291
+ });
292
+ }
293
+ } else {
294
+ // No parent engine deps — caller is in a non-engine context (legacy
295
+ // tests). Leave the record in 'created' so existing tests still work.
296
+ }
297
+
276
298
  return JSON.stringify({
277
299
  success: true,
278
300
  agentId,
279
301
  name,
280
302
  persona: spec.persona || null,
281
303
  budget: spec.budget || null,
282
- message: `Sub-agent "${name}" created (${agentId}). Use SendMessage to give it work.`,
304
+ status: agent.status,
305
+ message: `Sub-agent "${name}" spawned (${agentId}). Use WaitAgent to collect its first turn output, SendMessage to give it more work, CloseAgent to finish.`,
283
306
  });
284
307
  },
285
308
  });
@@ -42,7 +42,15 @@ The agent's result (if any) is returned before closing.`,
42
42
  agent.result = result;
43
43
  }
44
44
 
45
- const finalResult = agent.result;
45
+ // PR-M1: abort any in-flight engine.query so the driver loop exits
46
+ // promptly. This is cooperative — if the driver is mid-stream the
47
+ // adapter receives the signal; if it's idle, status flip ends the
48
+ // wait loop on the next 50ms tick.
49
+ if (agent.abortController && !agent.abortController.signal.aborted) {
50
+ try { agent.abortController.abort('closed'); } catch { /* ignore */ }
51
+ }
52
+
53
+ const finalResult = agent.result || agent.lastResult || '';
46
54
  agent.status = 'closed';
47
55
 
48
56
  return JSON.stringify({
@@ -51,6 +59,7 @@ The agent's result (if any) is returned before closing.`,
51
59
  name: agent.name,
52
60
  result: finalResult,
53
61
  messages: agent.messages.length,
62
+ turns: agent.usage?.turns || 0,
54
63
  message: `Agent "${agent.name}" closed`,
55
64
  });
56
65
  },
@@ -42,20 +42,31 @@ The message is queued for the agent to process.`,
42
42
  if (agent.status === 'closed') {
43
43
  return JSON.stringify({ error: `Agent "${agent.name}" is closed` });
44
44
  }
45
+ if (agent.status === 'failed') {
46
+ return JSON.stringify({ error: `Agent "${agent.name}" has failed: ${agent.error || 'unknown error'}` });
47
+ }
45
48
 
49
+ // PR-M1: queue as a pending prompt the driver will pull. This wakes
50
+ // the driver out of its idle wait and starts a new turn. The 'active'
51
+ // status alias kept for backward-compat with code that polls for it.
52
+ if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
53
+ agent.pendingPrompts.push(message);
46
54
  agent.messages.push({
47
55
  role: 'user',
48
56
  content: message,
49
57
  timestamp: Date.now(),
50
58
  });
51
- agent.status = 'active';
59
+ if (agent.status === 'idle' || agent.status === 'created') {
60
+ agent.status = 'running';
61
+ }
52
62
 
53
63
  return JSON.stringify({
54
64
  success: true,
55
65
  agentId: agent_id,
56
66
  name: agent.name,
57
67
  messageCount: agent.messages.length,
58
- message: `Message sent to agent "${agent.name}"`,
68
+ pending: agent.pendingPrompts.length,
69
+ message: `Message sent to agent "${agent.name}". Use WaitAgent to collect its reply.`,
59
70
  });
60
71
  },
61
72
  });
@@ -38,37 +38,41 @@ Use after sending a task to an agent via SendMessage.`,
38
38
  return JSON.stringify({ error: `Agent not found: ${agent_id}` });
39
39
  }
40
40
 
41
- // If already completed, return result immediately
42
- if (agent.status === 'completed' || agent.status === 'closed') {
41
+ // PR-M1: terminal states return immediately.
42
+ if (agent.status === 'completed' || agent.status === 'closed' || agent.status === 'failed') {
43
43
  return JSON.stringify({
44
44
  agentId: agent_id,
45
45
  name: agent.name,
46
46
  status: agent.status,
47
- result: agent.result,
47
+ result: agent.result || agent.lastResult || '',
48
+ error: agent.error || null,
48
49
  messages: agent.messages.length,
50
+ turns: agent.usage?.turns || 0,
49
51
  });
50
52
  }
51
53
 
52
- // Wait for completion with timeout
54
+ // PR-M1: 'idle' means the sub-agent finished its current turn and is
55
+ // waiting for the next SendMessage. That IS a useful return point for
56
+ // the parent — surface lastResult and let parent decide what's next.
53
57
  const deadline = Date.now() + timeout_ms;
54
58
  while (Date.now() < deadline) {
55
- if (agent.status === 'completed' || agent.status === 'closed') {
59
+ if (agent.status === 'idle' || agent.status === 'completed' || agent.status === 'closed' || agent.status === 'failed') {
56
60
  return JSON.stringify({
57
61
  agentId: agent_id,
58
62
  name: agent.name,
59
63
  status: agent.status,
60
- result: agent.result,
64
+ result: agent.result || agent.lastResult || '',
65
+ error: agent.error || null,
61
66
  messages: agent.messages.length,
67
+ turns: agent.usage?.turns || 0,
62
68
  });
63
69
  }
64
70
 
65
- // Check abort signal
66
71
  if (ctx?.signal?.aborted) {
67
72
  return JSON.stringify({ error: 'Wait cancelled', agentId: agent_id });
68
73
  }
69
74
 
70
- // Poll every 500ms
71
- await new Promise(r => setTimeout(r, 500));
75
+ await new Promise(r => setTimeout(r, 200));
72
76
  }
73
77
 
74
78
  return JSON.stringify({
@@ -77,7 +81,9 @@ Use after sending a task to an agent via SendMessage.`,
77
81
  status: agent.status,
78
82
  timedOut: true,
79
83
  message: `Agent "${agent.name}" is still running after ${timeout_ms}ms`,
84
+ result: agent.lastResult || '',
80
85
  messages: agent.messages.length,
86
+ turns: agent.usage?.turns || 0,
81
87
  });
82
88
  },
83
89
  });
@@ -120,7 +120,18 @@ function buildStatic(vp, capabilitiesLine) {
120
120
  : `(no persona body for ${vp.id})`;
121
121
 
122
122
  const caps = (capabilitiesLine && capabilitiesLine.trim())
123
- || 'Tools: route_forward, memory_search, memory_trace, task_summary_post (if initiator).';
123
+ || [
124
+ 'Tools: route_forward, memory_search, memory_trace, task_summary_post (if initiator).',
125
+ 'Sub-agent fan-out: when a single user task is large enough to benefit from parallel execution,',
126
+ 'you MAY spawn sub-agents using the `Agent` tool. Each sub-agent inherits your persona +',
127
+ 'voice, gets its own ToolRegistry (without Agent / RouteForward / AskUser to prevent recursion),',
128
+ 'and runs the same Engine flow. Use `Agent` to spawn (returns agentId), `WaitAgent` to collect',
129
+ 'each turn output, `SendMessage` for follow-ups, `CloseAgent` when done. You can fire multiple',
130
+ '`Agent` tool_calls in one assistant turn to launch them in parallel. Pass a self-contained,',
131
+ 'markdown mission ("## Goal / ## Context / ## Deliverable / ## Constraints") — the sub-agent',
132
+ 'cannot see your conversation history. Only spawn when work is genuinely parallelisable; for',
133
+ 'small or strictly-sequential tasks, do it yourself.',
134
+ ].join('\n');
124
135
 
125
136
  // personaHash travels in the static block so downstream (334h live-diff)
126
137
  // can detect changes without re-hashing.
@@ -1402,6 +1402,22 @@ export async function handleUnifyChat(msg) {
1402
1402
  // code — the config file was updated on disk but the running
1403
1403
  // session continued with the old caps until next restart.
1404
1404
  installUnifyRuntimeBridge(session);
1405
+
1406
+ // PR-M1: install a sub-agent event sink so events emitted by sub-
1407
+ // agent Engines surface to the web client. Frontend filters by the
1408
+ // `agentId` field and renders them inside the sub-agent card.
1409
+ try {
1410
+ if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
1411
+ session.engine.setSubAgentEventSink((agentId, evt) => {
1412
+ try {
1413
+ sendUnifyEvent({ type: 'sub_agent_event', agentId, payload: evt });
1414
+ } catch { /* ignore */ }
1415
+ });
1416
+ }
1417
+ } catch (err) {
1418
+ console.warn('[Unify] setSubAgentEventSink wiring failed:', err?.message || err);
1419
+ }
1420
+
1405
1421
  // task-317: run one idle-archive sweep at bootstrap, then schedule
1406
1422
  // the hourly tick bound to this session.
1407
1423
  runAutoArchiveSweep(session);