@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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status.js — Centralized status vocabulary for the sub-agent subsystem.
|
|
3
|
+
*
|
|
4
|
+
* The same string set used to be sprinkled across tools/agent.js,
|
|
5
|
+
* tools/wait-agent.js, tools/send-message.js, tools/close-agent.js,
|
|
6
|
+
* tools/list-agents.js and sub-agent/runner.js. Every time we added a
|
|
7
|
+
* status (e.g. 'abandoned' for the idle watchdog) we had to chase 5+
|
|
8
|
+
* files and update string lists. This module is the single source of
|
|
9
|
+
* truth.
|
|
10
|
+
*
|
|
11
|
+
* Status lifecycle:
|
|
12
|
+
*
|
|
13
|
+
* created → running ↔ idle (after each turn ends without queued prompt)
|
|
14
|
+
* ↓ ↓
|
|
15
|
+
* failed completed
|
|
16
|
+
* ↓ ↓
|
|
17
|
+
* closed abandoned (idle too long; reaped by watchdog)
|
|
18
|
+
*
|
|
19
|
+
* - 'created' : registry record exists but the driver hasn't taken a
|
|
20
|
+
* step yet. Transient — flips to 'running' on first tick.
|
|
21
|
+
* - 'running' : the sub-engine is processing a prompt.
|
|
22
|
+
* - 'idle' : the previous turn ended cleanly and the queue is empty.
|
|
23
|
+
* The driver is parked in waitUntilResumed().
|
|
24
|
+
* - 'completed' : terminal success — typically set by tickAgent() when a
|
|
25
|
+
* budget cutoff is hit cleanly with a partial_output.
|
|
26
|
+
* - 'failed' : terminal — driver/adapter/stream raised; agent.error set.
|
|
27
|
+
* - 'closed' : terminal — CloseAgent called (or driver finally{} reaped
|
|
28
|
+
* a cleanly-finishing agent).
|
|
29
|
+
* - 'abandoned' : terminal — idle watchdog tripped (no prompt arrived in
|
|
30
|
+
* IDLE_ABANDON_MS). Distinct from 'closed' so the parent
|
|
31
|
+
* can tell "the parent forgot about me" from "the parent
|
|
32
|
+
* deliberately wrapped me up".
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const STATUS = Object.freeze({
|
|
36
|
+
CREATED: 'created',
|
|
37
|
+
RUNNING: 'running',
|
|
38
|
+
IDLE: 'idle',
|
|
39
|
+
COMPLETED: 'completed',
|
|
40
|
+
FAILED: 'failed',
|
|
41
|
+
CLOSED: 'closed',
|
|
42
|
+
ABANDONED: 'abandoned',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const TERMINAL = new Set([STATUS.COMPLETED, STATUS.FAILED, STATUS.CLOSED, STATUS.ABANDONED]);
|
|
46
|
+
const INTERACTIVE = new Set([STATUS.CREATED, STATUS.RUNNING, STATUS.IDLE]);
|
|
47
|
+
|
|
48
|
+
/** Is this status a permanent terminal state? */
|
|
49
|
+
export function isTerminalAgentStatus(status) {
|
|
50
|
+
return TERMINAL.has(status);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Is the agent still alive and interactable (not terminal)? */
|
|
54
|
+
export function isInteractiveAgentStatus(status) {
|
|
55
|
+
return INTERACTIVE.has(status);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Can PromptAgent successfully queue a prompt at this status? */
|
|
59
|
+
export function isPromptableAgentStatus(status) {
|
|
60
|
+
// Even 'created' is promptable — the mission is the implicit first prompt
|
|
61
|
+
// and a SendMessage racing the driver simply gets dequeued after the
|
|
62
|
+
// mission.
|
|
63
|
+
return status === STATUS.CREATED || status === STATUS.RUNNING || status === STATUS.IDLE;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the canonical human-facing label for a status. Used by UI nudges
|
|
68
|
+
* and `next_steps` strings so we don't drift between modules.
|
|
69
|
+
*/
|
|
70
|
+
export function describeAgentStatus(status) {
|
|
71
|
+
switch (status) {
|
|
72
|
+
case STATUS.CREATED: return 'just spawned';
|
|
73
|
+
case STATUS.RUNNING: return 'running a turn';
|
|
74
|
+
case STATUS.IDLE: return 'idle (turn ended, queue empty)';
|
|
75
|
+
case STATUS.COMPLETED: return 'completed (terminal)';
|
|
76
|
+
case STATUS.FAILED: return 'failed (terminal)';
|
|
77
|
+
case STATUS.CLOSED: return 'closed (terminal)';
|
|
78
|
+
case STATUS.ABANDONED: return 'abandoned by idle watchdog (terminal)';
|
|
79
|
+
default: return String(status || 'unknown');
|
|
80
|
+
}
|
|
81
|
+
}
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -24,6 +24,8 @@ import { defineTool } from './types.js';
|
|
|
24
24
|
import { randomUUID } from 'crypto';
|
|
25
25
|
import { getPersona, listPersonaIds } from '../personas.js';
|
|
26
26
|
import { startSubAgent } from '../sub-agent/runner.js';
|
|
27
|
+
import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
|
|
28
|
+
import { makeLiveness } from '../sub-agent/liveness.js';
|
|
27
29
|
|
|
28
30
|
/** In-memory sub-agent registry. */
|
|
29
31
|
const agents = new Map();
|
|
@@ -33,6 +35,44 @@ export function getAgentRegistry() {
|
|
|
33
35
|
return agents;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
const MAIN_THREAD_ID = 'main';
|
|
39
|
+
|
|
40
|
+
function cleanString(value) {
|
|
41
|
+
return (typeof value === 'string' && value.trim()) ? value.trim() : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getCallerAgentScope(ctx = {}) {
|
|
45
|
+
const deps = ctx?.parentEngineDeps || {};
|
|
46
|
+
return {
|
|
47
|
+
sessionId: cleanString(deps.parentSessionId ?? ctx?.sessionId),
|
|
48
|
+
parentVpId: cleanString(deps.parentVpId ?? ctx?.senderVpId),
|
|
49
|
+
parentThreadId: cleanString(deps.parentThreadId ?? ctx?.threadId),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function agentBelongsToCaller(agent, ctx = {}) {
|
|
54
|
+
if (!agent) return false;
|
|
55
|
+
const scope = getCallerAgentScope(ctx);
|
|
56
|
+
return agentBelongsToScope(agent, scope);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function agentBelongsToScope(agent, scope = {}) {
|
|
60
|
+
if (!agent) return false;
|
|
61
|
+
const agentSessionId = cleanString(agent.parentSessionId);
|
|
62
|
+
const scopeSessionId = cleanString(scope.sessionId);
|
|
63
|
+
const agentVpId = cleanString(agent.parentVpId);
|
|
64
|
+
const scopeVpId = cleanString(scope.parentVpId);
|
|
65
|
+
const agentThreadId = cleanString(agent.parentThreadId);
|
|
66
|
+
const scopeThreadId = cleanString(scope.parentThreadId);
|
|
67
|
+
if (!scopeSessionId && !scopeVpId && !scopeThreadId) {
|
|
68
|
+
return !agentSessionId && !agentVpId;
|
|
69
|
+
}
|
|
70
|
+
if ((agentSessionId || scopeSessionId) && agentSessionId !== scopeSessionId) return false;
|
|
71
|
+
if ((agentVpId || scopeVpId) && agentVpId !== scopeVpId) return false;
|
|
72
|
+
if (!agentThreadId && !scopeThreadId) return true;
|
|
73
|
+
return (agentThreadId || MAIN_THREAD_ID) === (scopeThreadId || MAIN_THREAD_ID);
|
|
74
|
+
}
|
|
75
|
+
|
|
36
76
|
/** Reset registry (for tests). */
|
|
37
77
|
export function _resetAgentRegistry() {
|
|
38
78
|
agents.clear();
|
|
@@ -138,7 +178,7 @@ export function budgetExceededResult(agent, reason) {
|
|
|
138
178
|
export function tickAgent(agentId, delta = {}, now = Date.now()) {
|
|
139
179
|
const agent = agents.get(agentId);
|
|
140
180
|
if (!agent) return null;
|
|
141
|
-
if (agent.status
|
|
181
|
+
if (isTerminalAgentStatus(agent.status)) return null;
|
|
142
182
|
|
|
143
183
|
if (typeof delta.tokens === 'number' && delta.tokens > 0) {
|
|
144
184
|
agent.usage.tokens += delta.tokens;
|
|
@@ -155,7 +195,7 @@ export function tickAgent(agentId, delta = {}, now = Date.now()) {
|
|
|
155
195
|
|
|
156
196
|
const envelope = budgetExceededResult(agent, check.reason);
|
|
157
197
|
agent.result = envelope;
|
|
158
|
-
agent.status =
|
|
198
|
+
agent.status = STATUS.COMPLETED;
|
|
159
199
|
agent.diagnostics.push({
|
|
160
200
|
type: 'budget_exceeded',
|
|
161
201
|
limit: check.limit,
|
|
@@ -262,10 +302,15 @@ you just kicked off.`,
|
|
|
262
302
|
}
|
|
263
303
|
const spec = validation.spec;
|
|
264
304
|
const { name, cwd } = input;
|
|
305
|
+
const callerScope = getCallerAgentScope(ctx);
|
|
265
306
|
|
|
266
|
-
// Check for name collision
|
|
307
|
+
// Check for name collision — any non-terminal agent with the same
|
|
308
|
+
// name blocks the new spawn. Terminal agents (closed/failed/abandoned/
|
|
309
|
+
// completed) free the name up for reuse. Scope this to the caller's
|
|
310
|
+
// Session/VP/thread so independent sessions can reuse natural names.
|
|
267
311
|
for (const [, agent] of agents) {
|
|
268
|
-
if (agent.name === name && agent.status
|
|
312
|
+
if (agent.name === name && !isTerminalAgentStatus(agent.status)
|
|
313
|
+
&& agentBelongsToScope(agent, callerScope)) {
|
|
269
314
|
return JSON.stringify({
|
|
270
315
|
next_steps: ERROR_NEXT_STEPS,
|
|
271
316
|
error: `Agent "${name}" already exists. Close it first or use a different name.`,
|
|
@@ -287,28 +332,42 @@ you just kicked off.`,
|
|
|
287
332
|
personaData: persona || null,
|
|
288
333
|
budget: spec.budget,
|
|
289
334
|
cwd: cwd || ctx?.cwd || process.cwd(),
|
|
290
|
-
status:
|
|
335
|
+
status: STATUS.CREATED,
|
|
291
336
|
messages: [],
|
|
292
337
|
result: null,
|
|
338
|
+
lastResult: '',
|
|
293
339
|
partial_output: '',
|
|
294
340
|
diagnostics: [],
|
|
295
341
|
usage: { tokens: 0, turns: 0, startedAt: now },
|
|
296
342
|
createdAt: now,
|
|
297
343
|
trace: [],
|
|
344
|
+
// Liveness counters — kept fresh by the runner from each sub-engine
|
|
345
|
+
// event so WaitAgent/ListAgents can show real-time progress.
|
|
346
|
+
liveness: makeLiveness(),
|
|
347
|
+
// Path to the durable JSONL event log. Populated by the runner
|
|
348
|
+
// when startSubAgent attaches createOutputLog(); we initialize to
|
|
349
|
+
// null so the field always exists on the record shape.
|
|
350
|
+
outputFile: null,
|
|
351
|
+
// ParentVpId is mirrored from deps when the driver starts so the
|
|
352
|
+
// notification queue can bucket by parent Session/VP/thread.
|
|
353
|
+
parentVpId: callerScope.parentVpId,
|
|
354
|
+
parentSessionId: callerScope.sessionId,
|
|
355
|
+
parentThreadId: callerScope.parentThreadId,
|
|
298
356
|
abortController: new AbortController(),
|
|
299
357
|
};
|
|
300
358
|
|
|
301
359
|
agents.set(agentId, agent);
|
|
302
360
|
|
|
303
|
-
//
|
|
304
|
-
// it returns immediately. The parent observes via WaitAgent (
|
|
305
|
-
//
|
|
361
|
+
// Actually spawn the sub-agent driver. This is fire-and-forget;
|
|
362
|
+
// it returns immediately. The parent observes via WaitAgent (which
|
|
363
|
+
// surfaces status + liveness + outputFile) or via the engine's
|
|
364
|
+
// sub-agent event sink (live UI streaming).
|
|
306
365
|
const deps = ctx?.parentEngineDeps;
|
|
307
366
|
if (deps && deps.adapter) {
|
|
308
367
|
try {
|
|
309
368
|
startSubAgent(agent, deps);
|
|
310
369
|
} catch (err) {
|
|
311
|
-
agent.status =
|
|
370
|
+
agent.status = STATUS.FAILED;
|
|
312
371
|
agent.error = err && err.message ? err.message : String(err);
|
|
313
372
|
agent.diagnostics.push({ type: 'spawn_error', error: agent.error, at: Date.now() });
|
|
314
373
|
return JSON.stringify({
|
|
@@ -333,7 +392,8 @@ you just kicked off.`,
|
|
|
333
392
|
persona: spec.persona || null,
|
|
334
393
|
budget: spec.budget || null,
|
|
335
394
|
status: agent.status,
|
|
336
|
-
|
|
395
|
+
outputFile: agent.outputFile || null,
|
|
396
|
+
message: `Sub-agent "${name}" spawned (${agentId}). Use WaitAgent to collect its first turn output, PromptAgent to give it more work, CloseAgent to finish. Read \`outputFile\` for the durable event log at any time.`,
|
|
337
397
|
});
|
|
338
398
|
},
|
|
339
399
|
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* close-agent.js — Close a sub-agent and clean up.
|
|
3
|
+
*
|
|
4
|
+
* Marks status='closed' (terminal), aborts the abort controller so any
|
|
5
|
+
* in-flight engine.query stops promptly, and drains any pending re-entry
|
|
6
|
+
* notification for the agent so the engine doesn't redeliver it on the
|
|
7
|
+
* next user turn.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { defineTool } from './types.js';
|
|
6
|
-
import { getAgentRegistry } from './agent.js';
|
|
11
|
+
import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
|
|
12
|
+
import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
|
|
13
|
+
import { consumeNotificationForAgent, enqueueTerminalNotification } from '../sub-agent/notifications.js';
|
|
14
|
+
import { snapshotLiveness } from '../sub-agent/liveness.js';
|
|
7
15
|
|
|
8
16
|
export default defineTool({
|
|
9
17
|
name: 'CloseAgent',
|
|
@@ -33,8 +41,6 @@ Do NOT end your turn silently right after CloseAgent.`,
|
|
|
33
41
|
isConcurrencySafe: () => false,
|
|
34
42
|
isReadOnly: () => false,
|
|
35
43
|
async execute(input, ctx) {
|
|
36
|
-
// NB: next_steps is the FIRST envelope field — the registry's 1 KiB
|
|
37
|
-
// tail-truncation would eat it if it lived at the end.
|
|
38
44
|
const ERROR_NEXT_STEPS =
|
|
39
45
|
'That call failed — see `error`. Either correct the arguments and ' +
|
|
40
46
|
'retry, or tell the user what went wrong. Do NOT end your turn ' +
|
|
@@ -49,21 +55,58 @@ Do NOT end your turn silently right after CloseAgent.`,
|
|
|
49
55
|
if (!agent) {
|
|
50
56
|
return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
|
|
51
57
|
}
|
|
58
|
+
if (!agentBelongsToCaller(agent, ctx)) {
|
|
59
|
+
return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
|
|
60
|
+
}
|
|
52
61
|
|
|
53
62
|
if (result) {
|
|
54
63
|
agent.result = result;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// wait loop on the next 50ms tick.
|
|
66
|
+
// Abort any in-flight engine.query so the driver loop exits promptly.
|
|
67
|
+
// Cooperative — if the driver is mid-stream the adapter receives the
|
|
68
|
+
// signal; if it's idle, status flip ends the wait loop on the next tick.
|
|
61
69
|
if (agent.abortController && !agent.abortController.signal.aborted) {
|
|
62
70
|
try { agent.abortController.abort('closed'); } catch { /* ignore */ }
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
const finalResult = agent.result
|
|
66
|
-
|
|
73
|
+
const finalResult = (typeof agent.result === 'string' && agent.result)
|
|
74
|
+
? agent.result
|
|
75
|
+
: (agent.lastResult || '');
|
|
76
|
+
|
|
77
|
+
// If the agent had already gone terminal (e.g. failed) before we got
|
|
78
|
+
// here, preserve that status; otherwise mark closed. Either way drain
|
|
79
|
+
// any pending re-entry notification — the parent is explicitly
|
|
80
|
+
// wrapping up so it doesn't need another nudge.
|
|
81
|
+
const wasTerminal = isTerminalAgentStatus(agent.status);
|
|
82
|
+
if (!wasTerminal) {
|
|
83
|
+
agent.status = STATUS.CLOSED;
|
|
84
|
+
// The driver may not yet have observed the abort / status flip; push
|
|
85
|
+
// a notification so the queue stays consistent (idempotent inside
|
|
86
|
+
// the notifications module). The driver's own finalizeTerminal()
|
|
87
|
+
// would also try to enqueue but the __terminalNotified guard makes
|
|
88
|
+
// that a no-op.
|
|
89
|
+
try {
|
|
90
|
+
enqueueTerminalNotification({
|
|
91
|
+
agentId: agent.id,
|
|
92
|
+
agentName: agent.name,
|
|
93
|
+
status: STATUS.CLOSED,
|
|
94
|
+
result: finalResult,
|
|
95
|
+
error: agent.error || null,
|
|
96
|
+
outputFile: agent.outputFile || null,
|
|
97
|
+
turns: agent.usage?.turns || 0,
|
|
98
|
+
parentVpId: agent.parentVpId || null,
|
|
99
|
+
parentSessionId: agent.parentSessionId || null,
|
|
100
|
+
parentThreadId: agent.parentThreadId || 'main',
|
|
101
|
+
});
|
|
102
|
+
} catch { /* never block close on notification queue */ }
|
|
103
|
+
agent.__terminalNotified = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The parent is acknowledging the agent right now via this tool
|
|
107
|
+
// call; drop the queued notification so the engine doesn't
|
|
108
|
+
// double-deliver on its next user turn.
|
|
109
|
+
consumeNotificationForAgent(agent.id);
|
|
67
110
|
|
|
68
111
|
return JSON.stringify({
|
|
69
112
|
next_steps:
|
|
@@ -73,7 +116,10 @@ Do NOT end your turn silently right after CloseAgent.`,
|
|
|
73
116
|
success: true,
|
|
74
117
|
agentId: agent_id,
|
|
75
118
|
name: agent.name,
|
|
119
|
+
status: agent.status,
|
|
76
120
|
result: finalResult,
|
|
121
|
+
outputFile: agent.outputFile || null,
|
|
122
|
+
liveness: snapshotLiveness(agent.liveness),
|
|
77
123
|
messages: agent.messages.length,
|
|
78
124
|
turns: agent.usage?.turns || 0,
|
|
79
125
|
message: `Agent "${agent.name}" closed`,
|
|
@@ -1,41 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* list-agents.js — List all active sub-agents.
|
|
2
|
+
* list-agents.js — List all active (and optionally terminal) sub-agents.
|
|
3
|
+
*
|
|
4
|
+
* Returns: { agents: [{ id, name, status, task, outputFile, liveness,
|
|
5
|
+
* lastEventAt, msSinceLastEvent, error, hasResult, createdAt }, …] }.
|
|
6
|
+
*
|
|
7
|
+
* The default filter drops `closed` agents to stay tidy; pass
|
|
8
|
+
* include_closed=true (or include_terminal=true) to see them all. The
|
|
9
|
+
* include_closed alias is kept for backward-compat with the old shape.
|
|
3
10
|
*/
|
|
4
11
|
|
|
5
12
|
import { defineTool } from './types.js';
|
|
6
|
-
import { getAgentRegistry } from './agent.js';
|
|
13
|
+
import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
|
|
14
|
+
import { isTerminalAgentStatus } from '../sub-agent/status.js';
|
|
15
|
+
import { snapshotLiveness } from '../sub-agent/liveness.js';
|
|
7
16
|
|
|
8
17
|
export default defineTool({
|
|
9
18
|
name: 'ListAgents',
|
|
10
19
|
description: `List all sub-agents and their current status.
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
Returns id, name, status, mission/task summary, durable outputFile path,
|
|
22
|
+
liveness counters (toolUseCount, tokenCount, msSinceLastEvent, recentTools)
|
|
23
|
+
and message count for each agent. Use to monitor parallel work in flight,
|
|
24
|
+
and Read \`outputFile\` for any single agent if you need its full timeline.
|
|
25
|
+
|
|
26
|
+
By default only non-closed agents are returned. Pass include_closed=true
|
|
27
|
+
to also list closed/failed/abandoned/completed agents.`,
|
|
14
28
|
parameters: {
|
|
15
29
|
type: 'object',
|
|
16
30
|
properties: {
|
|
17
31
|
include_closed: {
|
|
18
32
|
type: 'boolean',
|
|
19
|
-
description: 'Include closed agents in the list (default: false)',
|
|
33
|
+
description: 'Include closed/failed/abandoned/completed agents in the list (default: false)',
|
|
34
|
+
},
|
|
35
|
+
include_terminal: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
description: 'Alias for include_closed — include all terminal-status agents in the list',
|
|
20
38
|
},
|
|
21
39
|
},
|
|
22
40
|
},
|
|
23
41
|
isConcurrencySafe: () => true,
|
|
24
42
|
isReadOnly: () => true,
|
|
25
43
|
async execute(input, ctx) {
|
|
26
|
-
const
|
|
44
|
+
const includeTerminal = Boolean(input?.include_closed || input?.include_terminal);
|
|
27
45
|
const agents = getAgentRegistry();
|
|
46
|
+
const now = Date.now();
|
|
28
47
|
|
|
29
48
|
const agentList = [];
|
|
30
49
|
for (const [id, agent] of agents) {
|
|
31
|
-
if (!
|
|
50
|
+
if (!agentBelongsToCaller(agent, ctx)) continue;
|
|
51
|
+
if (!includeTerminal && isTerminalAgentStatus(agent.status)) continue;
|
|
52
|
+
const liveness = snapshotLiveness(agent.liveness, now);
|
|
32
53
|
agentList.push({
|
|
33
54
|
id,
|
|
34
55
|
name: agent.name,
|
|
35
56
|
status: agent.status,
|
|
36
|
-
task: agent.task
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
task: typeof agent.task === 'string' ? agent.task.slice(0, 200) : null,
|
|
58
|
+
outputFile: agent.outputFile || null,
|
|
59
|
+
liveness,
|
|
60
|
+
lastEventAt: liveness.lastEventAt,
|
|
61
|
+
msSinceLastEvent: liveness.msSinceLastEvent,
|
|
62
|
+
error: agent.error || null,
|
|
63
|
+
hasResult: Boolean(agent.result || agent.lastResult),
|
|
64
|
+
messages: Array.isArray(agent.messages) ? agent.messages.length : 0,
|
|
65
|
+
turns: agent.usage?.turns || 0,
|
|
39
66
|
createdAt: agent.createdAt,
|
|
40
67
|
});
|
|
41
68
|
}
|
|
@@ -43,7 +70,9 @@ and message counts. Use to monitor parallel task progress.`,
|
|
|
43
70
|
if (agentList.length === 0) {
|
|
44
71
|
return JSON.stringify({
|
|
45
72
|
agents: [],
|
|
46
|
-
message:
|
|
73
|
+
message: includeTerminal
|
|
74
|
+
? 'No sub-agents in the registry'
|
|
75
|
+
: 'No active sub-agents (pass include_closed=true to see terminal ones)',
|
|
47
76
|
});
|
|
48
77
|
}
|
|
49
78
|
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { defineTool } from './types.js';
|
|
9
|
-
import { getAgentRegistry } from './agent.js';
|
|
9
|
+
import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
|
|
10
|
+
import { isTerminalAgentStatus, isPromptableAgentStatus, STATUS, describeAgentStatus } from '../sub-agent/status.js';
|
|
10
11
|
|
|
11
12
|
export default defineTool({
|
|
12
13
|
name: 'PromptAgent',
|
|
@@ -21,7 +22,10 @@ returns you almost always want to call WaitAgent next to collect the reply.
|
|
|
21
22
|
Do NOT end your turn after PromptAgent without either (a) calling WaitAgent,
|
|
22
23
|
(b) explaining to the user what you just asked the sub-agent, or (c) calling
|
|
23
24
|
CloseAgent. The orchestration loop is
|
|
24
|
-
SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user
|
|
25
|
+
SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.
|
|
26
|
+
|
|
27
|
+
PromptAgent is rejected if the sub-agent is in a terminal state
|
|
28
|
+
(completed/failed/closed/abandoned). Use SpawnAgent to start a fresh one.`,
|
|
25
29
|
parameters: {
|
|
26
30
|
type: 'object',
|
|
27
31
|
properties: {
|
|
@@ -56,17 +60,38 @@ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to us
|
|
|
56
60
|
if (!agent) {
|
|
57
61
|
return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
|
|
58
62
|
}
|
|
63
|
+
if (!agentBelongsToCaller(agent, ctx)) {
|
|
64
|
+
return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
|
|
65
|
+
}
|
|
59
66
|
|
|
60
|
-
if (agent.status
|
|
61
|
-
|
|
67
|
+
if (isTerminalAgentStatus(agent.status)) {
|
|
68
|
+
// Build a specific error message per status. Telling the model
|
|
69
|
+
// "agent is closed" vs "agent failed: <error>" vs "agent was
|
|
70
|
+
// abandoned (idle too long)" gives it a clear next action.
|
|
71
|
+
const desc = describeAgentStatus(agent.status);
|
|
72
|
+
const detail = agent.status === STATUS.FAILED && agent.error
|
|
73
|
+
? `: ${agent.error}`
|
|
74
|
+
: '';
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
next_steps: ERROR_NEXT_STEPS,
|
|
77
|
+
error: `Agent "${agent.name}" is ${desc}${detail}. Spawn a new agent if you need more work.`,
|
|
78
|
+
agentId: agent_id,
|
|
79
|
+
status: agent.status,
|
|
80
|
+
});
|
|
62
81
|
}
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
|
|
83
|
+
if (!isPromptableAgentStatus(agent.status)) {
|
|
84
|
+
// Defensive — covers any future status that isn't terminal but
|
|
85
|
+
// also isn't ready for prompts.
|
|
86
|
+
return JSON.stringify({
|
|
87
|
+
next_steps: ERROR_NEXT_STEPS,
|
|
88
|
+
error: `Agent "${agent.name}" is in status "${agent.status}", which does not accept new prompts.`,
|
|
89
|
+
agentId: agent_id,
|
|
90
|
+
});
|
|
65
91
|
}
|
|
66
92
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// status alias kept for backward-compat with code that polls for it.
|
|
93
|
+
// Queue as a pending prompt the driver will pull. This wakes the
|
|
94
|
+
// driver out of its idle wait and starts a new turn.
|
|
70
95
|
if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
|
|
71
96
|
agent.pendingPrompts.push(message);
|
|
72
97
|
agent.messages.push({
|
|
@@ -74,8 +99,8 @@ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to us
|
|
|
74
99
|
content: message,
|
|
75
100
|
timestamp: Date.now(),
|
|
76
101
|
});
|
|
77
|
-
if (agent.status ===
|
|
78
|
-
agent.status =
|
|
102
|
+
if (agent.status === STATUS.IDLE || agent.status === STATUS.CREATED) {
|
|
103
|
+
agent.status = STATUS.RUNNING;
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
return JSON.stringify({
|