@yeaft/webchat-agent 0.1.951 → 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/workbench/transfer.js +74 -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
|
@@ -3,32 +3,59 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Lifecycle (per agent record in the global registry from `tools/agent.js`):
|
|
5
5
|
*
|
|
6
|
-
* created → running
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* created → running ↔ idle (mission turn finished, awaiting parent feedback)
|
|
7
|
+
* ↘
|
|
8
|
+
* → completed (terminal — budget cutoff with partial output)
|
|
9
|
+
* → failed (terminal — adapter/stream error)
|
|
10
|
+
* → closed (terminal — CloseAgent or clean finally{} drain)
|
|
11
|
+
* → abandoned (terminal — idle watchdog tripped)
|
|
11
12
|
*
|
|
12
13
|
* Each sub-agent owns:
|
|
13
14
|
* - its own Engine instance (shares parent adapter/trace/config/stores)
|
|
14
|
-
* - its own ToolRegistry: parent's minus
|
|
15
|
-
* CloseAgent, ListAgents, RouteForward, AskUser]
|
|
15
|
+
* - its own ToolRegistry: parent's minus the orchestration tools
|
|
16
16
|
* - its own messages buffer (`agent.engineMessages`) so a turn can resume
|
|
17
|
-
* after
|
|
17
|
+
* after PromptAgent
|
|
18
|
+
* - a durable output log at ~/.yeaft/sub-agents/<agentId>.log mirroring
|
|
19
|
+
* every onEvent (see output-log.js)
|
|
20
|
+
* - a liveness snapshot (toolUseCount, tokenCount, lastEventAt, …) the
|
|
21
|
+
* parent reads through WaitAgent / ListAgents
|
|
18
22
|
*
|
|
19
23
|
* The runner is fire-and-forget: `startSubAgent(agent, deps)` schedules a
|
|
20
24
|
* microtask that drives the loop and returns immediately. Parents observe
|
|
21
|
-
* via `WaitAgent` (
|
|
22
|
-
*
|
|
25
|
+
* via `WaitAgent` (which now returns a structured envelope with status,
|
|
26
|
+
* liveness, mid-stream preview, outputFile path) or via `deps.onEvent` for
|
|
27
|
+
* live UI streaming.
|
|
23
28
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
29
|
+
* Terminal transitions ALWAYS:
|
|
30
|
+
* 1. enqueue a sub-agent notification (see notifications.js) so the
|
|
31
|
+
* parent engine surfaces it on the next user turn even if the
|
|
32
|
+
* parent forgot to call WaitAgent;
|
|
33
|
+
* 2. close the output log file;
|
|
34
|
+
* 3. emit a `sub_agent_status` event with the terminal status;
|
|
35
|
+
* 4. release the subEngine reference for GC.
|
|
27
36
|
*/
|
|
28
37
|
|
|
29
38
|
import { Engine } from '../engine.js';
|
|
30
39
|
import { ToolRegistry } from '../tools/registry.js';
|
|
31
40
|
import { buildSpawnedPreamble } from './spawned-prompt.js';
|
|
41
|
+
import { STATUS, isTerminalAgentStatus } from './status.js';
|
|
42
|
+
import { createOutputLog } from './output-log.js';
|
|
43
|
+
import { makeLiveness, bumpLivenessFromEvent } from './liveness.js';
|
|
44
|
+
import { enqueueTerminalNotification } from './notifications.js';
|
|
45
|
+
// NOTE: tickAgent lives in `../tools/agent.js`, which itself imports this
|
|
46
|
+
// module (startSubAgent). To avoid the ES-module circular-import gotcha
|
|
47
|
+
// where one side sees an undefined export at module-init time, we import
|
|
48
|
+
// tickAgent dynamically inside the driver at first use. The cost is one
|
|
49
|
+
// `await import(...)` per sub-agent lifetime — negligible — and the
|
|
50
|
+
// benefit is that either module can be loaded first without ordering
|
|
51
|
+
// hazards.
|
|
52
|
+
let _tickAgent = null;
|
|
53
|
+
async function loadTickAgent() {
|
|
54
|
+
if (_tickAgent) return _tickAgent;
|
|
55
|
+
const mod = await import('../tools/agent.js');
|
|
56
|
+
_tickAgent = mod.tickAgent;
|
|
57
|
+
return _tickAgent;
|
|
58
|
+
}
|
|
32
59
|
|
|
33
60
|
const RESTRICTED_TOOLS = new Set([
|
|
34
61
|
'SpawnAgent',
|
|
@@ -42,6 +69,12 @@ const RESTRICTED_TOOLS = new Set([
|
|
|
42
69
|
'AskUser',
|
|
43
70
|
]);
|
|
44
71
|
|
|
72
|
+
/** How long an idle sub-agent may wait for a follow-up before the watchdog reaps it. */
|
|
73
|
+
const IDLE_ABANDON_MS = 5 * 60 * 1000; // 5 minutes
|
|
74
|
+
|
|
75
|
+
/** Cap on agent.lastResult (mid-stream preview) — keeps memory bounded. */
|
|
76
|
+
const LAST_RESULT_MAX_CHARS = 8 * 1024;
|
|
77
|
+
|
|
45
78
|
/**
|
|
46
79
|
* Build a child ToolRegistry by copying every tool from the parent
|
|
47
80
|
* registry except those in RESTRICTED_TOOLS.
|
|
@@ -71,7 +104,8 @@ export function isRestrictedToolName(name) {
|
|
|
71
104
|
|
|
72
105
|
/**
|
|
73
106
|
* Fire-and-forget: kick off the sub-agent loop. Mutates `agent` in place
|
|
74
|
-
* (status, result, error, engineMessages, abortController
|
|
107
|
+
* (status, result, error, engineMessages, abortController, outputLog,
|
|
108
|
+
* liveness).
|
|
75
109
|
*
|
|
76
110
|
* @param {object} agent — record from getAgentRegistry()
|
|
77
111
|
* @param {{
|
|
@@ -87,10 +121,14 @@ export function isRestrictedToolName(name) {
|
|
|
87
121
|
* yeaftDir?: string,
|
|
88
122
|
* parentName?: string,
|
|
89
123
|
* parentVpId?: string,
|
|
124
|
+
* parentSessionId?: string|null,
|
|
125
|
+
* parentThreadId?: string|null,
|
|
90
126
|
* parentVpPersona?: object,
|
|
91
127
|
* toolStats?: object,
|
|
92
128
|
* onEvent?: (agentId: string, evt: object) => void,
|
|
93
129
|
* language?: 'en'|'zh',
|
|
130
|
+
* subAgentLogDir?: string,
|
|
131
|
+
* idleAbandonMs?: number,
|
|
94
132
|
* }} deps
|
|
95
133
|
*/
|
|
96
134
|
export function startSubAgent(agent, deps = {}) {
|
|
@@ -115,21 +153,20 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
115
153
|
skillManager: deps.skillManager || null,
|
|
116
154
|
mcpManager: deps.mcpManager || null,
|
|
117
155
|
yeaftDir: deps.yeaftDir || null,
|
|
118
|
-
// Share the session-shared ToolUsageStats so sub-agent tool calls
|
|
119
|
-
// land in the same on-disk snapshot the parent records into. Sub-
|
|
120
|
-
// agents are often the heaviest tool users — leaving them out
|
|
121
|
-
// skewed `yeaft_fetch_tool_stats` output.
|
|
122
156
|
toolStats: deps.toolStats || null,
|
|
123
157
|
});
|
|
124
158
|
|
|
125
159
|
agent.subEngine = subEngine;
|
|
126
160
|
agent.engineMessages = agent.engineMessages || [];
|
|
161
|
+
agent.liveness = agent.liveness || makeLiveness();
|
|
162
|
+
agent.parentVpId = deps.parentVpId || null;
|
|
163
|
+
agent.parentSessionId = deps.parentSessionId || null;
|
|
164
|
+
agent.parentThreadId = deps.parentThreadId || 'main';
|
|
165
|
+
agent.outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
|
|
166
|
+
agent.outputFile = agent.outputLog.path;
|
|
167
|
+
agent.outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
|
|
127
168
|
|
|
128
|
-
// Compose the system-prompt-overlay we want injected.
|
|
129
|
-
// the existing `vpPersona` parameter that #buildSystemPrompt threads
|
|
130
|
-
// through to buildWorkerPrompt — appending our spawned-preamble at the
|
|
131
|
-
// end of the persona block guarantees it lands inside Layer A and is
|
|
132
|
-
// subject to the same persona caching guarantees.
|
|
169
|
+
// Compose the system-prompt-overlay we want injected.
|
|
133
170
|
const preamble = buildSpawnedPreamble({
|
|
134
171
|
parentName: deps.parentName || 'parent',
|
|
135
172
|
parentVpId: deps.parentVpId || null,
|
|
@@ -142,16 +179,10 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
142
179
|
deps.parentVpPersona && typeof deps.parentVpPersona === 'object'
|
|
143
180
|
? { ...deps.parentVpPersona }
|
|
144
181
|
: {};
|
|
145
|
-
// Append the preamble onto whatever the parent persona body looked
|
|
146
|
-
// like. If the parent had no persona, we still hand the LLM a clean
|
|
147
|
-
// sub-agent identity block so it knows the scope.
|
|
148
182
|
baseVpPersona.persona =
|
|
149
183
|
[(baseVpPersona.persona || '').trim(), preamble.trim()]
|
|
150
184
|
.filter(Boolean)
|
|
151
185
|
.join('\n\n');
|
|
152
|
-
// renderVpPersona requires a displayName to emit the persona block.
|
|
153
|
-
// If the parent did not provide one, synthesize one from agent name
|
|
154
|
-
// so the spawned-preamble actually surfaces in the system prompt.
|
|
155
186
|
if (!baseVpPersona.displayName || !String(baseVpPersona.displayName).trim()) {
|
|
156
187
|
baseVpPersona.displayName = `${deps.parentName || 'Parent'}/${agent.name || 'sub-agent'}`;
|
|
157
188
|
}
|
|
@@ -164,15 +195,18 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
164
195
|
agent.subVpPersona = baseVpPersona;
|
|
165
196
|
|
|
166
197
|
// Background driver — pumps queued user messages through engine.query
|
|
167
|
-
// turn by turn until
|
|
198
|
+
// turn by turn until the agent reaches a terminal state.
|
|
168
199
|
driveSubAgent(agent, subEngine, baseVpPersona, deps).catch((err) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
200
|
+
// The driver normally handles its own failures (stream try/catch +
|
|
201
|
+
// terminal transition). This .catch covers genuinely unexpected
|
|
202
|
+
// throws between turns (e.g. inside dequeueNextUserPrompt) so we
|
|
203
|
+
// never leave a zombie record without a terminal status.
|
|
204
|
+
if (isTerminalAgentStatus(agent.status)) return;
|
|
205
|
+
transitionTerminal(agent, STATUS.FAILED, {
|
|
206
|
+
error: err && err.message ? err.message : String(err),
|
|
207
|
+
diagnostic: 'driver_error',
|
|
208
|
+
deps,
|
|
209
|
+
});
|
|
176
210
|
});
|
|
177
211
|
}
|
|
178
212
|
|
|
@@ -181,152 +215,304 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
181
215
|
* 1. Pull the next pending user message from the queue (or, on first
|
|
182
216
|
* turn, the mission itself).
|
|
183
217
|
* 2. Run engine.query, forwarding every event to deps.onEvent (tagged
|
|
184
|
-
* with agentId)
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
218
|
+
* with agentId) AND mirroring to the output log AND updating
|
|
219
|
+
* liveness + lastResult.
|
|
220
|
+
* 3. Stash the final assistant text on agent.result, tickAgent for
|
|
221
|
+
* budget enforcement, mark idle.
|
|
222
|
+
* 4. Wait for either a new PromptAgent (status flips to running) OR
|
|
223
|
+
* CloseAgent (status=='closed') OR the idle watchdog firing
|
|
224
|
+
* (status=='abandoned').
|
|
189
225
|
*/
|
|
190
226
|
async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
191
227
|
const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
228
|
+
const idleAbandonMs = typeof deps.idleAbandonMs === 'number' && deps.idleAbandonMs > 0
|
|
229
|
+
? deps.idleAbandonMs : IDLE_ABANDON_MS;
|
|
230
|
+
|
|
195
231
|
const wrapEvt = (evt) => ({ ...evt, agentId: agent.id, agentName: agent.name });
|
|
196
232
|
|
|
197
|
-
|
|
233
|
+
const emit = (evt) => {
|
|
234
|
+
const wrapped = wrapEvt(evt);
|
|
235
|
+
try { agent.outputLog?.write(wrapped); } catch { /* ignore log failures */ }
|
|
236
|
+
if (onEvent) {
|
|
237
|
+
try { onEvent(agent.id, wrapped); } catch { /* ignore listener errors */ }
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
198
241
|
const dequeueNextUserPrompt = () => {
|
|
199
242
|
if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
|
|
200
243
|
return agent.pendingPrompts.shift() || null;
|
|
201
244
|
};
|
|
202
245
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
agent.
|
|
207
|
-
|
|
208
|
-
|
|
246
|
+
try {
|
|
247
|
+
// Seed: mission becomes the first user prompt.
|
|
248
|
+
if (!agent.pendingPrompts) agent.pendingPrompts = [];
|
|
249
|
+
if (agent.mission && !agent.__missionSeeded) {
|
|
250
|
+
agent.pendingPrompts.push(agent.mission);
|
|
251
|
+
agent.__missionSeeded = true;
|
|
252
|
+
}
|
|
209
253
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'running' })); } catch { /* ignore */ }
|
|
213
|
-
}
|
|
254
|
+
agent.status = STATUS.RUNNING;
|
|
255
|
+
emit({ type: 'sub_agent_status', status: STATUS.RUNNING });
|
|
214
256
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
await waitUntilResumed(agent);
|
|
224
|
-
// Either we have a new prompt now (back to running) or status is closed.
|
|
225
|
-
if (agent.status === 'closed') break;
|
|
226
|
-
agent.status = 'running';
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
257
|
+
while (!isTerminalAgentStatus(agent.status)) {
|
|
258
|
+
const prompt = dequeueNextUserPrompt();
|
|
259
|
+
if (!prompt) {
|
|
260
|
+
// No queued work — go idle and wait for PromptAgent / CloseAgent /
|
|
261
|
+
// watchdog.
|
|
262
|
+
agent.status = STATUS.IDLE;
|
|
263
|
+
agent.idleSince = Date.now();
|
|
264
|
+
emit({ type: 'sub_agent_status', status: STATUS.IDLE });
|
|
229
265
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
scenario: 'chat',
|
|
239
|
-
vpPersona,
|
|
240
|
-
});
|
|
241
|
-
for await (const evt of stream) {
|
|
242
|
-
// Forward every sub-engine event to the parent observer with
|
|
243
|
-
// the agent identity attached. Frontend renders these inside
|
|
244
|
-
// the sub-agent's collapsed card.
|
|
245
|
-
if (onEvent) {
|
|
246
|
-
try { onEvent(agent.id, wrapEvt(evt)); } catch { /* ignore listener errors */ }
|
|
247
|
-
}
|
|
248
|
-
if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
|
|
249
|
-
assistantText += evt.text;
|
|
266
|
+
const reason = await waitUntilResumed(agent, idleAbandonMs);
|
|
267
|
+
if (reason === 'abandoned') {
|
|
268
|
+
transitionTerminal(agent, STATUS.ABANDONED, {
|
|
269
|
+
error: `idle for more than ${idleAbandonMs}ms with no follow-up`,
|
|
270
|
+
diagnostic: 'idle_watchdog',
|
|
271
|
+
deps,
|
|
272
|
+
});
|
|
273
|
+
break;
|
|
250
274
|
}
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
275
|
+
if (isTerminalAgentStatus(agent.status)) break;
|
|
276
|
+
agent.idleSince = null;
|
|
277
|
+
agent.status = STATUS.RUNNING;
|
|
278
|
+
emit({ type: 'sub_agent_status', status: STATUS.RUNNING });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let assistantText = '';
|
|
283
|
+
let endedNormally = false;
|
|
284
|
+
let streamError = null;
|
|
285
|
+
const turnTokenStart = agent.liveness?.tokenCount || 0;
|
|
286
|
+
let turnUsageTokens = 0;
|
|
287
|
+
try {
|
|
288
|
+
const stream = subEngine.query({
|
|
289
|
+
prompt,
|
|
290
|
+
messages: agent.engineMessages,
|
|
291
|
+
signal: agent.abortController?.signal,
|
|
292
|
+
scenario: 'chat',
|
|
293
|
+
vpPersona,
|
|
294
|
+
});
|
|
295
|
+
for await (const evt of stream) {
|
|
296
|
+
// Liveness — update first so even listener throws don't lose
|
|
297
|
+
// the bump.
|
|
298
|
+
bumpLivenessFromEvent(agent.liveness, evt);
|
|
299
|
+
|
|
300
|
+
// Mirror to log + UI sink.
|
|
301
|
+
if (agent.outputLog) {
|
|
302
|
+
try { agent.outputLog.write(wrapEvt(evt)); } catch { /* ignore */ }
|
|
303
|
+
}
|
|
304
|
+
if (onEvent) {
|
|
305
|
+
try { onEvent(agent.id, wrapEvt(evt)); } catch { /* ignore listener errors */ }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
|
|
309
|
+
assistantText += evt.text;
|
|
310
|
+
// Mid-stream visibility: keep lastResult fresh so a parent
|
|
311
|
+
// calling WaitAgent during a long generation sees what the
|
|
312
|
+
// child is currently saying, not stale text from the prior
|
|
313
|
+
// turn.
|
|
314
|
+
agent.lastResult = capTail(assistantText, LAST_RESULT_MAX_CHARS);
|
|
315
|
+
}
|
|
316
|
+
if (evt && evt.type === 'usage') {
|
|
317
|
+
turnUsageTokens += (evt.inputTokens || 0) + (evt.outputTokens || 0);
|
|
318
|
+
}
|
|
319
|
+
if (evt && evt.type === 'error' && evt.error) {
|
|
320
|
+
streamError = evt.error.message || String(evt.error);
|
|
321
|
+
}
|
|
322
|
+
if (evt && evt.type === 'stop') {
|
|
323
|
+
if (evt.stopReason === 'end_turn' || evt.stopReason === 'stop_sequence') {
|
|
324
|
+
endedNormally = true;
|
|
325
|
+
}
|
|
257
326
|
}
|
|
258
327
|
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
transitionTerminal(agent, STATUS.FAILED, {
|
|
330
|
+
error: err && err.message ? err.message : String(err),
|
|
331
|
+
diagnostic: 'query_error',
|
|
332
|
+
deps,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
259
335
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
336
|
+
|
|
337
|
+
if (streamError) {
|
|
338
|
+
transitionTerminal(agent, STATUS.FAILED, {
|
|
339
|
+
error: streamError,
|
|
340
|
+
diagnostic: 'stream_error',
|
|
341
|
+
deps,
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
266
344
|
}
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
345
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// throwing — treat the same as a thrown error.
|
|
273
|
-
agent.status = 'failed';
|
|
274
|
-
agent.error = streamError;
|
|
275
|
-
agent.diagnostics.push({ type: 'stream_error', error: streamError, at: Date.now() });
|
|
276
|
-
if (onEvent) {
|
|
277
|
-
try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: streamError })); } catch { /* ignore */ }
|
|
346
|
+
if (isTerminalAgentStatus(agent.status)) {
|
|
347
|
+
return;
|
|
278
348
|
}
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
349
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
agent.lastResult = assistantText;
|
|
289
|
-
agent.usage.turns = (agent.usage.turns || 0) + 1;
|
|
290
|
-
|
|
291
|
-
if (!endedNormally) {
|
|
292
|
-
// Adapter aborted/errored without end_turn — mark failed.
|
|
293
|
-
agent.status = 'failed';
|
|
294
|
-
agent.error = agent.error || 'sub-agent stream ended without end_turn';
|
|
295
|
-
if (onEvent) {
|
|
296
|
-
try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: agent.error })); } catch { /* ignore */ }
|
|
350
|
+
// Persist the turn into the local message buffer so subsequent
|
|
351
|
+
// PromptAgent continuations see context.
|
|
352
|
+
agent.engineMessages.push({ role: 'user', content: prompt });
|
|
353
|
+
if (assistantText) {
|
|
354
|
+
agent.engineMessages.push({ role: 'assistant', content: assistantText });
|
|
297
355
|
}
|
|
298
|
-
|
|
299
|
-
|
|
356
|
+
agent.lastResult = capTail(assistantText, LAST_RESULT_MAX_CHARS);
|
|
357
|
+
agent.result = assistantText;
|
|
358
|
+
// NB: agent.usage.turns is incremented by tickAgent below — do NOT
|
|
359
|
+
// bump it here too or every turn would double-count and trip
|
|
360
|
+
// max_turns budgets at half the configured limit.
|
|
300
361
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
362
|
+
if (!endedNormally) {
|
|
363
|
+
transitionTerminal(agent, STATUS.FAILED, {
|
|
364
|
+
error: agent.error || 'sub-agent stream ended without end_turn',
|
|
365
|
+
diagnostic: 'no_end_turn',
|
|
366
|
+
deps,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Budget enforcement: tickAgent will flip the agent to 'completed'
|
|
372
|
+
// with a budget_exceeded envelope if any explicit budget bound was
|
|
373
|
+
// tripped. The driver respects that and exits cleanly. We
|
|
374
|
+
// dynamically import to avoid the agent.js↔runner.js cycle.
|
|
375
|
+
let tickResult = null;
|
|
376
|
+
try {
|
|
377
|
+
const tickAgent = await loadTickAgent();
|
|
378
|
+
if (typeof tickAgent === 'function') {
|
|
379
|
+
const textTokenDelta = Math.max(0, (agent.liveness?.tokenCount || 0) - turnTokenStart);
|
|
380
|
+
const tokenDelta = turnUsageTokens > 0 ? turnUsageTokens : textTokenDelta;
|
|
381
|
+
tickResult = tickAgent(agent.id, {
|
|
382
|
+
turns: 1,
|
|
383
|
+
tokens: tokenDelta,
|
|
384
|
+
partial_output: assistantText,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
} catch { /* budget enforcement is best-effort */ }
|
|
388
|
+
if (tickResult) {
|
|
389
|
+
// tickAgent already flipped status to 'completed' and aborted
|
|
390
|
+
// the signal. Still want a terminal-status event + notification.
|
|
391
|
+
finalizeTerminal(agent, STATUS.COMPLETED, { error: null, deps });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Turn complete cleanly. Stash the result for WaitAgent and emit
|
|
396
|
+
// a turn-end event for the UI. Loop re-enters: if more
|
|
397
|
+
// pendingPrompts queued by PromptAgent, run the next; else idle.
|
|
398
|
+
emit({ type: 'sub_agent_turn_end', content: assistantText });
|
|
307
399
|
}
|
|
400
|
+
} finally {
|
|
401
|
+
// Always clean up driver-owned resources. We intentionally do NOT
|
|
402
|
+
// unset agent.result / agent.lastResult / agent.liveness / agent.
|
|
403
|
+
// outputFile — those are observable by the parent after termination.
|
|
404
|
+
try { agent.outputLog?.close(); } catch { /* ignore */ }
|
|
405
|
+
agent.subEngine = null;
|
|
406
|
+
agent.__driverStarted = false;
|
|
407
|
+
agent.idleSince = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Flip an agent to a terminal status, emit the matching status event,
|
|
413
|
+
* mirror to the log, and enqueue a re-entry notification for the
|
|
414
|
+
* parent. Idempotent — if status is already terminal we no-op.
|
|
415
|
+
*
|
|
416
|
+
* @param {object} agent
|
|
417
|
+
* @param {string} status
|
|
418
|
+
* @param {{ error?: string|null, diagnostic?: string, deps?: object }} opts
|
|
419
|
+
*/
|
|
420
|
+
function transitionTerminal(agent, status, opts = {}) {
|
|
421
|
+
if (isTerminalAgentStatus(agent.status)) return;
|
|
422
|
+
agent.status = status;
|
|
423
|
+
if (opts.error) agent.error = opts.error;
|
|
424
|
+
agent.diagnostics = agent.diagnostics || [];
|
|
425
|
+
agent.diagnostics.push({ type: opts.diagnostic || `transition_${status}`, error: opts.error || null, at: Date.now() });
|
|
426
|
+
finalizeTerminal(agent, status, { error: opts.error || null, deps: opts.deps });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Emit the terminal status event, write it to the log, enqueue a
|
|
431
|
+
* notification for the parent. Split out from transitionTerminal so
|
|
432
|
+
* tickAgent's external status flip (it sets 'completed' itself) can
|
|
433
|
+
* still go through the same notification path.
|
|
434
|
+
*/
|
|
435
|
+
function finalizeTerminal(agent, status, { error, deps } = {}) {
|
|
436
|
+
// Mark notified-once to avoid double notifications if both tickAgent
|
|
437
|
+
// and the driver loop converge on the same terminal transition.
|
|
438
|
+
if (agent.__terminalNotified) return;
|
|
439
|
+
agent.__terminalNotified = true;
|
|
440
|
+
|
|
441
|
+
const evt = {
|
|
442
|
+
type: 'sub_agent_status',
|
|
443
|
+
agentId: agent.id,
|
|
444
|
+
agentName: agent.name,
|
|
445
|
+
status,
|
|
446
|
+
error: error || agent.error || null,
|
|
447
|
+
};
|
|
448
|
+
try { agent.outputLog?.write(evt); } catch { /* ignore */ }
|
|
449
|
+
if (deps && typeof deps.onEvent === 'function') {
|
|
450
|
+
try { deps.onEvent(agent.id, evt); } catch { /* ignore */ }
|
|
308
451
|
}
|
|
452
|
+
|
|
453
|
+
// Push the re-entry notification so the parent learns about this
|
|
454
|
+
// even if it forgot to call WaitAgent.
|
|
455
|
+
try {
|
|
456
|
+
const budgetResult = agent.result && typeof agent.result === 'object'
|
|
457
|
+
&& agent.result.status === 'budget_exceeded'
|
|
458
|
+
? agent.result
|
|
459
|
+
: null;
|
|
460
|
+
enqueueTerminalNotification({
|
|
461
|
+
agentId: agent.id,
|
|
462
|
+
agentName: agent.name,
|
|
463
|
+
status,
|
|
464
|
+
result: budgetResult
|
|
465
|
+
? (budgetResult.partial_output || '')
|
|
466
|
+
: (typeof agent.result === 'string' ? agent.result : (agent.lastResult || '')),
|
|
467
|
+
error: error || agent.error || null,
|
|
468
|
+
outputFile: agent.outputFile || null,
|
|
469
|
+
turns: agent.usage?.turns || 0,
|
|
470
|
+
parentVpId: agent.parentVpId || null,
|
|
471
|
+
parentSessionId: agent.parentSessionId || null,
|
|
472
|
+
parentThreadId: agent.parentThreadId || 'main',
|
|
473
|
+
budgetExceeded: !!budgetResult,
|
|
474
|
+
budgetReason: budgetResult?.reason || null,
|
|
475
|
+
budgetUsage: budgetResult?.usage || null,
|
|
476
|
+
});
|
|
477
|
+
} catch { /* never let the notification queue throw kill the driver */ }
|
|
309
478
|
}
|
|
310
479
|
|
|
311
480
|
/**
|
|
312
|
-
* Resume signal — resolves
|
|
313
|
-
* -
|
|
314
|
-
* -
|
|
481
|
+
* Resume signal — resolves with a string reason:
|
|
482
|
+
* - 'prompt' : pendingPrompts non-empty (PromptAgent fired)
|
|
483
|
+
* - 'terminal' : agent status flipped to terminal externally
|
|
484
|
+
* - 'abandoned' : idle timer expired
|
|
315
485
|
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
486
|
+
* @param {object} agent
|
|
487
|
+
* @param {number} idleAbandonMs
|
|
488
|
+
* @returns {Promise<'prompt'|'terminal'|'abandoned'>}
|
|
318
489
|
*/
|
|
319
|
-
function waitUntilResumed(agent) {
|
|
490
|
+
function waitUntilResumed(agent, idleAbandonMs) {
|
|
320
491
|
return new Promise((resolve) => {
|
|
492
|
+
const start = Date.now();
|
|
321
493
|
const tick = () => {
|
|
322
|
-
if (agent.status
|
|
323
|
-
return resolve();
|
|
494
|
+
if (isTerminalAgentStatus(agent.status)) {
|
|
495
|
+
return resolve('terminal');
|
|
324
496
|
}
|
|
325
497
|
if (Array.isArray(agent.pendingPrompts) && agent.pendingPrompts.length > 0) {
|
|
326
|
-
return resolve();
|
|
498
|
+
return resolve('prompt');
|
|
499
|
+
}
|
|
500
|
+
if (idleAbandonMs > 0 && Date.now() - start >= idleAbandonMs) {
|
|
501
|
+
return resolve('abandoned');
|
|
327
502
|
}
|
|
328
503
|
setTimeout(tick, 50);
|
|
329
504
|
};
|
|
330
505
|
tick();
|
|
331
506
|
});
|
|
332
507
|
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Cap a tailing string to N chars while keeping the most recent content.
|
|
511
|
+
* Used for agent.lastResult so a runaway model can't OOM the registry.
|
|
512
|
+
*/
|
|
513
|
+
function capTail(text, maxChars) {
|
|
514
|
+
if (typeof text !== 'string' || text.length <= maxChars) return text;
|
|
515
|
+
return '…' + text.slice(text.length - maxChars);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export const _internals = { IDLE_ABANDON_MS, LAST_RESULT_MAX_CHARS };
|