@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 +1 -1
- package/yeaft/engine.js +48 -3
- package/yeaft/sub-agent/liveness.js +101 -0
- package/yeaft/sub-agent/notifications.js +257 -0
- package/yeaft/sub-agent/output-log.js +237 -0
- package/yeaft/sub-agent/runner.js +332 -146
- package/yeaft/sub-agent/status.js +81 -0
- package/yeaft/tools/agent.js +70 -10
- package/yeaft/tools/close-agent.js +55 -9
- package/yeaft/tools/list-agents.js +40 -11
- package/yeaft/tools/send-message.js +36 -11
- package/yeaft/tools/wait-agent.js +168 -110
package/package.json
CHANGED
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
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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
|
+
}
|