@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.
@@ -1,69 +1,103 @@
1
1
  /**
2
- * wait-agent.js — Wait for a sub-agent to complete and get its result.
2
+ * wait-agent.js — Wait for a sub-agent's next state change.
3
3
  *
4
- * Loop-stall fix (2026-06-11):
4
+ * Returns a JSON envelope shaped for the model to make an explicit
5
+ * decision. The shape is stable across statuses; the `next_steps` field
6
+ * always names the exact tool the model should call next.
5
7
  *
6
- * Symptom when the LLM called WaitAgent inside a sub-agent orchestration,
7
- * the assistant turn ended silently right after the tool returned. The user
8
- * saw a stuck "tool ran" panel and no follow-up text or further tool calls.
8
+ * Status semantics returned:
9
+ * - completed / closed / failed / abandoned (terminal): include
10
+ * `error` (when applicable), final `result`, and outputFile path
11
+ * for re-reading. We also drain the agent's queued re-entry
12
+ * notification so the engine doesn't redeliver it on the next user
13
+ * turn.
14
+ * - idle: sub-agent finished a turn and is parked, waiting for a
15
+ * PromptAgent or CloseAgent. `result` carries the last
16
+ * assistant text. Distinct from terminal — the parent can
17
+ * still send more work.
18
+ * - running with timedOut=true: we waited and the agent is still
19
+ * processing. The envelope flags `runningInBackground:
20
+ * true` (the sub-agent IS continuing — it does NOT need
21
+ * another PromptAgent to keep going) and recommends either
22
+ * another WaitAgent or CloseAgent. `result` carries the
23
+ * mid-stream preview (the driver keeps lastResult fresh
24
+ * from every text_delta).
9
25
  *
10
- * Cause the engine loop is correct (it appends the tool result and re-enters
11
- * `adapter.stream()`), but the LLM was reading the bare JSON envelope (status +
12
- * result + turns…) as a "complete answer" and emitting `end_turn` with no
13
- * text. The previous description ("Returns the agent's final result or current
14
- * status") gave it zero guidance about what to do next.
15
- *
16
- * Fix — every WaitAgent response now carries an explicit, status-dependent
17
- * `next_steps` field that names the exact tool to call next, and the tool
18
- * description spells out the full SpawnAgent → PromptAgent → WaitAgent →
19
- * CloseAgent loop. The same nudge pattern lives on the companion tools.
26
+ * Every envelope includes `outputFile` (durable per-agent JSONL log) and
27
+ * `liveness` (toolUseCount, tokenCount, msSinceLastEvent, recentTools,
28
+ * lastEventType) so the model can distinguish "stuck" from "still
29
+ * working" and can Read the log directly when it needs the full
30
+ * timeline.
20
31
  */
21
32
 
22
33
  import { defineTool } from './types.js';
23
- import { getAgentRegistry } from './agent.js';
34
+ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
35
+ import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
36
+ import { snapshotLiveness } from '../sub-agent/liveness.js';
37
+ import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
24
38
 
25
39
  /**
26
40
  * Build the status-specific next-step guidance the LLM reads after a wait.
27
- *
28
- * The wording is imperative and names actual tools so the model has a clear
29
- * action to take leaving it implicit was the bug.
41
+ * Imperative + names actual tools. Always appears as the FIRST field on
42
+ * the envelope (the registry's 1 KiB tail-truncation would otherwise eat
43
+ * tail-positioned nudges when `result` is long).
30
44
  *
31
45
  * @param {string} status
32
- * @param {boolean} [timedOut]
46
+ * @param {{ timedOut?: boolean, runningInBackground?: boolean, budgetExceeded?: boolean }} [opts]
33
47
  */
34
- function nextStepsFor(status, timedOut = false) {
35
- if (timedOut) {
48
+ function nextStepsFor(status, opts = {}) {
49
+ if (opts.budgetExceeded) {
36
50
  return (
37
- 'Sub-agent is still running. Either (a) call WaitAgent again with a ' +
38
- 'larger timeout_ms to keep waiting, (b) call CloseAgent if you want to ' +
39
- 'cut it short and use partial output, or (c) explain to the user that ' +
40
- 'the agent is still working and ask whether to keep waiting. Do NOT ' +
41
- 'end your turn silently.'
51
+ 'Sub-agent stopped because an explicit budget limit was reached. ' +
52
+ 'Use `partial_output`, `budget_reason`, and `budget_usage` to decide ' +
53
+ 'whether to relay the partial result, spawn a fresh agent with a ' +
54
+ 'larger budget, or report the cutoff to the user. Do NOT present this ' +
55
+ 'as an ordinary successful completion.'
56
+ );
57
+ }
58
+ if (opts.timedOut) {
59
+ 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.'
42
67
  );
43
68
  }
44
69
  switch (status) {
45
- case 'idle':
70
+ case STATUS.IDLE:
46
71
  return (
47
- 'Sub-agent finished one turn and is idle. The `result` above is its ' +
48
- 'reply — relay it to the user in your own words, or send a follow-up ' +
49
- 'via PromptAgent, or finalize via CloseAgent. Do NOT end your turn ' +
50
- 'silently without telling the user what the sub-agent said.'
72
+ 'Sub-agent finished one turn and is idle (queue empty). The ' +
73
+ '`result` above is its reply — relay it to the user in your own ' +
74
+ 'words, or send a follow-up via PromptAgent, or finalize via ' +
75
+ 'CloseAgent. Do NOT end your turn silently without telling the ' +
76
+ 'user what the sub-agent said.'
51
77
  );
52
- case 'completed':
78
+ case STATUS.COMPLETED:
53
79
  return (
54
- 'Sub-agent finished successfully (terminal). Summarize the `result` ' +
55
- 'for the user in your own reply. Do NOT end your turn with no text.'
80
+ 'Sub-agent finished successfully (terminal). Summarize the ' +
81
+ '`result` for the user in your own reply. Do NOT end your turn ' +
82
+ 'with no text.'
56
83
  );
57
- case 'closed':
84
+ case STATUS.CLOSED:
58
85
  return (
59
- 'Sub-agent was closed (terminal). Report the final `result` to the ' +
60
- 'user in your reply. Do NOT end your turn silently.'
86
+ 'Sub-agent was closed (terminal). Report the final `result` to ' +
87
+ 'the user in your reply. Do NOT end your turn silently.'
61
88
  );
62
- case 'failed':
89
+ case STATUS.FAILED:
63
90
  return (
64
- 'Sub-agent failed — see `error`. Decide whether to retry with a fresh ' +
65
- 'SpawnAgent, adjust the mission, or report the failure to the user. ' +
66
- 'Do NOT end your turn silently.'
91
+ 'Sub-agent failed — see `error`. Decide whether to retry with a ' +
92
+ 'fresh SpawnAgent, adjust the mission, or report the failure to ' +
93
+ 'the user. Do NOT end your turn silently.'
94
+ );
95
+ case STATUS.ABANDONED:
96
+ return (
97
+ 'Sub-agent was abandoned by the idle watchdog — it sat idle too ' +
98
+ 'long without a follow-up. The last `result` is its final reply. ' +
99
+ 'Either relay it to the user or SpawnAgent fresh with a new ' +
100
+ 'mission. Do NOT end your turn silently.'
67
101
  );
68
102
  default:
69
103
  return (
@@ -74,40 +108,84 @@ function nextStepsFor(status, timedOut = false) {
74
108
  }
75
109
  }
76
110
 
77
- /**
78
- * Nudge for the `{error: ...}` error envelopes. The error path used to ship a
79
- * naked `{error}` blob — same shape that caused the silent-end_turn bug on the
80
- * happy path. Tell the LLM what to do about an error (correct the call, or
81
- * report the failure to the user) so it does not end its turn silently after a
82
- * fat-finger like a wrong agent_id.
83
- */
84
111
  function errorNextSteps() {
85
112
  return (
86
- 'That call failed — see `error`. Either correct the arguments and retry, ' +
87
- 'or tell the user what went wrong. Do NOT end your turn silently after an ' +
88
- 'error envelope; the user has not seen the error, only you have.'
113
+ 'That call failed — see `error`. Either correct the arguments and ' +
114
+ 'retry, or tell the user what went wrong. Do NOT end your turn ' +
115
+ 'silently after an error envelope; the user has not seen the error, ' +
116
+ 'only you have.'
89
117
  );
90
118
  }
91
119
 
120
+ /**
121
+ * Build the envelope for a single status snapshot. `result` is taken
122
+ * from `agent.result` (final) if present, else from `agent.lastResult`
123
+ * (mid-stream preview).
124
+ */
125
+ function buildEnvelope(agent, { timedOut = false } = {}) {
126
+ const status = agent.status;
127
+ const budgetResult = agent.result && typeof agent.result === 'object'
128
+ && agent.result.status === 'budget_exceeded'
129
+ ? agent.result
130
+ : null;
131
+ const resultText = budgetResult
132
+ ? (budgetResult.partial_output || '')
133
+ : ((typeof agent.result === 'string' && agent.result)
134
+ ? agent.result
135
+ : (agent.lastResult || ''));
136
+ const env = {
137
+ next_steps: nextStepsFor(status, { timedOut, budgetExceeded: !!budgetResult }),
138
+ agentId: agent.id,
139
+ name: agent.name,
140
+ status,
141
+ error: agent.error || null,
142
+ outputFile: agent.outputFile || null,
143
+ liveness: snapshotLiveness(agent.liveness),
144
+ messages: Array.isArray(agent.messages) ? agent.messages.length : 0,
145
+ turns: agent.usage?.turns || 0,
146
+ };
147
+ if (timedOut) {
148
+ env.timedOut = true;
149
+ env.runningInBackground = true;
150
+ env.message = `Agent "${agent.name}" is still running in the background.`;
151
+ }
152
+ if (budgetResult) {
153
+ env.budgetExceeded = true;
154
+ env.budget_status = budgetResult.status;
155
+ env.budget_reason = budgetResult.reason || null;
156
+ env.partial_output = budgetResult.partial_output || '';
157
+ env.budget_usage = budgetResult.usage || null;
158
+ }
159
+ env.result = resultText;
160
+ return env;
161
+ }
162
+
92
163
  export default defineTool({
93
164
  name: 'WaitAgent',
94
- description: `Wait for a sub-agent to complete its current turn and retrieve its reply.
165
+ description: `Wait for a sub-agent's next state change (turn end, terminal, or wait-timeout) and retrieve a status envelope.
95
166
 
96
- Returns a JSON envelope with the sub-agent's status, latest \`result\` text, and
97
- an explicit \`next_steps\` field telling you what to do next. Read \`next_steps\`
98
- every time the wait is part of an orchestration loop, not a terminal answer.
167
+ Returns JSON with explicit \`status\`, latest \`result\` text, \`liveness\`
168
+ counters (toolUseCount, tokenCount, msSinceLastEvent, recentTools), the
169
+ durable \`outputFile\` path you can Read at any time, and a status-specific
170
+ \`next_steps\` directive telling you what to call next.
99
171
 
100
- CRITICAL — after WaitAgent returns you MUST take one of these actions:
101
- status='idle' / 'completed' / 'closed': RELAY the \`result\` to the user
102
- in your own words (or send follow-up work via PromptAgent, or finalize
103
- via CloseAgent).
104
- status='failed': report the failure to the user OR retry with a fresh
105
- SpawnAgent.
106
- • timedOut=true: call WaitAgent again with a larger timeout, OR CloseAgent
107
- to cut it short, OR tell the user the agent is still working.
172
+ Status semantics:
173
+ completed / closed / failed / abandoned → terminal. The sub-agent will
174
+ do nothing more. Relay the result to the user (or retry / report the
175
+ failure).
176
+ idle → the sub-agent finished a turn and is parked with an empty
177
+ queue. You can PromptAgent for follow-up or CloseAgent to finalize.
178
+ • timedOut=true (with status='running' and runningInBackground=true)
179
+ the wait elapsed but the sub-agent is STILL working. It does NOT need
180
+ another PromptAgent. Either WaitAgent again with a larger timeout,
181
+ CloseAgent to cut it short, or tell the user it's still working.
108
182
 
109
- NEVER end your turn silently right after WaitAgent the user has not seen the
110
- sub-agent's reply yet; only you have. The orchestration loop is
183
+ CRITICAL after WaitAgent returns you MUST take one of these actions:
184
+ status terminal: relay/retry/report.
185
+ • status idle: reply to user OR PromptAgent OR CloseAgent.
186
+ • timedOut: re-wait, cut short, or report progress.
187
+ NEVER end your turn silently right after WaitAgent — the user has not seen
188
+ the sub-agent's reply yet; only you have. The orchestration loop is
111
189
  SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.
112
190
 
113
191
  The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
@@ -132,11 +210,6 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
132
210
  isReadOnly: () => true,
133
211
  async execute(input, ctx) {
134
212
  const { agent_id, timeout_ms = 30000 } = input;
135
- // NB: `next_steps` is intentionally the FIRST field in every envelope
136
- // below. `agent/yeaft/tools/registry.js` caps each tool result at
137
- // TOOL_RESULT_MAX_BYTES (1 KiB) by truncating the tail — if `next_steps`
138
- // were last, a long `result` would push the directive off the end and the
139
- // LLM would never see the very nudge this PR delivers.
140
213
  if (!agent_id) {
141
214
  return JSON.stringify({ next_steps: errorNextSteps(), error: 'agent_id is required' });
142
215
  }
@@ -150,56 +223,41 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
150
223
  if (!agent) {
151
224
  return JSON.stringify({ next_steps: errorNextSteps(), error: `Agent not found: ${agent_id}` });
152
225
  }
226
+ if (!agentBelongsToCaller(agent, ctx)) {
227
+ return JSON.stringify({ next_steps: errorNextSteps(), error: `Agent not found: ${agent_id}` });
228
+ }
153
229
 
154
- // Terminal states return immediately.
155
- if (agent.status === 'completed' || agent.status === 'closed' || agent.status === 'failed') {
156
- return JSON.stringify({
157
- next_steps: nextStepsFor(agent.status),
158
- agentId: agent_id,
159
- name: agent.name,
160
- status: agent.status,
161
- result: agent.result || agent.lastResult || '',
162
- error: agent.error || null,
163
- messages: agent.messages.length,
164
- turns: agent.usage?.turns || 0,
165
- });
230
+ // Terminal already return immediately and drain the notification
231
+ // so the engine doesn't redeliver it on the next user turn.
232
+ if (isTerminalAgentStatus(agent.status)) {
233
+ consumeNotificationForAgent(agent.id);
234
+ return JSON.stringify(buildEnvelope(agent));
235
+ }
236
+ if (agent.status === STATUS.IDLE) {
237
+ return JSON.stringify(buildEnvelope(agent));
238
+ }
239
+ if (ctx?.signal?.aborted) {
240
+ return JSON.stringify({ next_steps: errorNextSteps(), error: 'Wait cancelled', agentId: agent_id });
166
241
  }
167
242
 
168
- // 'idle' means the sub-agent finished its current turn and is waiting
169
- // for the next SendMessage. That IS a useful return point for the
170
- // parent — surface lastResult and let parent decide what's next.
243
+ // Block until next interesting state change, capped at timeout_ms.
171
244
  const deadline = Date.now() + timeout_ms;
172
245
  while (Date.now() < deadline) {
173
- if (agent.status === 'idle' || agent.status === 'completed' || agent.status === 'closed' || agent.status === 'failed') {
174
- return JSON.stringify({
175
- next_steps: nextStepsFor(agent.status),
176
- agentId: agent_id,
177
- name: agent.name,
178
- status: agent.status,
179
- result: agent.result || agent.lastResult || '',
180
- error: agent.error || null,
181
- messages: agent.messages.length,
182
- turns: agent.usage?.turns || 0,
183
- });
246
+ if (isTerminalAgentStatus(agent.status)) {
247
+ consumeNotificationForAgent(agent.id);
248
+ return JSON.stringify(buildEnvelope(agent));
249
+ }
250
+ if (agent.status === STATUS.IDLE) {
251
+ return JSON.stringify(buildEnvelope(agent));
184
252
  }
185
-
186
253
  if (ctx?.signal?.aborted) {
187
254
  return JSON.stringify({ next_steps: errorNextSteps(), error: 'Wait cancelled', agentId: agent_id });
188
255
  }
189
-
190
256
  await new Promise(r => setTimeout(r, 200));
191
257
  }
192
258
 
193
- return JSON.stringify({
194
- next_steps: nextStepsFor(agent.status, true),
195
- agentId: agent_id,
196
- name: agent.name,
197
- status: agent.status,
198
- timedOut: true,
199
- message: `Agent "${agent.name}" is still running after ${timeout_ms}ms`,
200
- result: agent.lastResult || '',
201
- messages: agent.messages.length,
202
- turns: agent.usage?.turns || 0,
203
- });
259
+ // Wait elapsed; the sub-agent is still running. Surface mid-stream
260
+ // preview + liveness so the parent has actionable signal.
261
+ return JSON.stringify(buildEnvelope(agent, { timedOut: true }));
204
262
  },
205
263
  });