@yeaft/webchat-agent 0.1.952 → 0.1.953

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.952",
3
+ "version": "0.1.953",
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/yeaft/engine.js CHANGED
@@ -43,6 +43,7 @@ import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/c
43
43
  import { resolveThinking } from './router/thinking.js';
44
44
  import { approxTokens } from './memory/budget.js';
45
45
  import { COLLAB_TOOL_POLICY, truncateToolResultIfNeeded } from './tools/registry.js';
46
+ import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
46
47
  import {
47
48
  TOOL_BATCH_SIZE,
48
49
  TURN_SUMMARY_THRESHOLD,
@@ -958,6 +959,8 @@ export class Engine {
958
959
  parentName: vpCtx?.senderVpId || 'parent',
959
960
  parentVpId: vpCtx?.senderVpId || null,
960
961
  parentVpPersona: vpCtx?.vpPersona || null,
962
+ parentSessionId: vpCtx?.sessionId || null,
963
+ parentThreadId: vpCtx?.threadId || MAIN_THREAD_ID,
961
964
  onEvent: this.#subAgentEventSink || null,
962
965
  language: this.#config?.language || 'en',
963
966
  // Forward the session-shared ToolUsageStats so sub-agent
@@ -1424,6 +1427,12 @@ export class Engine {
1424
1427
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1425
1428
  ? collabToolPolicy
1426
1429
  : null;
1430
+ const runtimeSessionId = (typeof sessionId === 'string' && sessionId.trim())
1431
+ ? sessionId.trim()
1432
+ : this.#sessionId;
1433
+ const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
1434
+ ? threadId.trim()
1435
+ : MAIN_THREAD_ID;
1427
1436
 
1428
1437
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1429
1438
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -1574,9 +1583,37 @@ export class Engine {
1574
1583
  // If `promptParts` was supplied (image/file attachments), use the array form
1575
1584
  // so the adapter sees image content blocks alongside the text. Otherwise the
1576
1585
  // legacy string form keeps prompt-cache behavior identical.
1577
- const finalUserContent = (Array.isArray(promptParts) && promptParts.length > 0)
1578
- ? promptParts
1579
- : prompt;
1586
+ //
1587
+ // Sub-agent re-entry: before constructing the user message, drain any
1588
+ // terminal sub-agent notifications that landed for this parent VP
1589
+ // while it was idle. If any are present we prepend an XML-tagged
1590
+ // block to the user prompt so the parent model sees the sub-agent
1591
+ // result(s) even if it forgot to call WaitAgent. See
1592
+ // sub-agent/notifications.js for the bucketing + format.
1593
+ const parentVpIdForNotif = (vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string')
1594
+ ? vpPersona.vpId
1595
+ : (typeof senderVpId === 'string' ? senderVpId : null);
1596
+ const isSubAgentTurn = !!(vpPersona && typeof vpPersona === 'object' && vpPersona.subAgent);
1597
+ const notifScope = {
1598
+ sessionId: runtimeSessionId,
1599
+ parentVpId: parentVpIdForNotif,
1600
+ threadId: runtimeThreadId,
1601
+ };
1602
+ const pendingSubAgentNotifs = isSubAgentTurn ? [] : peekPendingNotifications(notifScope);
1603
+ const subAgentNotifBlock = formatNotificationsForPrompt(pendingSubAgentNotifs);
1604
+
1605
+ let finalUserContent;
1606
+ if (Array.isArray(promptParts) && promptParts.length > 0) {
1607
+ // Multimodal prompt — prepend the notification block as a leading
1608
+ // text part so the adapter still sees image content blocks intact.
1609
+ finalUserContent = subAgentNotifBlock
1610
+ ? [{ type: 'text', text: subAgentNotifBlock + '\n\n' }, ...promptParts]
1611
+ : promptParts;
1612
+ } else {
1613
+ finalUserContent = subAgentNotifBlock
1614
+ ? `${subAgentNotifBlock}\n\n${prompt || ''}`
1615
+ : prompt;
1616
+ }
1580
1617
  const conversationMessages = [
1581
1618
  ...compactMessages,
1582
1619
  ...messages,
@@ -2191,6 +2228,9 @@ export class Engine {
2191
2228
 
2192
2229
  // If no tool calls, we're done
2193
2230
  if (stopReason !== 'tool_use' || toolCalls.length === 0) {
2231
+ if (pendingSubAgentNotifs.length > 0) {
2232
+ acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
2233
+ }
2194
2234
  yield { type: 'turn_end', turnNumber, stopReason, threadId };
2195
2235
 
2196
2236
  // ─── Post-query: StopHooks or Legacy ─────────────
@@ -2340,6 +2380,8 @@ export class Engine {
2340
2380
  const toolCtx = this.#buildToolContext(signal, {
2341
2381
  router,
2342
2382
  senderVpId,
2383
+ sessionId: runtimeSessionId,
2384
+ threadId: runtimeThreadId,
2343
2385
  inboundEnvelope,
2344
2386
  taskId,
2345
2387
  taskMembers,
@@ -2536,6 +2578,9 @@ export class Engine {
2536
2578
  // multi-iteration tool loops) and BEFORE the abortedDuringTools
2537
2579
  // check (so a clean handoff doesn't get reported as 'aborted').
2538
2580
  if (endTurnRequested) {
2581
+ if (pendingSubAgentNotifs.length > 0) {
2582
+ acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
2583
+ }
2539
2584
  const handoffDetail = typeof endTurnRequested === 'object'
2540
2585
  ? endTurnRequested
2541
2586
  : { kind: 'tool_handoff', reason: String(endTurnRequested) };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * liveness.js — "Is this sub-agent alive or hung?" helpers.
3
+ *
4
+ * Yeaft's original wait_agent payload told the parent essentially nothing
5
+ * mid-flight: just `{ status, result: '' }`. The model couldn't tell
6
+ * "still thinking" from "stuck on a slow tool" from "actually wedged".
7
+ *
8
+ * Liveness is a tiny counter struct we update from the runner whenever a
9
+ * sub-engine event passes through. wait_agent and list_agents include
10
+ * it in their JSON payloads so the parent gets a clear visible signal
11
+ * that the child is doing work, plus a timestamp it can compare against
12
+ * Date.now() to compute "seconds since last activity".
13
+ *
14
+ * The struct lives on the agent record as `agent.liveness`. We never
15
+ * delete the field — even on terminal status the last snapshot is
16
+ * preserved so the parent can see "you ran 7 tools and last spoke 3
17
+ * seconds before completing".
18
+ */
19
+
20
+ /**
21
+ * Create a fresh liveness record.
22
+ *
23
+ * @returns {{
24
+ * toolUseCount: number,
25
+ * tokenCount: number,
26
+ * eventCount: number,
27
+ * lastEventAt: number,
28
+ * lastEventType: string|null,
29
+ * recentTools: string[],
30
+ * }}
31
+ */
32
+ export function makeLiveness() {
33
+ return {
34
+ toolUseCount: 0,
35
+ tokenCount: 0,
36
+ eventCount: 0,
37
+ lastEventAt: 0,
38
+ lastEventType: null,
39
+ recentTools: [],
40
+ };
41
+ }
42
+
43
+ const RECENT_TOOLS_MAX = 5;
44
+
45
+ /**
46
+ * Update a liveness record from a sub-engine event.
47
+ *
48
+ * @param {ReturnType<typeof makeLiveness>} liveness
49
+ * @param {object} evt
50
+ */
51
+ export function bumpLivenessFromEvent(liveness, evt) {
52
+ if (!liveness || !evt || typeof evt !== 'object') return;
53
+ liveness.eventCount += 1;
54
+ liveness.lastEventAt = Date.now();
55
+ liveness.lastEventType = evt.type || liveness.lastEventType;
56
+ if (evt.type === 'text_delta' && typeof evt.text === 'string') {
57
+ // Coarse "have we produced output" signal. Token count is not exact —
58
+ // it's character-based — but it lets the parent see "yes, the model
59
+ // is generating".
60
+ liveness.tokenCount += evt.text.length;
61
+ } else if (evt.type === 'tool_start' || evt.type === 'tool_call') {
62
+ liveness.toolUseCount += 1;
63
+ const name = evt.toolName || evt.name || (evt.tool && evt.tool.name) || null;
64
+ if (name) {
65
+ liveness.recentTools.push(name);
66
+ if (liveness.recentTools.length > RECENT_TOOLS_MAX) {
67
+ liveness.recentTools.splice(0, liveness.recentTools.length - RECENT_TOOLS_MAX);
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Render a small JSON object suitable for embedding inside a wait_agent /
75
+ * list_agents reply. Keeps the public field names stable and bounded.
76
+ *
77
+ * @param {ReturnType<typeof makeLiveness>|null|undefined} liveness
78
+ * @param {number} [now=Date.now()]
79
+ */
80
+ export function snapshotLiveness(liveness, now = Date.now()) {
81
+ if (!liveness) {
82
+ return {
83
+ toolUseCount: 0,
84
+ tokenCount: 0,
85
+ eventCount: 0,
86
+ lastEventAt: null,
87
+ msSinceLastEvent: null,
88
+ lastEventType: null,
89
+ recentTools: [],
90
+ };
91
+ }
92
+ return {
93
+ toolUseCount: liveness.toolUseCount,
94
+ tokenCount: liveness.tokenCount,
95
+ eventCount: liveness.eventCount,
96
+ lastEventAt: liveness.lastEventAt || null,
97
+ msSinceLastEvent: liveness.lastEventAt ? Math.max(0, now - liveness.lastEventAt) : null,
98
+ lastEventType: liveness.lastEventType,
99
+ recentTools: liveness.recentTools.slice(),
100
+ };
101
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * notifications.js — Sub-agent → parent re-entry queue.
3
+ *
4
+ * Problem this solves:
5
+ * The original sub-agent protocol was purely pull-based — the parent had
6
+ * to keep calling WaitAgent to discover that its child had finished. If
7
+ * the parent forgot, the child's terminal state was never surfaced and
8
+ * the orchestration "hung" from the user's perspective. Modeled on
9
+ * claude-code's `<task-notification>` XML re-entry pattern: when a
10
+ * child reaches a terminal state we *push* a notification onto a queue
11
+ * that the parent will see the next time it talks to its engine.
12
+ *
13
+ * This module is the queue. Two entry points consume it:
14
+ *
15
+ * 1. WaitAgent (drains anything queued for this agent on terminal,
16
+ * before returning).
17
+ * 2. Engine.query() — when started with a user prompt, it asks
18
+ * `consumePendingNotifications({ sessionId, parentVpId, threadId })` for any queued
19
+ * terminal events from sub-agents that the parent hasn't yet
20
+ * acknowledged, and prepends a short XML block to the user
21
+ * message. The XML block is human-readable for the model and
22
+ * explicitly tells it "your sub-agent X finished while you were
23
+ * away; here's the result and what to do next".
24
+ *
25
+ * The queue is in-memory only. We do NOT persist across process
26
+ * restarts because (a) sub-agents themselves don't survive restart,
27
+ * and (b) the durable per-agent outputFile (see output-log.js) is the
28
+ * long-term record.
29
+ *
30
+ * Keying:
31
+ * Notifications are bucketed by `(sessionId, parentVpId, threadId)` when
32
+ * the parent runs inside a Yeaft Session. Legacy / test callers that
33
+ * don't provide a sessionId still bucket by `parentVpId` (or
34
+ * `'__no_vp__'`) so older in-process callers keep working. WaitAgent
35
+ * always drains via agentId regardless of bucket, so the bucket only
36
+ * matters for the engine pre-prompt drain.
37
+ *
38
+ * Dedup:
39
+ * Each notification carries a unique id (agentId + status + ts). The
40
+ * `markNotified()` flag on the agent record (set by drainers) prevents
41
+ * us from emitting more than one terminal notification per agent.
42
+ */
43
+
44
+ /** @typedef {{ id: string, agentId: string, agentName: string, status: string, result: string, error: string|null, outputFile: string|null, turns: number, parentVpId: string|null, parentSessionId: string|null, parentThreadId: string|null, budgetExceeded: boolean, budgetReason: string|null, budgetUsage: object|null, createdAt: number }} SubAgentNotification */
45
+
46
+ /** Map<bucketKey, SubAgentNotification[]> */
47
+ const byParent = new Map();
48
+ /** Map<agentId, SubAgentNotification> — for WaitAgent agentId drains. */
49
+ const byAgent = new Map();
50
+
51
+ const FALLBACK_BUCKET = '__no_vp__';
52
+ const MAIN_THREAD_ID = 'main';
53
+
54
+ function cleanString(value) {
55
+ return (typeof value === 'string' && value.trim()) ? value.trim() : null;
56
+ }
57
+
58
+ function normalizeScope(input, sessionId, threadId) {
59
+ if (input && typeof input === 'object') {
60
+ return {
61
+ parentVpId: cleanString(input.parentVpId),
62
+ sessionId: cleanString(input.sessionId ?? input.parentSessionId),
63
+ threadId: cleanString(input.threadId ?? input.parentThreadId) || MAIN_THREAD_ID,
64
+ };
65
+ }
66
+ return {
67
+ parentVpId: cleanString(input),
68
+ sessionId: cleanString(sessionId),
69
+ threadId: cleanString(threadId) || MAIN_THREAD_ID,
70
+ };
71
+ }
72
+
73
+ function bucketKey(scope, sessionId, threadId) {
74
+ const s = normalizeScope(scope, sessionId, threadId);
75
+ const vp = s.parentVpId || FALLBACK_BUCKET;
76
+ if (!s.sessionId) return vp;
77
+ return `${s.sessionId}::${vp}::${s.threadId || MAIN_THREAD_ID}`;
78
+ }
79
+
80
+ /**
81
+ * Enqueue a terminal notification for an agent. Idempotent per agent —
82
+ * a second call with the same agentId is a no-op (we only emit one
83
+ * terminal notice per child).
84
+ *
85
+ * @param {{ agentId: string, agentName: string, status: string, result?: string, error?: string|null, outputFile?: string|null, turns?: number, parentVpId?: string|null, parentSessionId?: string|null, sessionId?: string|null, parentThreadId?: string|null, threadId?: string|null, budgetExceeded?: boolean, budgetReason?: string|null, budgetUsage?: object|null }} input
86
+ * @returns {SubAgentNotification|null} the queued record (null if a dup)
87
+ */
88
+ export function enqueueTerminalNotification(input) {
89
+ if (!input || !input.agentId || !input.status) return null;
90
+ if (byAgent.has(input.agentId)) return null; // already queued
91
+ const scope = normalizeScope({
92
+ parentVpId: input.parentVpId,
93
+ parentSessionId: input.parentSessionId ?? input.sessionId,
94
+ parentThreadId: input.parentThreadId ?? input.threadId,
95
+ });
96
+ const rec = {
97
+ id: `${input.agentId}:${input.status}:${Date.now()}`,
98
+ agentId: input.agentId,
99
+ agentName: input.agentName || input.agentId,
100
+ status: input.status,
101
+ result: input.result || '',
102
+ error: input.error || null,
103
+ outputFile: input.outputFile || null,
104
+ turns: typeof input.turns === 'number' ? input.turns : 0,
105
+ parentVpId: scope.parentVpId,
106
+ parentSessionId: scope.sessionId,
107
+ parentThreadId: scope.threadId,
108
+ budgetExceeded: Boolean(input.budgetExceeded),
109
+ budgetReason: input.budgetReason || null,
110
+ budgetUsage: input.budgetUsage || null,
111
+ createdAt: Date.now(),
112
+ };
113
+ const key = bucketKey(scope);
114
+ if (!byParent.has(key)) byParent.set(key, []);
115
+ byParent.get(key).push(rec);
116
+ byAgent.set(rec.agentId, rec);
117
+ return rec;
118
+ }
119
+
120
+ /**
121
+ * Drain and return pending notifications for a parent VP. Pass null to
122
+ * drain the fallback bucket. Returns [] when nothing pending.
123
+ *
124
+ * Engine calls this at the start of every user-driven turn so the
125
+ * parent model sees terminal events that arrived while it was idle.
126
+ *
127
+ * @param {string|{parentVpId?: string|null, sessionId?: string|null, parentSessionId?: string|null, threadId?: string|null, parentThreadId?: string|null}|null} scope
128
+ * @returns {SubAgentNotification[]}
129
+ */
130
+ export function consumePendingNotifications(scope) {
131
+ const key = bucketKey(scope);
132
+ const list = byParent.get(key) || [];
133
+ byParent.set(key, []);
134
+ // Don't drop from byAgent yet — WaitAgent may still query by agentId
135
+ // and we want it to be a no-op on already-drained records (the
136
+ // `notified` flag on the agent itself is the real dedup gate).
137
+ return list.slice();
138
+ }
139
+
140
+ /**
141
+ * Return pending notifications without acknowledging them. Engine uses this
142
+ * while constructing a prompt; it acknowledges only after the parent turn
143
+ * completes successfully so abort/error paths don't lose the notification.
144
+ *
145
+ * @param {string|{parentVpId?: string|null, sessionId?: string|null, parentSessionId?: string|null, threadId?: string|null, parentThreadId?: string|null}|null} scope
146
+ * @returns {SubAgentNotification[]}
147
+ */
148
+ export function peekPendingNotifications(scope) {
149
+ const key = bucketKey(scope);
150
+ const list = byParent.get(key) || [];
151
+ return list.slice();
152
+ }
153
+
154
+ /**
155
+ * Acknowledge notifications previously returned by peekPendingNotifications.
156
+ * Removes them from both parent and per-agent maps.
157
+ *
158
+ * @param {string|{parentVpId?: string|null, sessionId?: string|null, parentSessionId?: string|null, threadId?: string|null, parentThreadId?: string|null}|null} scope
159
+ * @param {string[]} ids
160
+ */
161
+ export function acknowledgePendingNotifications(scope, ids = []) {
162
+ const idSet = new Set(Array.isArray(ids) ? ids.filter(Boolean) : []);
163
+ if (idSet.size === 0) return;
164
+ const key = bucketKey(scope);
165
+ const list = byParent.get(key) || [];
166
+ const kept = [];
167
+ for (const rec of list) {
168
+ if (idSet.has(rec.id)) {
169
+ byAgent.delete(rec.agentId);
170
+ } else {
171
+ kept.push(rec);
172
+ }
173
+ }
174
+ byParent.set(key, kept);
175
+ }
176
+
177
+ /**
178
+ * Drain and return the pending notification for a single agent, or null.
179
+ * Used by WaitAgent on terminal so the same notification isn't also
180
+ * re-prepended to the next user turn.
181
+ *
182
+ * @param {string} agentId
183
+ * @returns {SubAgentNotification|null}
184
+ */
185
+ export function consumeNotificationForAgent(agentId) {
186
+ if (!agentId) return null;
187
+ const rec = byAgent.get(agentId);
188
+ if (!rec) return null;
189
+ byAgent.delete(agentId);
190
+ // Also remove from the parent bucket so the engine drain doesn't
191
+ // re-emit it.
192
+ const key = bucketKey({
193
+ parentVpId: rec.parentVpId,
194
+ sessionId: rec.parentSessionId,
195
+ threadId: rec.parentThreadId,
196
+ });
197
+ const list = byParent.get(key);
198
+ if (Array.isArray(list)) {
199
+ const idx = list.findIndex(r => r.id === rec.id);
200
+ if (idx >= 0) list.splice(idx, 1);
201
+ }
202
+ return rec;
203
+ }
204
+
205
+ /**
206
+ * Format a notification batch as a single XML-tagged block to prepend
207
+ * to the user's next prompt. The model sees this as system-emitted
208
+ * out-of-band context (we wrap with a literal tag so it's visually
209
+ * obvious in transcripts).
210
+ *
211
+ * @param {SubAgentNotification[]} notifs
212
+ * @returns {string} '' when notifs is empty
213
+ */
214
+ export function formatNotificationsForPrompt(notifs) {
215
+ if (!Array.isArray(notifs) || notifs.length === 0) return '';
216
+ const parts = [];
217
+ parts.push('<sub-agent-notifications>');
218
+ parts.push(
219
+ 'The following sub-agent(s) reached a terminal state while you were ' +
220
+ 'away. The user has NOT seen any of this — only you have. You MUST ' +
221
+ 'either (a) relay the result(s) to the user in your reply, or (b) act ' +
222
+ 'on the result(s) before replying. Do NOT ignore these.',
223
+ );
224
+ for (const n of notifs) {
225
+ parts.push('');
226
+ parts.push(`<notification agent="${n.agentName}" id="${n.agentId}" status="${n.status}" turns="${n.turns}">`);
227
+ if (n.error) parts.push(` error: ${n.error}`);
228
+ if (n.budgetExceeded) {
229
+ parts.push(' budgetExceeded: true');
230
+ if (n.budgetReason) parts.push(` budgetReason: ${n.budgetReason}`);
231
+ if (n.budgetUsage) parts.push(` budgetUsage: ${JSON.stringify(n.budgetUsage)}`);
232
+ }
233
+ if (n.outputFile) parts.push(` outputFile: ${n.outputFile}`);
234
+ if (n.result) {
235
+ const r = n.result.length > 1500 ? n.result.slice(0, 1500) + '…(truncated)' : n.result;
236
+ parts.push(' result:');
237
+ parts.push(` ${r.split('\n').join('\n ')}`);
238
+ }
239
+ parts.push('</notification>');
240
+ }
241
+ parts.push('</sub-agent-notifications>');
242
+ return parts.join('\n');
243
+ }
244
+
245
+ /** Reset both maps. Tests only. */
246
+ export function _resetNotifications() {
247
+ byParent.clear();
248
+ byAgent.clear();
249
+ }
250
+
251
+ /** Inspect the queue. Tests only. */
252
+ export function _peekAll() {
253
+ return {
254
+ byParent: Object.fromEntries([...byParent.entries()].map(([k, v]) => [k, v.slice()])),
255
+ byAgent: Object.fromEntries(byAgent.entries()),
256
+ };
257
+ }