@yeaft/webchat-agent 0.1.973 → 0.1.974

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.973",
3
+ "version": "0.1.974",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -99,3 +99,37 @@ export function snapshotLiveness(liveness, now = Date.now()) {
99
99
  recentTools: liveness.recentTools.slice(),
100
100
  };
101
101
  }
102
+
103
+ export const DEFAULT_STALL_THRESHOLD_MS = 120000;
104
+
105
+ /**
106
+ * Add a stable "is this likely stuck?" diagnostic to a liveness snapshot.
107
+ * If no event has ever arrived, fall back to createdAt / usage.startedAt so
108
+ * a silent running child can still become stale.
109
+ *
110
+ * @param {object} agent
111
+ * @param {{ now?: number, thresholdMs?: number }} [opts]
112
+ */
113
+ export function diagnoseAgentLiveness(agent, opts = {}) {
114
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
115
+ const thresholdMs = typeof opts.thresholdMs === 'number' && opts.thresholdMs > 0
116
+ ? opts.thresholdMs
117
+ : DEFAULT_STALL_THRESHOLD_MS;
118
+ const liveness = snapshotLiveness(agent?.liveness, now);
119
+ const fallbackAt = agent?.createdAt || agent?.usage?.startedAt || null;
120
+ const activityAt = liveness.lastEventAt || fallbackAt;
121
+ const msSinceActivity = activityAt ? Math.max(0, now - activityAt) : null;
122
+ const stale = agent?.status === 'running'
123
+ && msSinceActivity !== null
124
+ && msSinceActivity >= thresholdMs;
125
+ return {
126
+ ...liveness,
127
+ msSinceLastEvent: liveness.msSinceLastEvent ?? msSinceActivity,
128
+ stale,
129
+ stalled: stale,
130
+ stallThresholdMs: thresholdMs,
131
+ diagnostic: stale
132
+ ? `No sub-agent activity for ${msSinceActivity}ms; treat it as stalled instead of waiting in a loop.`
133
+ : null,
134
+ };
135
+ }
@@ -4,11 +4,12 @@
4
4
  * Problem this solves:
5
5
  * The original sub-agent protocol was purely pull-based — the parent had
6
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.
7
+ * the parent forgot, the child's progress or terminal state was never
8
+ * surfaced and the orchestration "hung" from the user's perspective.
9
+ * Modeled on claude-code's `<task-notification>` XML re-entry pattern:
10
+ * when a child finishes a turn or reaches a terminal state we *push* a
11
+ * notification onto a queue that the parent will see the next time it
12
+ * talks to its engine.
12
13
  *
13
14
  * This module is the queue. Two entry points consume it:
14
15
  *
@@ -16,11 +17,11 @@
16
17
  * before returning).
17
18
  * 2. Engine.query() — when started with a user prompt, it asks
18
19
  * `consumePendingNotifications({ sessionId, parentVpId })` for any queued
19
- * terminal events from sub-agents that the parent hasn't yet
20
+ * idle/terminal events from sub-agents that the parent hasn't yet
20
21
  * acknowledged, and prepends a short XML block to the user
21
22
  * 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".
23
+ * explicitly tells it "your sub-agent X produced progress while you
24
+ * were away; here's the result and what to do next".
24
25
  *
25
26
  * The queue is in-memory only. We do NOT persist across process
26
27
  * restarts because (a) sub-agents themselves don't survive restart,
@@ -75,9 +76,9 @@ function bucketKey(scope, sessionId) {
75
76
  }
76
77
 
77
78
  /**
78
- * Enqueue a terminal notification for an agent. Idempotent per agent —
79
- * a second call with the same agentId is a no-op (we only emit one
80
- * terminal notice per child).
79
+ * Enqueue a progress/terminal notification for an agent. Idempotent per
80
+ * agent while queued — a second call with the same agentId is a no-op
81
+ * until the previous notice is replaced or consumed.
81
82
  *
82
83
  * @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, budgetExceeded?: boolean, budgetReason?: string|null, budgetUsage?: object|null }} input
83
84
  * @returns {SubAgentNotification|null} the queued record (null if a dup)
@@ -210,10 +211,10 @@ export function formatNotificationsForPrompt(notifs) {
210
211
  const parts = [];
211
212
  parts.push('<sub-agent-notifications>');
212
213
  parts.push(
213
- 'The following sub-agent(s) reached a terminal state while you were ' +
214
- 'away. The user has NOT seen any of this — only you have. You MUST ' +
215
- 'either (a) relay the result(s) to the user in your reply, or (b) act ' +
216
- 'on the result(s) before replying. Do NOT ignore these.',
214
+ 'The following sub-agent(s) produced progress or reached a terminal ' +
215
+ 'state while you were away. The user has NOT seen any of this — only ' +
216
+ 'you have. You MUST either (a) relay the result(s) to the user in your ' +
217
+ 'reply, or (b) act on the result(s) before replying. Do NOT ignore these.',
217
218
  );
218
219
  for (const n of notifs) {
219
220
  parts.push('');
@@ -41,7 +41,7 @@ import { buildSpawnedPreamble } from './spawned-prompt.js';
41
41
  import { STATUS, isTerminalAgentStatus } from './status.js';
42
42
  import { createOutputLog } from './output-log.js';
43
43
  import { makeLiveness, bumpLivenessFromEvent } from './liveness.js';
44
- import { enqueueTerminalNotification } from './notifications.js';
44
+ import { consumeNotificationForAgent, enqueueTerminalNotification } from './notifications.js';
45
45
  // NOTE: tickAgent lives in `../tools/agent.js`, which itself imports this
46
46
  // module (startSubAgent). To avoid the ES-module circular-import gotcha
47
47
  // where one side sees an undefined export at module-init time, we import
@@ -223,8 +223,43 @@ export function startSubAgent(agent, deps = {}) {
223
223
  * CloseAgent (status=='closed') OR the idle watchdog firing
224
224
  * (status=='abandoned').
225
225
  */
226
+ function buildWallTimeBudgetResult(agent, reason) {
227
+ return {
228
+ status: 'budget_exceeded',
229
+ partial_output: agent.partial_output || agent.lastResult || agent.result || '',
230
+ reason,
231
+ usage: { ...(agent.usage || {}) },
232
+ };
233
+ }
234
+
235
+ function armWallTimeWatchdog(agent, deps) {
236
+ const wallTimeMs = agent?.budget?.wall_time_ms;
237
+ if (typeof wallTimeMs !== 'number' || !Number.isFinite(wallTimeMs) || wallTimeMs <= 0) {
238
+ return null;
239
+ }
240
+ const startedAt = agent.usage?.startedAt || Date.now();
241
+ const remainingMs = Math.max(0, startedAt + wallTimeMs - Date.now());
242
+ const timer = setTimeout(() => {
243
+ if (isTerminalAgentStatus(agent.status)) return;
244
+ const reason = `wall_time_ms (${wallTimeMs}) exceeded`;
245
+ agent.result = buildWallTimeBudgetResult(agent, reason);
246
+ agent.partial_output = agent.result.partial_output || '';
247
+ if (agent.abortController && !agent.abortController.signal.aborted) {
248
+ try { agent.abortController.abort(reason); } catch { /* ignore */ }
249
+ }
250
+ transitionTerminal(agent, STATUS.COMPLETED, {
251
+ error: reason,
252
+ diagnostic: 'wall_time_watchdog',
253
+ deps,
254
+ });
255
+ }, remainingMs);
256
+ timer.unref?.();
257
+ return timer;
258
+ }
259
+
226
260
  async function driveSubAgent(agent, subEngine, vpPersona, deps) {
227
261
  const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
262
+ const wallTimeWatchdog = armWallTimeWatchdog(agent, deps);
228
263
  const idleAbandonMs = typeof deps.idleAbandonMs === 'number' && deps.idleAbandonMs > 0
229
264
  ? deps.idleAbandonMs : IDLE_ABANDON_MS;
230
265
 
@@ -262,6 +297,21 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
262
297
  agent.status = STATUS.IDLE;
263
298
  agent.idleSince = Date.now();
264
299
  emit({ type: 'sub_agent_status', status: STATUS.IDLE });
300
+ if (agent.result || agent.lastResult) {
301
+ try {
302
+ enqueueTerminalNotification({
303
+ agentId: agent.id,
304
+ agentName: agent.name,
305
+ status: STATUS.IDLE,
306
+ result: typeof agent.result === 'string' ? agent.result : (agent.lastResult || ''),
307
+ error: null,
308
+ outputFile: agent.outputFile || null,
309
+ turns: agent.usage?.turns || 0,
310
+ parentVpId: agent.parentVpId || deps.parentVpId || null,
311
+ parentSessionId: agent.parentSessionId || deps.parentSessionId || null,
312
+ });
313
+ } catch { /* best-effort notification */ }
314
+ }
265
315
 
266
316
  const reason = await waitUntilResumed(agent, idleAbandonMs);
267
317
  if (reason === 'abandoned') {
@@ -398,6 +448,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
398
448
  emit({ type: 'sub_agent_turn_end', content: assistantText });
399
449
  }
400
450
  } finally {
451
+ if (wallTimeWatchdog) clearTimeout(wallTimeWatchdog);
401
452
  // Always clean up driver-owned resources. We intentionally do NOT
402
453
  // unset agent.result / agent.lastResult / agent.liveness / agent.
403
454
  // outputFile — those are observable by the parent after termination.
@@ -451,7 +502,10 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
451
502
  }
452
503
 
453
504
  // Push the re-entry notification so the parent learns about this
454
- // even if it forgot to call WaitAgent.
505
+ // even if it forgot to call WaitAgent. A prior idle notification may
506
+ // still be indexed by agentId after the parent consumed the queue; remove
507
+ // it so terminal state can replace that non-terminal progress notice.
508
+ try { consumeNotificationForAgent(agent.id); } catch { /* ignore */ }
455
509
  try {
456
510
  const budgetResult = agent.result && typeof agent.result === 'object'
457
511
  && agent.result.status === 'budget_exceeded'
@@ -25,7 +25,7 @@ import { randomUUID } from 'crypto';
25
25
  import { getPersona, listPersonaIds } from '../personas.js';
26
26
  import { startSubAgent } from '../sub-agent/runner.js';
27
27
  import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
28
- import { makeLiveness } from '../sub-agent/liveness.js';
28
+ import { diagnoseAgentLiveness, makeLiveness } from '../sub-agent/liveness.js';
29
29
 
30
30
  /** In-memory sub-agent registry. */
31
31
  const agents = new Map();
@@ -232,18 +232,16 @@ Guidelines:
232
232
  - Use expected_output when the return shape matters
233
233
  - Add a budget only when you need an explicit safety cutoff
234
234
 
235
- Orchestration loop you MUST follow:
236
- 1. SpawnAgent fire-and-forget; the sub-agent is now running.
237
- 2. WaitAgent blocks until the sub-agent goes idle / completes /
238
- fails / closes (or times out). Returns the reply.
239
- 3. PromptAgent — optional: queue a follow-up; then WaitAgent again.
240
- 4. CloseAgent — finalize when done.
241
- 5. Reply to user — relay what the sub-agent found in your own words.
235
+ Async orchestration:
236
+ 1. SpawnAgent starts the sub-agent as a background task and returns immediately.
237
+ 2. Continue keep working in the parent VP; do not block just to poll.
238
+ 3. ListAgents — non-blocking status check when you need progress/liveness.
239
+ 4. PromptAgent — optional follow-up if the sub-agent is idle and needs guidance.
240
+ 5. CloseAgent stop or finalize a sub-agent when it is no longer needed.
242
241
 
243
- CRITICAL SpawnAgent only KICKS OFF the sub-agent. It has NOT produced any
244
- result yet. You MUST call WaitAgent next to collect the reply. Do NOT end your
245
- turn right after SpawnAgent without calling WaitAgent or telling the user what
246
- you just kicked off.`,
242
+ Completion/failure is delivered through sub-agent notifications on later parent
243
+ turns. WaitAgent remains available only as a short compatibility poll; do not
244
+ use it as the default workflow or call it repeatedly in a loop.`,
247
245
  parameters: {
248
246
  type: 'object',
249
247
  properties: {
@@ -381,11 +379,13 @@ you just kicked off.`,
381
379
  // tests). Leave the record in 'created' so existing tests still work.
382
380
  }
383
381
 
382
+ const liveness = diagnoseAgentLiveness(agent);
384
383
  return JSON.stringify({
385
384
  next_steps:
386
- 'Sub-agent is now running no reply yet. Call WaitAgent next to ' +
387
- 'collect its first turn. Do NOT end your turn here without either ' +
388
- 'waiting for the reply or telling the user what you just spawned.',
385
+ 'Sub-agent is running in the background. Continue the parent task; ' +
386
+ 'use ListAgents for a non-blocking status check, PromptAgent only ' +
387
+ 'when the sub-agent is idle and needs more input, and rely on ' +
388
+ 'completion notifications on later turns. Do not call WaitAgent in a loop.',
389
389
  success: true,
390
390
  agentId,
391
391
  name,
@@ -393,7 +393,10 @@ you just kicked off.`,
393
393
  budget: spec.budget || null,
394
394
  status: agent.status,
395
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.`,
396
+ liveness,
397
+ stale: liveness.stale,
398
+ stalled: liveness.stalled,
399
+ message: `Sub-agent "${name}" spawned (${agentId}) as an async background task. Use ListAgents to monitor it without blocking. Read \`outputFile\` for the durable event log at any time.`,
397
400
  });
398
401
  },
399
402
  });
@@ -12,16 +12,17 @@
12
12
  import { defineTool } from './types.js';
13
13
  import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
14
14
  import { isTerminalAgentStatus } from '../sub-agent/status.js';
15
- import { snapshotLiveness } from '../sub-agent/liveness.js';
15
+ import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
16
16
 
17
17
  export default defineTool({
18
18
  name: 'ListAgents',
19
19
  description: `List all sub-agents and their current status.
20
20
 
21
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.
22
+ liveness counters (toolUseCount, tokenCount, msSinceLastEvent, recentTools),
23
+ stale/stalled diagnostics, result tail, and message count for each agent. Use
24
+ this as the primary non-blocking monitor for async sub-agent work, and Read
25
+ \`outputFile\` for any single agent if you need its full timeline.
25
26
 
26
27
  By default only non-closed agents are returned. Pass include_closed=true
27
28
  to also list closed/failed/abandoned/completed agents.`,
@@ -49,7 +50,10 @@ to also list closed/failed/abandoned/completed agents.`,
49
50
  for (const [id, agent] of agents) {
50
51
  if (!agentBelongsToCaller(agent, ctx)) continue;
51
52
  if (!includeTerminal && isTerminalAgentStatus(agent.status)) continue;
52
- const liveness = snapshotLiveness(agent.liveness, now);
53
+ const liveness = diagnoseAgentLiveness(agent, { now });
54
+ const resultText = (typeof agent.result === 'string' && agent.result)
55
+ ? agent.result
56
+ : (agent.lastResult || '');
53
57
  agentList.push({
54
58
  id,
55
59
  name: agent.name,
@@ -59,8 +63,13 @@ to also list closed/failed/abandoned/completed agents.`,
59
63
  liveness,
60
64
  lastEventAt: liveness.lastEventAt,
61
65
  msSinceLastEvent: liveness.msSinceLastEvent,
66
+ lastEventType: liveness.lastEventType,
67
+ stale: liveness.stale,
68
+ stalled: liveness.stalled,
69
+ diagnostic: liveness.diagnostic,
62
70
  error: agent.error || null,
63
71
  hasResult: Boolean(agent.result || agent.lastResult),
72
+ resultTail: resultText ? resultText.slice(-1000) : '',
64
73
  messages: Array.isArray(agent.messages) ? agent.messages.length : 0,
65
74
  turns: agent.usage?.turns || 0,
66
75
  createdAt: agent.createdAt,
@@ -33,7 +33,7 @@
33
33
  import { defineTool } from './types.js';
34
34
  import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
35
35
  import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
36
- import { snapshotLiveness } from '../sub-agent/liveness.js';
36
+ import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
37
37
  import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
38
38
 
39
39
  /**
@@ -43,7 +43,7 @@ import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
43
43
  * tail-positioned nudges when `result` is long).
44
44
  *
45
45
  * @param {string} status
46
- * @param {{ timedOut?: boolean, runningInBackground?: boolean, budgetExceeded?: boolean }} [opts]
46
+ * @param {{ timedOut?: boolean, runningInBackground?: boolean, budgetExceeded?: boolean, stale?: boolean }} [opts]
47
47
  */
48
48
  function nextStepsFor(status, opts = {}) {
49
49
  if (opts.budgetExceeded) {
@@ -55,15 +55,20 @@ function nextStepsFor(status, opts = {}) {
55
55
  'as an ordinary successful completion.'
56
56
  );
57
57
  }
58
+ if (opts.timedOut && opts.stale) {
59
+ return (
60
+ 'Sub-agent still has a running record but appears stalled. Do NOT keep ' +
61
+ 'calling WaitAgent in a loop. Use ListAgents/outputFile to inspect it, ' +
62
+ 'CloseAgent if you want to stop it, or report the stalled background ' +
63
+ 'task and start a fresh agent if needed.'
64
+ );
65
+ }
58
66
  if (opts.timedOut) {
59
67
  return (
60
- 'Sub-agent is STILL RUNNING in the background it does NOT need ' +
61
- 'another PromptAgent to keep going. Decide: (a) call WaitAgent again ' +
62
- 'with a larger timeout_ms to keep waiting, (b) call CloseAgent if ' +
63
- 'you want to cut it short and use partial output, or (c) tell the ' +
64
- 'user the agent is still working and ask whether to keep waiting. ' +
65
- 'Read `outputFile` for the full event timeline. Do NOT end your turn ' +
66
- 'silently.'
68
+ 'Sub-agent is running in the background; it does not need another ' +
69
+ 'PromptAgent to keep going. Continue the main task or tell the user it ' +
70
+ 'is still running. Use ListAgents later for a non-blocking status check; ' +
71
+ 'only call WaitAgent again if the user explicitly wants to wait.'
67
72
  );
68
73
  }
69
74
  switch (status) {
@@ -124,6 +129,7 @@ function errorNextSteps() {
124
129
  */
125
130
  function buildEnvelope(agent, { timedOut = false } = {}) {
126
131
  const status = agent.status;
132
+ const liveness = diagnoseAgentLiveness(agent);
127
133
  const budgetResult = agent.result && typeof agent.result === 'object'
128
134
  && agent.result.status === 'budget_exceeded'
129
135
  ? agent.result
@@ -134,13 +140,18 @@ function buildEnvelope(agent, { timedOut = false } = {}) {
134
140
  ? agent.result
135
141
  : (agent.lastResult || ''));
136
142
  const env = {
137
- next_steps: nextStepsFor(status, { timedOut, budgetExceeded: !!budgetResult }),
143
+ next_steps: nextStepsFor(status, { timedOut, budgetExceeded: !!budgetResult, stale: liveness.stale }),
138
144
  agentId: agent.id,
139
145
  name: agent.name,
140
146
  status,
141
147
  error: agent.error || null,
142
148
  outputFile: agent.outputFile || null,
143
- liveness: snapshotLiveness(agent.liveness),
149
+ liveness,
150
+ stale: liveness.stale,
151
+ stalled: liveness.stalled,
152
+ msSinceLastEvent: liveness.msSinceLastEvent,
153
+ lastEventType: liveness.lastEventType,
154
+ diagnostic: liveness.diagnostic,
144
155
  messages: Array.isArray(agent.messages) ? agent.messages.length : 0,
145
156
  turns: agent.usage?.turns || 0,
146
157
  };
@@ -188,7 +199,7 @@ NEVER end your turn silently right after WaitAgent — the user has not seen
188
199
  the sub-agent's reply yet; only you have. The orchestration loop is
189
200
  SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.
190
201
 
191
- The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
202
+ Compatibility tool. The default wait is a short 5000ms poll. Callers may request up to 300000ms (5 minutes), but this is no longer the primary sub-agent workflow; prefer SpawnAgent + ListAgents + completion notifications for async background work.`,
192
203
  parameters: {
193
204
  type: 'object',
194
205
  properties: {
@@ -200,7 +211,7 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
200
211
  type: 'number',
201
212
  minimum: 0,
202
213
  maximum: 300000,
203
- description: 'Maximum time to wait in milliseconds (default: 30000, max: 300000 / 5 minutes)',
214
+ description: 'Maximum time to wait in milliseconds (default: 5000 short poll, max: 300000 / 5 minutes)',
204
215
  },
205
216
  },
206
217
  required: ['agent_id'],
@@ -209,7 +220,7 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
209
220
  isConcurrencySafe: () => true,
210
221
  isReadOnly: () => true,
211
222
  async execute(input, ctx) {
212
- const { agent_id, timeout_ms = 30000 } = input;
223
+ const { agent_id, timeout_ms = 5000 } = input;
213
224
  if (!agent_id) {
214
225
  return JSON.stringify({ next_steps: errorNextSteps(), error: 'agent_id is required' });
215
226
  }
@@ -234,6 +245,7 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
234
245
  return JSON.stringify(buildEnvelope(agent));
235
246
  }
236
247
  if (agent.status === STATUS.IDLE) {
248
+ consumeNotificationForAgent(agent.id);
237
249
  return JSON.stringify(buildEnvelope(agent));
238
250
  }
239
251
  if (ctx?.signal?.aborted) {
@@ -248,6 +260,7 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
248
260
  return JSON.stringify(buildEnvelope(agent));
249
261
  }
250
262
  if (agent.status === STATUS.IDLE) {
263
+ consumeNotificationForAgent(agent.id);
251
264
  return JSON.stringify(buildEnvelope(agent));
252
265
  }
253
266
  if (ctx?.signal?.aborted) {