@yeaft/webchat-agent 0.1.929 → 0.1.931

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/history.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'os';
2
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import ctx from './context.js';
5
5
  import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
@@ -146,6 +146,116 @@ export async function getHistorySessions(workDir) {
146
146
  return sessions;
147
147
  }
148
148
 
149
+ // feat-chat-load-perf: tail-read helper used by loadSessionHistory.
150
+ // Reads the last `limit` user/assistant rows from a JSONL without slurping
151
+ // the whole file. Strategy: open the file, fstat to get size, then read
152
+ // fixed-size chunks from the end backwards into a Buffer. We split on the
153
+ // `\n` *byte* (0x0A) — NOT on a decoded string — because Buffer→string
154
+ // substitutes U+FFFD for any partial multi-byte sequence at chunk
155
+ // boundaries, and that corruption is undetectable downstream (JSON.parse
156
+ // happily accepts U+FFFD as valid string content). Splitting on the
157
+ // newline byte and carrying raw bytes between iterations means every
158
+ // complete line is decoded as a whole and the agent never feeds the LLM
159
+ // mangled history. A 42 MB / 100k-message JSONL with limit=500 reads
160
+ // roughly 1–4 MB instead of the entire file.
161
+ //
162
+ // Tradeoffs:
163
+ // - Falls back to full readFileSync if anything throws (defensive — a 200ms
164
+ // slow path beats a broken history load).
165
+ // - The TAIL_CHUNK_SIZE constant (256 KB) is sized so a single chunk almost
166
+ // always contains many complete lines from Claude CLI's per-message
167
+ // write pattern.
168
+ // - The carry Buffer is capped at TAIL_MAX_CARRY_BYTES — a pathological
169
+ // JSONL line longer than that triggers the fallback path rather than
170
+ // letting the agent OOM.
171
+ const TAIL_CHUNK_SIZE = 256 * 1024; // 256 KB
172
+ const TAIL_MAX_CARRY_BYTES = 4 * 1024 * 1024; // 4 MB — safety valve, see above
173
+ const NEWLINE_BYTE = 0x0a;
174
+
175
+ // Exported for tests so the UTF-8-boundary regression can splice a
176
+ // multi-byte character exactly at the chunk seam.
177
+ export const _TAIL_CHUNK_SIZE_FOR_TESTS = TAIL_CHUNK_SIZE;
178
+
179
+ function readTailMessages(filePath, limit) {
180
+ const fd = openSync(filePath, 'r');
181
+ try {
182
+ const { size } = fstatSync(fd);
183
+ if (size === 0) return [];
184
+
185
+ const collected = []; // newest-first while we build it; reverse before return
186
+ let carry = Buffer.alloc(0); // raw-byte tail from the previous (deeper-into-file) chunk
187
+ let position = size;
188
+ const chunkBuf = Buffer.alloc(TAIL_CHUNK_SIZE);
189
+
190
+ while (position > 0 && collected.length < limit) {
191
+ const readSize = Math.min(TAIL_CHUNK_SIZE, position);
192
+ const offset = position - readSize;
193
+ readSync(fd, chunkBuf, 0, readSize, offset);
194
+ position = offset;
195
+ const atHead = position === 0;
196
+
197
+ // Concatenate raw bytes — never decode partials, never split UTF-8.
198
+ const buf = Buffer.concat([chunkBuf.slice(0, readSize), carry]);
199
+
200
+ // If we're not yet at the head of the file, the first segment up to
201
+ // (but not including) the first newline is a potentially-partial
202
+ // line — stash its bytes for the next iteration. If there's no
203
+ // newline at all, the whole chunk is one partial line and we carry
204
+ // it forward.
205
+ let tailStart = 0;
206
+ if (!atHead) {
207
+ const firstNl = buf.indexOf(NEWLINE_BYTE);
208
+ if (firstNl === -1) {
209
+ if (buf.length > TAIL_MAX_CARRY_BYTES) {
210
+ // Refuse to grow the carry unbounded — propagate to fallback.
211
+ throw new Error(`tail-read carry exceeded ${TAIL_MAX_CARRY_BYTES} bytes`);
212
+ }
213
+ carry = buf;
214
+ continue;
215
+ }
216
+ carry = buf.slice(0, firstNl);
217
+ tailStart = firstNl + 1;
218
+ }
219
+
220
+ // Everything from tailStart to end is complete UTF-8 lines — decode
221
+ // safely as one block.
222
+ const text = buf.slice(tailStart).toString('utf-8');
223
+ const lines = text.split('\n');
224
+
225
+ // Walk lines newest-first (end to start).
226
+ for (let i = lines.length - 1; i >= 0; i--) {
227
+ const line = lines[i];
228
+ if (!line || !line.trim()) continue;
229
+ try {
230
+ const data = JSON.parse(line);
231
+ if (data.type === 'user' || data.type === 'assistant') {
232
+ collected.push(data);
233
+ if (collected.length >= limit) break;
234
+ }
235
+ } catch {}
236
+ }
237
+ }
238
+
239
+ // If we ran out of file with leftover carry, try it as the head line.
240
+ if (collected.length < limit && carry.length > 0) {
241
+ const headLine = carry.toString('utf-8').trim();
242
+ if (headLine) {
243
+ try {
244
+ const data = JSON.parse(headLine);
245
+ if (data.type === 'user' || data.type === 'assistant') {
246
+ collected.push(data);
247
+ }
248
+ } catch {}
249
+ }
250
+ }
251
+
252
+ // collected is newest-first; flip to chronological order for callers.
253
+ return collected.reverse();
254
+ } finally {
255
+ closeSync(fd);
256
+ }
257
+ }
258
+
149
259
  // 读取 session 文件中的历史消息
150
260
  export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
151
261
  const projectsDir = getClaudeProjectsDir();
@@ -159,6 +269,17 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
159
269
  return [];
160
270
  }
161
271
 
272
+ // Fast path: tail-read only the last `limit` user/assistant rows. Avoids
273
+ // slurping ~42 MB into memory + ~100k JSON.parse calls when we only need
274
+ // the last 500 entries on every chat resume.
275
+ if (limit && limit > 0) {
276
+ try {
277
+ return readTailMessages(sessionFile, limit);
278
+ } catch (e) {
279
+ console.error(`Tail-read failed (${e.message}), falling back to full read for: ${sessionFile}`);
280
+ }
281
+ }
282
+
162
283
  const messages = [];
163
284
  try {
164
285
  const content = readFileSync(sessionFile, 'utf-8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.929",
3
+ "version": "0.1.931",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -191,7 +191,19 @@ Guidelines:
191
191
  - Give a clear, focused mission — what "done" looks like
192
192
  - Use expected_output when the return shape matters
193
193
  - Add a budget only when you need an explicit safety cutoff
194
- - Use PromptAgent to communicate, WaitAgent to collect results, CloseAgent to finalize`,
194
+
195
+ Orchestration loop you MUST follow:
196
+ 1. SpawnAgent — fire-and-forget; the sub-agent is now running.
197
+ 2. WaitAgent — blocks until the sub-agent goes idle / completes /
198
+ fails / closes (or times out). Returns the reply.
199
+ 3. PromptAgent — optional: queue a follow-up; then WaitAgent again.
200
+ 4. CloseAgent — finalize when done.
201
+ 5. Reply to user — relay what the sub-agent found in your own words.
202
+
203
+ CRITICAL — SpawnAgent only KICKS OFF the sub-agent. It has NOT produced any
204
+ result yet. You MUST call WaitAgent next to collect the reply. Do NOT end your
205
+ turn right after SpawnAgent without calling WaitAgent or telling the user what
206
+ you just kicked off.`,
195
207
  parameters: {
196
208
  type: 'object',
197
209
  properties: {
@@ -235,8 +247,19 @@ Guidelines:
235
247
  isConcurrencySafe: () => false,
236
248
  isReadOnly: () => false,
237
249
  async execute(input, ctx) {
250
+ // NB: every envelope below puts `next_steps` (or `error_next_steps`) at
251
+ // the FIRST position because `agent/yeaft/tools/registry.js` caps tool
252
+ // output at 1 KiB by chopping the tail. Tail-positioned nudges get
253
+ // truncated when the rest of the envelope is large.
254
+ const ERROR_NEXT_STEPS =
255
+ 'That call failed — see `error`. Either correct the arguments and ' +
256
+ 'retry, or tell the user what went wrong. Do NOT end your turn ' +
257
+ 'silently after an error envelope.';
258
+
238
259
  const validation = validateSpec(input);
239
- if (!validation.ok) return JSON.stringify({ error: validation.error });
260
+ if (!validation.ok) {
261
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: validation.error });
262
+ }
240
263
  const spec = validation.spec;
241
264
  const { name, cwd } = input;
242
265
 
@@ -244,6 +267,7 @@ Guidelines:
244
267
  for (const [, agent] of agents) {
245
268
  if (agent.name === name && agent.status !== 'closed') {
246
269
  return JSON.stringify({
270
+ next_steps: ERROR_NEXT_STEPS,
247
271
  error: `Agent "${name}" already exists. Close it first or use a different name.`,
248
272
  agentId: agent.id,
249
273
  });
@@ -288,6 +312,7 @@ Guidelines:
288
312
  agent.error = err && err.message ? err.message : String(err);
289
313
  agent.diagnostics.push({ type: 'spawn_error', error: agent.error, at: Date.now() });
290
314
  return JSON.stringify({
315
+ next_steps: ERROR_NEXT_STEPS,
291
316
  error: `Failed to start sub-agent: ${agent.error}`,
292
317
  agentId,
293
318
  });
@@ -298,6 +323,10 @@ Guidelines:
298
323
  }
299
324
 
300
325
  return JSON.stringify({
326
+ next_steps:
327
+ 'Sub-agent is now running — no reply yet. Call WaitAgent next to ' +
328
+ 'collect its first turn. Do NOT end your turn here without either ' +
329
+ 'waiting for the reply or telling the user what you just spawned.',
301
330
  success: true,
302
331
  agentId,
303
332
  name,
@@ -10,7 +10,12 @@ export default defineTool({
10
10
  description: `Close a sub-agent and release its resources.
11
11
 
12
12
  Use when a sub-agent's task is complete or no longer needed.
13
- The agent's result (if any) is returned before closing.`,
13
+ The agent's final \`result\` (if any) is returned in the envelope before closing.
14
+
15
+ CRITICAL — closing the sub-agent is NOT the end of YOUR turn. After CloseAgent
16
+ you MUST relay the \`result\` to the user in your own reply (or summarize what
17
+ was accomplished). The user has not seen the sub-agent's reply — only you have.
18
+ Do NOT end your turn silently right after CloseAgent.`,
14
19
  parameters: {
15
20
  type: 'object',
16
21
  properties: {
@@ -28,14 +33,21 @@ The agent's result (if any) is returned before closing.`,
28
33
  isConcurrencySafe: () => false,
29
34
  isReadOnly: () => false,
30
35
  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
+ const ERROR_NEXT_STEPS =
39
+ 'That call failed — see `error`. Either correct the arguments and ' +
40
+ 'retry, or tell the user what went wrong. Do NOT end your turn ' +
41
+ 'silently after an error envelope.';
42
+
31
43
  const { agent_id, result } = input;
32
- if (!agent_id) return JSON.stringify({ error: 'agent_id is required' });
44
+ if (!agent_id) return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: 'agent_id is required' });
33
45
 
34
46
  const agents = getAgentRegistry();
35
47
  const agent = agents.get(agent_id);
36
48
 
37
49
  if (!agent) {
38
- return JSON.stringify({ error: `Agent not found: ${agent_id}` });
50
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
39
51
  }
40
52
 
41
53
  if (result) {
@@ -54,6 +66,10 @@ The agent's result (if any) is returned before closing.`,
54
66
  agent.status = 'closed';
55
67
 
56
68
  return JSON.stringify({
69
+ next_steps:
70
+ 'Sub-agent is closed. Now reply to the user — summarize what was ' +
71
+ 'accomplished and surface the `result` text. Do NOT end your turn ' +
72
+ 'without telling the user what happened.',
57
73
  success: true,
58
74
  agentId: agent_id,
59
75
  name: agent.name,
@@ -14,7 +14,14 @@ export default defineTool({
14
14
  description: `Send a follow-up prompt to a sub-agent you previously spawned.
15
15
 
16
16
  Use this to give the sub-agent more work, additional instructions, or relay
17
- information. The prompt is queued for the agent to process on its next turn.`,
17
+ information. The prompt is queued for the agent to process on its next turn.
18
+
19
+ IMPORTANT — PromptAgent only QUEUES the message; it does NOT block. After this
20
+ returns you almost always want to call WaitAgent next to collect the reply.
21
+ Do NOT end your turn after PromptAgent without either (a) calling WaitAgent,
22
+ (b) explaining to the user what you just asked the sub-agent, or (c) calling
23
+ CloseAgent. The orchestration loop is
24
+ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.`,
18
25
  parameters: {
19
26
  type: 'object',
20
27
  properties: {
@@ -32,22 +39,29 @@ information. The prompt is queued for the agent to process on its next turn.`,
32
39
  isConcurrencySafe: () => false,
33
40
  isReadOnly: () => false,
34
41
  async execute(input, ctx) {
42
+ // NB: next_steps is the FIRST envelope field — the registry's 1 KiB
43
+ // tail-truncation would eat it if it lived at the end.
44
+ const ERROR_NEXT_STEPS =
45
+ 'That call failed — see `error`. Either correct the arguments and ' +
46
+ 'retry, or tell the user what went wrong. Do NOT end your turn ' +
47
+ 'silently after an error envelope.';
48
+
35
49
  const { agent_id, message } = input;
36
- if (!agent_id) return JSON.stringify({ error: 'agent_id is required' });
37
- if (!message) return JSON.stringify({ error: 'message is required' });
50
+ if (!agent_id) return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: 'agent_id is required' });
51
+ if (!message) return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: 'message is required' });
38
52
 
39
53
  const agents = getAgentRegistry();
40
54
  const agent = agents.get(agent_id);
41
55
 
42
56
  if (!agent) {
43
- return JSON.stringify({ error: `Agent not found: ${agent_id}` });
57
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
44
58
  }
45
59
 
46
60
  if (agent.status === 'closed') {
47
- return JSON.stringify({ error: `Agent "${agent.name}" is closed` });
61
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent "${agent.name}" is closed` });
48
62
  }
49
63
  if (agent.status === 'failed') {
50
- return JSON.stringify({ error: `Agent "${agent.name}" has failed: ${agent.error || 'unknown error'}` });
64
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent "${agent.name}" has failed: ${agent.error || 'unknown error'}` });
51
65
  }
52
66
 
53
67
  // PR-M1: queue as a pending prompt the driver will pull. This wakes
@@ -65,6 +79,11 @@ information. The prompt is queued for the agent to process on its next turn.`,
65
79
  }
66
80
 
67
81
  return JSON.stringify({
82
+ next_steps:
83
+ 'Message is queued — the sub-agent has NOT replied yet. Call WaitAgent ' +
84
+ 'next to collect the reply, then relay it to the user. Do NOT end your ' +
85
+ 'turn here without either waiting for the reply or telling the user ' +
86
+ 'what you just asked.',
68
87
  success: true,
69
88
  agentId: agent_id,
70
89
  name: agent.name,
@@ -1,16 +1,114 @@
1
1
  /**
2
2
  * wait-agent.js — Wait for a sub-agent to complete and get its result.
3
+ *
4
+ * Loop-stall fix (2026-06-11):
5
+ *
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.
9
+ *
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.
3
20
  */
4
21
 
5
22
  import { defineTool } from './types.js';
6
23
  import { getAgentRegistry } from './agent.js';
7
24
 
25
+ /**
26
+ * 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.
30
+ *
31
+ * @param {string} status
32
+ * @param {boolean} [timedOut]
33
+ */
34
+ function nextStepsFor(status, timedOut = false) {
35
+ if (timedOut) {
36
+ 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.'
42
+ );
43
+ }
44
+ switch (status) {
45
+ case 'idle':
46
+ 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.'
51
+ );
52
+ case 'completed':
53
+ 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.'
56
+ );
57
+ case 'closed':
58
+ return (
59
+ 'Sub-agent was closed (terminal). Report the final `result` to the ' +
60
+ 'user in your reply. Do NOT end your turn silently.'
61
+ );
62
+ case 'failed':
63
+ 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.'
67
+ );
68
+ default:
69
+ return (
70
+ 'Decide what to do next: PromptAgent to send follow-up work, ' +
71
+ 'CloseAgent to finalize, or WaitAgent again. Always tell the user ' +
72
+ 'what just happened — do NOT end your turn silently.'
73
+ );
74
+ }
75
+ }
76
+
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
+ function errorNextSteps() {
85
+ 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.'
89
+ );
90
+ }
91
+
8
92
  export default defineTool({
9
93
  name: 'WaitAgent',
10
- description: `Wait for a sub-agent to complete its task and retrieve the result.
94
+ description: `Wait for a sub-agent to complete its current turn and retrieve its reply.
95
+
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.
11
99
 
12
- Returns the agent's final result or current status if still running.
13
- Use after sending a task to an agent via PromptAgent.
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.
108
+
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
111
+ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.
14
112
 
15
113
  The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
16
114
  parameters: {
@@ -34,21 +132,29 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
34
132
  isReadOnly: () => true,
35
133
  async execute(input, ctx) {
36
134
  const { agent_id, timeout_ms = 30000 } = input;
37
- if (!agent_id) return JSON.stringify({ error: 'agent_id is required' });
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
+ if (!agent_id) {
141
+ return JSON.stringify({ next_steps: errorNextSteps(), error: 'agent_id is required' });
142
+ }
38
143
  if (typeof timeout_ms !== 'number' || !Number.isFinite(timeout_ms) || timeout_ms < 0 || timeout_ms > 300000) {
39
- return JSON.stringify({ error: 'timeout_ms must be a number between 0 and 300000' });
144
+ return JSON.stringify({ next_steps: errorNextSteps(), error: 'timeout_ms must be a number between 0 and 300000' });
40
145
  }
41
146
 
42
147
  const agents = getAgentRegistry();
43
148
  const agent = agents.get(agent_id);
44
149
 
45
150
  if (!agent) {
46
- return JSON.stringify({ error: `Agent not found: ${agent_id}` });
151
+ return JSON.stringify({ next_steps: errorNextSteps(), error: `Agent not found: ${agent_id}` });
47
152
  }
48
153
 
49
- // PR-M1: terminal states return immediately.
154
+ // Terminal states return immediately.
50
155
  if (agent.status === 'completed' || agent.status === 'closed' || agent.status === 'failed') {
51
156
  return JSON.stringify({
157
+ next_steps: nextStepsFor(agent.status),
52
158
  agentId: agent_id,
53
159
  name: agent.name,
54
160
  status: agent.status,
@@ -59,13 +165,14 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
59
165
  });
60
166
  }
61
167
 
62
- // PR-M1: 'idle' means the sub-agent finished its current turn and is
63
- // waiting for the next SendMessage. That IS a useful return point for
64
- // the parent — surface lastResult and let parent decide what's next.
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.
65
171
  const deadline = Date.now() + timeout_ms;
66
172
  while (Date.now() < deadline) {
67
173
  if (agent.status === 'idle' || agent.status === 'completed' || agent.status === 'closed' || agent.status === 'failed') {
68
174
  return JSON.stringify({
175
+ next_steps: nextStepsFor(agent.status),
69
176
  agentId: agent_id,
70
177
  name: agent.name,
71
178
  status: agent.status,
@@ -77,13 +184,14 @@ The default wait is 30000ms. Callers may request up to 300000ms (5 minutes).`,
77
184
  }
78
185
 
79
186
  if (ctx?.signal?.aborted) {
80
- return JSON.stringify({ error: 'Wait cancelled', agentId: agent_id });
187
+ return JSON.stringify({ next_steps: errorNextSteps(), error: 'Wait cancelled', agentId: agent_id });
81
188
  }
82
189
 
83
190
  await new Promise(r => setTimeout(r, 200));
84
191
  }
85
192
 
86
193
  return JSON.stringify({
194
+ next_steps: nextStepsFor(agent.status, true),
87
195
  agentId: agent_id,
88
196
  name: agent.name,
89
197
  status: agent.status,