@dougbots/avenor-pi 0.15.0 → 0.16.0

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/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Input, Key, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { Type } from "typebox";
3
- import { createHash, randomUUID } from "node:crypto";
4
- import { Supervisor, THINKING_LEVELS, answerPermissionTool, askTool, attachOwner, brokerReceiveTool, brokerReplyTool, cancelTool, dial, eventsTool, extractEventText, extractReplyMessage, followUpTool, formatAskFailure, inspectTool, killTool, normalizeAskTimeoutMs, observeRun, peersTool, resultTool, shutdownTool, socketsRoot, spawnTool, statusTool, validateSpawnSelection, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
3
+ import { Supervisor, THINKING_LEVELS, answerPermissionTool, attachOwner, boundFinalOutput, brokerReceiveTool, createChildMessageProvider, createHostMessageProvider, dial, eventsTool, extractEventText, extractReplyMessage, extractTurnText, getSupervisorClient, inspectTool, installAskClaims, isGenerationMismatchError, killTool, messageTool, normalizeAskTimeoutMs, normalizeTurnStopReason, observeRun, peersDetails, peersTool, renderPeerList, resultOutput, resultTool, shutdownTool, socketsRoot, spawnTool, statusTool, validateSpawnSelection, workflowCancelTool, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
5
4
  import * as fs from "node:fs";
6
5
  import * as path from "node:path";
6
+ import { createHash } from "node:crypto";
7
7
  //#region src/subagent-broker.ts
8
8
  /** Deadline for a cleanup call whose caller's own deadline has already expired. */
9
9
  const ASK_CLEANUP_TIMEOUT_MS = 5e3;
@@ -36,25 +36,38 @@ function parseControlMessage(msg) {
36
36
  return {
37
37
  from_run_id: fromRunId,
38
38
  message_id: messageId,
39
- message: p.message
39
+ message: p.message,
40
+ role: typeof p.role === "string" ? p.role : void 0
40
41
  };
41
42
  }
42
- function formatInboundAskBody(ask) {
43
- return `**📨 Inbound ask**${ask.from_run_id ? ` from \`${ask.from_run_id}\`` : ""}\n\n${ask.message}\n\nTo answer, use avenor_reply({ reply_to_message_id: ${JSON.stringify(ask.message_id)}, message: "..." }).`;
43
+ /**
44
+ * Format an inbound ask as a steer body. Both modes share the formatter; only
45
+ * the heading and the optional role metadata differ.
46
+ */
47
+ function formatInboundAskBody(ask, options) {
48
+ const sender = ask.from_run_id;
49
+ const role = options.role ?? ask.role;
50
+ const from = sender ? role && role !== "agent" ? ` from \`${sender}\` (${role})` : ` from \`${sender}\`` : "";
51
+ return `**📨 ${options.heading}**${from}\n\n${ask.message}\n\nTo answer, use avenor_message({ action: "reply", reply_to: ${JSON.stringify(ask.message_id)}, message: "..." }).`;
44
52
  }
45
53
  /** A broker HTTP response error, so callers can classify by status instead of
46
54
  * matching digits in the request text. */
47
55
  var BrokerHttpError = class extends Error {
48
56
  status;
49
- constructor(path, status, body) {
57
+ code;
58
+ body;
59
+ constructor(path, status, body, code = "") {
50
60
  super(`broker ${path}: ${status} ${body}`);
51
61
  this.name = "BrokerHttpError";
52
62
  this.status = status;
63
+ this.code = code;
64
+ this.body = body;
53
65
  }
54
66
  };
55
67
  /** Build the `/send` body for one agent_message, as the broker child tools do. */
56
68
  function buildAgentMessage(fields) {
57
69
  return {
70
+ ...fields.generation !== void 0 ? { generation: fields.generation } : {},
58
71
  from_run_id: fields.fromRunId,
59
72
  to_run_id: fields.toRunId,
60
73
  type: "agent_message",
@@ -70,6 +83,15 @@ function buildAgentMessage(fields) {
70
83
  }
71
84
  };
72
85
  }
86
+ async function codedError(path, response) {
87
+ const body = (await response.text()).trim();
88
+ let code = "";
89
+ try {
90
+ const parsed = JSON.parse(body);
91
+ if (typeof parsed.code === "string") code = parsed.code;
92
+ } catch {}
93
+ return new BrokerHttpError(path, response.status, body, code);
94
+ }
73
95
  async function postBroker(env, path, body, fetchImpl = fetch, signal) {
74
96
  const res = await fetchImpl(`${env.brokerUrl}${path}`, {
75
97
  method: "POST",
@@ -81,27 +103,45 @@ async function postBroker(env, path, body, fetchImpl = fetch, signal) {
81
103
  }),
82
104
  ...signal ? { signal } : {}
83
105
  });
84
- if (!res.ok) throw new BrokerHttpError(path, res.status, (await res.text()).trim());
106
+ if (!res.ok) throw await codedError(path, res);
107
+ return res.json();
108
+ }
109
+ /** Authenticated GET; broker credentials travel in headers, never the URL. */
110
+ async function getBroker(env, path, fetchImpl = fetch, signal) {
111
+ const res = await fetchImpl(`${env.brokerUrl}${path}`, {
112
+ method: "GET",
113
+ headers: {
114
+ "X-Avenor-Run-Id": env.runId,
115
+ "X-Avenor-Run-Token": env.token
116
+ },
117
+ ...signal ? { signal } : {}
118
+ });
119
+ if (!res.ok) throw await codedError(path, res);
85
120
  return res.json();
86
121
  }
122
+ /** Read the ask generation `/sessions` advertises, or empty for none. */
123
+ async function readGeneration(env, fetchImpl) {
124
+ const body = await getBroker(env, "/sessions", fetchImpl);
125
+ return typeof body?.generation === "string" ? body.generation : "";
126
+ }
87
127
  /**
88
- * Resolve which pending ask to answer. Prefers an explicit message id (and its
89
- * asker); otherwise the most recent pending ask matching fromRunId.
128
+ * Pin the generation on first use and reuse it, so one child binds to one broker
129
+ * generation. The broker rotates it when its ask store resets; a pinned child is
130
+ * then refused instead of being retargeted. A broker that advertises no
131
+ * generation is not pinned, so the next call reads it again.
90
132
  */
91
- function resolveReplyTarget(pending, fromRunId, replyToMessageId) {
92
- if (replyToMessageId) {
93
- const hit = pending.get(replyToMessageId);
94
- if (!hit) return null;
95
- const from = fromRunId ?? hit.from_run_id;
96
- if (!from) return null;
97
- return {
98
- from_run_id: from,
99
- message_id: replyToMessageId
100
- };
101
- }
102
- let match;
103
- for (const [, entry] of pending) if (!fromRunId || entry.from_run_id === fromRunId) match = entry;
104
- return match ?? null;
133
+ function pinGeneration(env, fetchImpl) {
134
+ let pinned;
135
+ return () => {
136
+ pinned ??= readGeneration(env, fetchImpl).then((generation) => {
137
+ if (!generation) pinned = void 0;
138
+ return generation;
139
+ }, (error) => {
140
+ pinned = void 0;
141
+ throw error;
142
+ });
143
+ return pinned;
144
+ };
105
145
  }
106
146
  function combineSignals(...signals) {
107
147
  const active = signals.filter((signal) => signal !== void 0);
@@ -109,32 +149,47 @@ function combineSignals(...signals) {
109
149
  if (active.length === 1) return active[0];
110
150
  return AbortSignal.any(active);
111
151
  }
112
- function describeFailure(error) {
113
- if (error instanceof Error) {
114
- if (error.name === "TimeoutError") return "timed out";
115
- if (error.name === "AbortError") return "cancelled";
116
- return error.message || error.name;
117
- }
118
- return String(error);
119
- }
120
152
  /**
121
153
  * Withdraw a pending ask through the broker as this run. The broker's own error
122
154
  * propagates so a caller reporting to a model shows its exact text.
123
155
  */
124
- async function withdrawPendingAsk(env, messageId, fetchImpl = fetch) {
125
- await postBroker(env, "/cancel_message", { cancel_message_id: messageId }, fetchImpl, AbortSignal.timeout(ASK_CLEANUP_TIMEOUT_MS));
156
+ async function withdrawPendingAsk(env, messageId, generation, fetchImpl = fetch) {
157
+ const body = await postBroker(env, "/cancel_message", {
158
+ cancel_message_id: messageId,
159
+ generation
160
+ }, fetchImpl, AbortSignal.timeout(ASK_CLEANUP_TIMEOUT_MS));
161
+ if (typeof body?.outcome === "string" && body.outcome) return { outcome: body.outcome };
162
+ return { outcome: "withdrawn" };
126
163
  }
127
164
  /**
128
165
  * Withdraw a pending ask and classify whether the edge may still exist on the
129
166
  * broker: true means the cleanup could not be confirmed, false means it is gone.
130
167
  */
131
- async function withdrawAsk(env, messageId, fetchImpl) {
168
+ async function withdrawAsk(env, messageId, generation, fetchImpl) {
132
169
  try {
133
- await withdrawPendingAsk(env, messageId, fetchImpl);
134
- return false;
170
+ return {
171
+ pending: false,
172
+ outcome: (await withdrawPendingAsk(env, messageId, generation, fetchImpl)).outcome
173
+ };
135
174
  } catch (error) {
136
- if (error instanceof BrokerHttpError) return error.status !== 404;
137
- return true;
175
+ if (error instanceof BrokerHttpError) {
176
+ if (error.code === "fence-capacity") return {
177
+ pending: true,
178
+ outcome: "possibly-pending"
179
+ };
180
+ if (error.status === 404) return {
181
+ pending: false,
182
+ outcome: "edge-absent"
183
+ };
184
+ return {
185
+ pending: true,
186
+ outcome: "possibly-pending"
187
+ };
188
+ }
189
+ return {
190
+ pending: true,
191
+ outcome: "possibly-pending"
192
+ };
138
193
  }
139
194
  }
140
195
  /** Fetch causes for a dial that never opened, so no request reached the broker. */
@@ -187,12 +242,10 @@ async function brokerReachable(env, fetchImpl) {
187
242
  }
188
243
  }
189
244
  /**
190
- * Whether a failed send provably never reached the broker, mirroring the host's
191
- * rule: a dial that never opened or an unusable broker address carries no
192
- * request, and a send that drew no HTTP response while the broker no longer
193
- * accepts a dial cannot have left its edge in a broker that is gone. An HTTP
194
- * response, a reset, or an abort against a broker that answers at all may have
195
- * been processed and still needs a withdraw.
245
+ * Whether a failed send provably never reached the broker: a dial that never
246
+ * opened or an unusable broker address carries no request, and a send that drew
247
+ * no HTTP response while the broker no longer accepts a dial cannot have left
248
+ * its edge in a broker that is gone.
196
249
  */
197
250
  async function sendNeverReachedBroker(error, env, fetchImpl) {
198
251
  if (error instanceof BrokerHttpError) return false;
@@ -200,21 +253,84 @@ async function sendNeverReachedBroker(error, env, fetchImpl) {
200
253
  if (abortedBeforeResponse(error)) return false;
201
254
  return !await brokerReachable(env, fetchImpl);
202
255
  }
256
+ function brokerOutcome(body) {
257
+ const record = body !== null && typeof body === "object" ? body : {};
258
+ return typeof record.outcome === "string" && record.outcome ? record.outcome : "sent";
259
+ }
203
260
  /**
204
- * Ask another run through the inherited broker as this run, then wait for the
205
- * correlated reply. A failed send, wait, timeout, or abort withdraws the ask
206
- * and reports its message id plus whether it is still pending, matching the
207
- * host-side ask tool's contract.
261
+ * The outcome the wire contract documents for a known code, or undefined for a
262
+ * code outside it. This mirrors the Go transport's wireErrorClasses table: a
263
+ * code whose documented outcome is a credential or edge refusal keeps it, and
264
+ * every other code names a request that was never admitted.
208
265
  */
209
- async function askViaBroker(env, args, fetchImpl = fetch) {
210
- const messageId = randomUUID();
211
- const role = args.role ?? "agent";
266
+ function knownCodeOutcome(code) {
267
+ switch (code) {
268
+ case "auth-rejected":
269
+ case "sender-mismatch": return "auth-rejected";
270
+ case "not-ask-sender":
271
+ case "not-ask-target": return "edge-absent";
272
+ case "invalid-request":
273
+ case "generation-mismatch":
274
+ case "unsupported-protocol":
275
+ case "malformed-message-id":
276
+ case "message-id-expired":
277
+ case "message-id-horizon":
278
+ case "duplicate-message-id":
279
+ case "mutual-ask":
280
+ case "fence-capacity":
281
+ case "target-unavailable":
282
+ case "transport-closed":
283
+ case "unsupported-operation": return "not-sent";
284
+ default: return;
285
+ }
286
+ }
287
+ /**
288
+ * The outcome of a response the messaging envelope does not describe, as the Go
289
+ * local transport classifies it: only a 401 there is a credential rejection, a
290
+ * code-less 403 is the router's not-ask-target refusal, and every other status
291
+ * leaves transmission uncertain.
292
+ */
293
+ function unenvelopedOutcome(status) {
294
+ if (status === 401) return "auth-rejected";
295
+ if (status === 403) return "edge-absent";
296
+ return "possibly-pending";
297
+ }
298
+ /**
299
+ * Classify a broker HTTP rejection the way the Go local transport does: the
300
+ * body's outcome wins, then a known wire code, then the status. A rejection that
301
+ * names neither leaves transmission uncertain, so it is never reported
302
+ * `not-sent`; only a send that provably never left the surface is.
303
+ */
304
+ function brokerErrorOutcome(error) {
305
+ if (!(error instanceof BrokerHttpError)) return void 0;
306
+ try {
307
+ const parsed = JSON.parse(error.body);
308
+ if (parsed && typeof parsed.outcome === "string" && parsed.outcome) return parsed.outcome;
309
+ } catch {}
310
+ if (error.code) return knownCodeOutcome(error.code) ?? "possibly-pending";
311
+ return unenvelopedOutcome(error.status);
312
+ }
313
+ /**
314
+ * The outcome of a generation pin that failed before any message was sent: no
315
+ * request reached the send route, so only a credential refusal keeps its own
316
+ * outcome and everything else is a message that was never sent.
317
+ */
318
+ function pinFailureOutcome(error) {
319
+ return brokerErrorOutcome(error) === "auth-rejected" ? "auth-rejected" : "not-sent";
320
+ }
321
+ async function brokerAsk(env, args, messageId, fetchImpl, pin) {
322
+ const role = "agent";
212
323
  const timeoutMs = normalizeAskTimeoutMs(args.timeoutMs);
213
324
  const signal = combineSignals(args.signal, timeoutMs !== void 0 ? AbortSignal.timeout(timeoutMs) : void 0);
214
- const fail = async (detail) => {
215
- const pending = await withdrawAsk(env, messageId, fetchImpl);
216
- throw new Error(formatAskFailure(messageId, pending, detail));
217
- };
325
+ let generation;
326
+ try {
327
+ generation = await pin();
328
+ } catch (error) {
329
+ return {
330
+ outcome: pinFailureOutcome(error),
331
+ messageId
332
+ };
333
+ }
218
334
  try {
219
335
  await postBroker(env, "/send", buildAgentMessage({
220
336
  fromRunId: env.runId,
@@ -222,22 +338,237 @@ async function askViaBroker(env, args, fetchImpl = fetch) {
222
338
  messageId,
223
339
  message: args.message,
224
340
  role,
225
- expectsReply: true
341
+ expectsReply: true,
342
+ generation
226
343
  }), fetchImpl, signal);
227
344
  } catch (error) {
228
- if (await sendNeverReachedBroker(error, env, fetchImpl)) throw new Error(formatAskFailure(messageId, false, `send ask: ${describeFailure(error)}`));
229
- return fail(`send ask: ${describeFailure(error)}`);
345
+ if (await sendNeverReachedBroker(error, env, fetchImpl)) return {
346
+ outcome: "not-sent",
347
+ messageId
348
+ };
349
+ const rejected = brokerErrorOutcome(error);
350
+ if (rejected) return {
351
+ outcome: rejected,
352
+ messageId
353
+ };
354
+ return {
355
+ outcome: (await withdrawAsk(env, messageId, generation, fetchImpl)).outcome,
356
+ messageId
357
+ };
230
358
  }
231
359
  let reply;
232
360
  try {
233
- reply = await postBroker(env, "/wait_reply", { waiting_for: messageId }, fetchImpl, signal);
361
+ reply = await postBroker(env, "/wait_reply", {
362
+ waiting_for: messageId,
363
+ generation
364
+ }, fetchImpl, signal);
234
365
  } catch (error) {
235
- return fail(`wait for reply: ${describeFailure(error)}`);
366
+ const timedOut = error instanceof Error && error.name === "TimeoutError" || brokerErrorOutcome(error) === "timed-out";
367
+ const cleaned = await withdrawAsk(env, messageId, generation, fetchImpl);
368
+ if (timedOut) return {
369
+ outcome: "timed-out",
370
+ messageId
371
+ };
372
+ const outcome = brokerErrorOutcome(error);
373
+ if (outcome) return {
374
+ outcome,
375
+ messageId
376
+ };
377
+ if (cleaned.pending) return {
378
+ outcome: "possibly-pending",
379
+ messageId
380
+ };
381
+ return {
382
+ outcome: cleaned.outcome,
383
+ messageId
384
+ };
236
385
  }
237
- const record = reply && typeof reply === "object" ? reply : {};
386
+ const record = reply !== null && typeof reply === "object" ? reply : {};
387
+ const outcome = brokerOutcome(record);
388
+ const result = {
389
+ outcome,
390
+ messageId
391
+ };
392
+ if (outcome === "replied") result.reply = extractReplyMessage(record);
393
+ return result;
394
+ }
395
+ function peerRecords(body) {
396
+ const sessions = body !== null && typeof body === "object" ? body.sessions ?? [] : [];
397
+ if (!Array.isArray(sessions)) return [];
398
+ return sessions.map((raw) => {
399
+ const session = raw !== null && typeof raw === "object" ? raw : {};
400
+ const record = { run_id: typeof session.run_id === "string" ? session.run_id : "" };
401
+ for (const key of [
402
+ "label",
403
+ "kind",
404
+ "status",
405
+ "backend",
406
+ "model",
407
+ "dir"
408
+ ]) {
409
+ const value = session[key];
410
+ if (typeof value === "string" && value) record[key] = value;
411
+ }
412
+ return record;
413
+ }).filter((record) => record.run_id !== "");
414
+ }
415
+ function wireCode(code) {
416
+ switch (code) {
417
+ case "not-permitted": return "not_permitted";
418
+ case "invalid-name": return "invalid_name";
419
+ case "unknown-target": return "unknown_target";
420
+ case "ambiguous-name": return "ambiguous_name";
421
+ default: return;
422
+ }
423
+ }
424
+ /** A broker refusal as the router's typed tool error. */
425
+ function brokerToolError(error) {
426
+ if (error instanceof BrokerHttpError) {
427
+ const code = wireCode(error.code) ?? (error.status === 403 ? "not_permitted" : error.status === 404 ? "unknown_target" : error.status === 409 ? "ambiguous_name" : "refused");
428
+ const text = (() => {
429
+ try {
430
+ const parsed = JSON.parse(error.body);
431
+ if (typeof parsed.error === "string") return parsed.error;
432
+ } catch {}
433
+ return error.body || error.message;
434
+ })();
435
+ const typed = new Error(text);
436
+ typed.name = "ToolError";
437
+ typed.code = code;
438
+ typed.facts = {};
439
+ return typed;
440
+ }
441
+ return error instanceof Error ? error : new Error(String(error));
442
+ }
443
+ /**
444
+ * The inherited-broker implementation of the core ChildBroker contract. Every
445
+ * request authenticates as this run; a transport failure keeps its own text and
446
+ * becomes a refusal at the router boundary.
447
+ */
448
+ function createBrokerChildBroker(env, fetchImpl = fetch) {
449
+ const pin = pinGeneration(env, fetchImpl);
238
450
  return {
239
- reply: extractReplyMessage(record),
240
- from_run_id: record.from_run_id ?? ""
451
+ async tell(targetRunId, message, messageId) {
452
+ let generation;
453
+ try {
454
+ generation = await pin();
455
+ } catch (error) {
456
+ return {
457
+ outcome: pinFailureOutcome(error),
458
+ messageId
459
+ };
460
+ }
461
+ let body;
462
+ try {
463
+ body = await postBroker(env, "/send", buildAgentMessage({
464
+ fromRunId: env.runId,
465
+ toRunId: targetRunId,
466
+ messageId,
467
+ message,
468
+ role: "agent",
469
+ generation
470
+ }), fetchImpl);
471
+ } catch (error) {
472
+ return {
473
+ outcome: brokerErrorOutcome(error) ?? (await sendNeverReachedBroker(error, env, fetchImpl) ? "not-sent" : "possibly-pending"),
474
+ messageId
475
+ };
476
+ }
477
+ return {
478
+ outcome: brokerOutcome(body),
479
+ messageId: typeof body?.message_id === "string" && body.message_id ? body.message_id : messageId
480
+ };
481
+ },
482
+ ask(targetRunId, message, messageId, timeoutMs, signal) {
483
+ return brokerAsk(env, {
484
+ toRunId: targetRunId,
485
+ message,
486
+ timeoutMs,
487
+ signal
488
+ }, messageId, fetchImpl, pin);
489
+ },
490
+ async reply(askerRunId, inboundAskId, message) {
491
+ let body;
492
+ try {
493
+ body = await postBroker(env, "/send", buildAgentMessage({
494
+ fromRunId: env.runId,
495
+ toRunId: askerRunId,
496
+ messageId: `${inboundAskId}.reply`,
497
+ message,
498
+ role: "agent",
499
+ replyTo: inboundAskId
500
+ }), fetchImpl);
501
+ } catch (error) {
502
+ return {
503
+ outcome: brokerErrorOutcome(error) ?? (await sendNeverReachedBroker(error, env, fetchImpl) ? "not-sent" : "possibly-pending"),
504
+ messageId: inboundAskId
505
+ };
506
+ }
507
+ return {
508
+ outcome: brokerOutcome(body),
509
+ messageId: inboundAskId
510
+ };
511
+ },
512
+ async withdraw(messageId) {
513
+ let generation;
514
+ try {
515
+ generation = await pin();
516
+ } catch (error) {
517
+ throw brokerToolError(error);
518
+ }
519
+ try {
520
+ return { outcome: brokerOutcome(await postBroker(env, "/cancel_message", {
521
+ cancel_message_id: messageId,
522
+ generation
523
+ }, fetchImpl)) };
524
+ } catch (error) {
525
+ if (error instanceof BrokerHttpError) {
526
+ if (error.code === "fence-capacity") return { outcome: "possibly-pending" };
527
+ if (error.status === 404) {
528
+ try {
529
+ const parsed = JSON.parse(error.body);
530
+ if (typeof parsed.outcome === "string" && parsed.outcome) return { outcome: parsed.outcome };
531
+ } catch {}
532
+ return { outcome: "edge-absent" };
533
+ }
534
+ }
535
+ throw brokerToolError(error);
536
+ }
537
+ },
538
+ async peers() {
539
+ return { peers: peerRecords(await getBroker(env, "/sessions", fetchImpl)) };
540
+ },
541
+ async rename(req) {
542
+ try {
543
+ const body = await postBroker(env, "/rename", {
544
+ session_id: req.sessionId ?? "",
545
+ name: req.name
546
+ }, fetchImpl);
547
+ return {
548
+ outcome: "renamed",
549
+ runId: typeof body?.run_id === "string" ? body.run_id : "",
550
+ label: typeof body?.label === "string" ? body.label : "",
551
+ previousLabel: typeof body?.previous_label === "string" ? body.previous_label : ""
552
+ };
553
+ } catch (error) {
554
+ throw brokerToolError(error);
555
+ }
556
+ },
557
+ async last(runId) {
558
+ try {
559
+ const body = await getBroker(env, `/last_message?target=${encodeURIComponent(runId)}`, fetchImpl);
560
+ return {
561
+ outcome: "sent",
562
+ label: typeof body?.label === "string" ? body.label : void 0,
563
+ hasRecord: body?.has_record === true,
564
+ finalOutput: typeof body?.final_output === "string" ? body.final_output : "",
565
+ stopReason: typeof body?.stop_reason === "string" ? body.stop_reason : void 0,
566
+ truncated: body?.final_output_truncated === true
567
+ };
568
+ } catch (error) {
569
+ throw brokerToolError(error);
570
+ }
571
+ }
241
572
  };
242
573
  }
243
574
  //#endregion
@@ -569,7 +900,7 @@ var RunInspectorOverlay = class {
569
900
  }
570
901
  if (!isFollowUpable(this.state.status, this.state.snapshot)) return;
571
902
  this.runAction(async () => {
572
- const next = await this.deps.followUpRun(this.state.run, text);
903
+ const next = await this.deps.resumeRun(this.state.run, text);
573
904
  this.state = {
574
905
  ...this.state,
575
906
  run: {
@@ -1173,7 +1504,7 @@ function guidance(status, pending, runId) {
1173
1504
  const options = permissionOptions(pending);
1174
1505
  if (options.length > 0) return `Guidance: Call avenor_answer_permission with run_id "${runId}" and option_id "${selected(options[0], "option_id")}".`;
1175
1506
  if (pending) return `Guidance: Permission input is required, but no options are available; call avenor_inspect with run_id "${runId}".`;
1176
- return `Guidance: Call avenor_follow_up or avenor_status with run_id "${runId}".`;
1507
+ return `Guidance: Call avenor_message with action "resume" or avenor_status with run_id "${runId}".`;
1177
1508
  }
1178
1509
  if (isTerminal(status)) return `Guidance: Call avenor_result or avenor_events with run_id "${runId}".`;
1179
1510
  return `Guidance: Call avenor_result with run_id "${runId}".`;
@@ -1269,18 +1600,33 @@ function permissionLines(details, args) {
1269
1600
  `Guidance: Call avenor_status with run_id "${runId}" to verify the permission state.`
1270
1601
  ];
1271
1602
  }
1272
- function followUpLines(details, args) {
1603
+ function messageLines(details, args) {
1273
1604
  const result = asRecord(details);
1274
- if (!result || !stringValue(result.run_id) || !stringValue(result.label)) return void 0;
1275
- const runId = selected(result, "run_id", args.run_id);
1276
- const label = selected(result, "label", args.label, runId);
1277
- return [
1278
- `Follow-up: ${label} — dispatched.`,
1279
- `Source run: ${scalar(args.run_id)}`,
1280
- `Run: ${runId}`,
1281
- `Label: ${label}`,
1282
- `Guidance: Call avenor_status with run_id "${runId}".`
1283
- ];
1605
+ if (!result) return void 0;
1606
+ const action = selectedOptional(result, "action") ?? stringValue(args.action) ?? "tell";
1607
+ if (action === "resume") {
1608
+ const runId = selected(result, "run_id", args.target);
1609
+ const label = selected(result, "label", args.label, runId);
1610
+ if (!stringValue(runId) || !stringValue(label)) return void 0;
1611
+ return [
1612
+ `Resume: ${label} — dispatched.`,
1613
+ `Source run: ${scalar(args.target)}`,
1614
+ `Run: ${runId}`,
1615
+ `Label: ${label}`,
1616
+ `Guidance: Call avenor_status with run_id "${runId}".`
1617
+ ];
1618
+ }
1619
+ const outcome = selectedOptional(result, "outcome") ?? "done";
1620
+ const lines = [`${action.charAt(0).toUpperCase() + action.slice(1)}: ${outcome}.`];
1621
+ const text = selectedOptional(result, "text");
1622
+ if (text) lines.push(scalar(text));
1623
+ const messageId = selectedOptional(result, "message_id") ?? stringValue(args.reply_to) ?? stringValue(args.message_id);
1624
+ if (messageId) lines.push(`Message: ${scalar(messageId)}`);
1625
+ const reply = selectedOptional(result, "reply");
1626
+ if (action === "ask" && reply) lines.push(`Reply: ${scalar(reply)}`);
1627
+ const target = selectedOptional(result, "label") ?? (action === "reply" ? void 0 : stringValue(args.target));
1628
+ if (target) lines.push(`Target: ${scalar(target)}`);
1629
+ return lines;
1284
1630
  }
1285
1631
  function eventSequence(value) {
1286
1632
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
@@ -1502,12 +1848,21 @@ function renderAnswerPermissionCall(args, theme) {
1502
1848
  if (supervisor) pieces.push(`supervisor_id ${supervisor}`);
1503
1849
  return linesText([pieces.join(" ")], theme);
1504
1850
  }
1505
- function renderFollowUpCall(args, theme) {
1506
- const pieces = [`avenor_follow_up run_id ${quoted(args.run_id)}`, `message ${quoted(args.message)}`];
1507
- const label = optionalQuoted(args.label);
1508
- const supervisor = optionalQuoted(args.supervisor_id);
1509
- if (label) pieces.push(`label ${label}`);
1510
- if (supervisor) pieces.push(`supervisor_id ${supervisor}`);
1851
+ function renderMessageResumeCall(args, theme) {
1852
+ const action = stringValue(args.action) || "tell";
1853
+ const pieces = [`avenor_message action ${quoted(action)}`];
1854
+ if (action === "reply") pieces.push(`reply_to ${quoted(args.reply_to)}`, `message ${quoted(args.message)}`);
1855
+ else if (action === "withdraw") pieces.push(`message_id ${quoted(args.message_id)}`);
1856
+ else if (action === "control_reply") {
1857
+ pieces.push(`target ${quoted(args.target)}`);
1858
+ if (args.payload !== void 0) pieces.push(`payload ${quoted(JSON.stringify(args.payload))}`);
1859
+ } else {
1860
+ pieces.push(`target ${quoted(args.target)}`, `message ${quoted(args.message)}`);
1861
+ if (action === "resume") {
1862
+ const label = optionalQuoted(args.label);
1863
+ if (label) pieces.push(`label ${label}`);
1864
+ }
1865
+ }
1511
1866
  return linesText([pieces.join(" ")], theme);
1512
1867
  }
1513
1868
  function renderEventsCall(args, theme) {
@@ -1541,8 +1896,8 @@ function renderInspectResult(result, options, theme, args) {
1541
1896
  function renderAnswerPermissionResult(result, options, theme, args) {
1542
1897
  return render("answer_permission", result, options, theme, () => permissionLines(result.details, args));
1543
1898
  }
1544
- function renderFollowUpResult(result, options, theme, args) {
1545
- return render("follow_up", result, options, theme, () => followUpLines(result.details, args));
1899
+ function renderMessageResumeResult(result, options, theme, args) {
1900
+ return render("message", result, options, theme, () => messageLines(result.details, args));
1546
1901
  }
1547
1902
  function renderEventsResult(result, options, theme, args) {
1548
1903
  return render("events", result, options, theme, () => eventsLines(result.details, args, options.expanded));
@@ -1577,11 +1932,10 @@ const defaultDeps = {
1577
1932
  statusTool,
1578
1933
  eventsTool,
1579
1934
  answerPermissionTool,
1580
- followUpTool,
1935
+ messageTool,
1581
1936
  inspectTool,
1582
1937
  resultTool,
1583
1938
  brokerReceiveTool,
1584
- brokerReplyTool,
1585
1939
  shutdownTool,
1586
1940
  killTool,
1587
1941
  workflowStatusTool,
@@ -1592,6 +1946,8 @@ const defaultDeps = {
1592
1946
  workflowGateTool,
1593
1947
  workflowControllerStatusTool,
1594
1948
  workflowReadyTool,
1949
+ workflowCancelTool,
1950
+ getSupervisorClient,
1595
1951
  observeRun,
1596
1952
  dial,
1597
1953
  attachOwner,
@@ -1633,7 +1989,7 @@ function buildCompletionText(run, result, finalOutput) {
1633
1989
  if (output) lines.push("", "Final output:", ...quotedLines(output));
1634
1990
  if (result.stop_reason && result.stop_reason.toUpperCase() !== result.status.toUpperCase()) lines.push(`Stop reason: ${result.stop_reason}`);
1635
1991
  if (result.session_id) lines.push(`Session: \`${result.session_id}\``);
1636
- lines.push("", `Call \`avenor_inspect\` with \`run_id: "${run.runId}"\` for the bounded transcript/snapshot, or \`avenor_follow_up\` to iterate.`);
1992
+ lines.push("", `Call \`avenor_inspect\` with \`run_id: "${run.runId}"\` for the bounded transcript/snapshot, or \`avenor_message\` with \`action: "resume"\` to iterate.`);
1637
1993
  return lines.join("\n");
1638
1994
  }
1639
1995
  function buildWaitingText(run, status) {
@@ -1642,7 +1998,7 @@ function buildWaitingText(run, status) {
1642
1998
  const lines = [`Sub-agent "${run.label}" (${agent}) is waiting for input.`];
1643
1999
  if (perm) lines.push(`Permission request: ${perm.description}`, `To answer: call \`avenor_answer_permission\` with run_id "${run.runId}", option_id "allow_once" | "allow_always" | "deny"`);
1644
2000
  else if (status.phase_label) lines.push(`Question: ${status.phase_label}`);
1645
- if (!perm) lines.push(`Use \`avenor_follow_up\` with run_id "${run.runId}" to respond, or \`avenor_inspect\` to review context first.`);
2001
+ if (!perm) lines.push(`Use \`avenor_message\` with \`action: "resume"\` and target "${run.runId}" to respond, or \`avenor_inspect\` to review context first.`);
1646
2002
  return lines.join("\n");
1647
2003
  }
1648
2004
  function isTerminalStatus(status) {
@@ -1746,6 +2102,16 @@ function createExtension(deps = defaultDeps, options = {}) {
1746
2102
  const pollingErrors = [];
1747
2103
  let lastStatusEntries = [];
1748
2104
  const pendingWaits = /* @__PURE__ */ new Set();
2105
+ /** The agent-facing prose clause for a stopping point. A synthetic status
2106
+ * such as `asking` has no run-status spelling, so it must not appear raw in
2107
+ * this prose. Structured payloads still carry the raw status (the
2108
+ * avenor_result JSON reports it as `interrupted_by.status`) so consumers can
2109
+ * check it. */
2110
+ function describeStoppingPoint(stop) {
2111
+ if (stop.status === "waiting") return "reached a permission request";
2112
+ if (stop.status === "asking") return "asked a question for you";
2113
+ return `reached ${stop.status}`;
2114
+ }
1749
2115
  function interruptPendingWaits(stop, excludeRunKey) {
1750
2116
  for (const wait of [...pendingWaits]) {
1751
2117
  if (wait.runKey === excludeRunKey) continue;
@@ -1761,6 +2127,29 @@ function createExtension(deps = defaultDeps, options = {}) {
1761
2127
  pendingWaits.add(wait);
1762
2128
  return wait;
1763
2129
  }
2130
+ /** An inbound ask is a stopping point: the asker cannot finish until it is
2131
+ * answered, so every blocking wait returns, the asker's own wait included. */
2132
+ function interruptWaitsForAsk(ask) {
2133
+ interruptPendingWaits({
2134
+ runId: ask.from_run_id,
2135
+ status: "asking"
2136
+ });
2137
+ }
2138
+ /** Surface one inbound ask: hand the body to the session, remember it so a
2139
+ * reply can reach the asker, then let every blocking wait return. Delivery
2140
+ * comes first so a throw leaves the waits waiting on an ask the agent never
2141
+ * saw, rather than reporting an ask it cannot answer. */
2142
+ function surfaceInboundAsk(pending, ask, heading) {
2143
+ pi.sendUserMessage(formatInboundAskBody(ask, {
2144
+ heading,
2145
+ role: ask.role
2146
+ }), { deliverAs: "steer" });
2147
+ pending.set(ask.message_id, {
2148
+ from_run_id: ask.from_run_id,
2149
+ message_id: ask.message_id
2150
+ });
2151
+ interruptWaitsForAsk(ask);
2152
+ }
1764
2153
  function createInterruptibleWait(runKey, signal) {
1765
2154
  const controller = new AbortController();
1766
2155
  let stopped;
@@ -1854,11 +2243,7 @@ function createExtension(deps = defaultDeps, options = {}) {
1854
2243
  for (const msg of msgs) {
1855
2244
  const ask = parseControlMessage(msg);
1856
2245
  if (!ask) continue;
1857
- subAgentPendingAsks.set(ask.message_id, {
1858
- from_run_id: ask.from_run_id,
1859
- message_id: ask.message_id
1860
- });
1861
- pi.sendUserMessage(formatInboundAskBody(ask), { deliverAs: "steer" });
2246
+ surfaceInboundAsk(subAgentPendingAsks, ask, "Inbound ask");
1862
2247
  }
1863
2248
  } catch (error) {
1864
2249
  consecutiveFailures++;
@@ -1873,6 +2258,7 @@ function createExtension(deps = defaultDeps, options = {}) {
1873
2258
  }
1874
2259
  let hostPollTimer = null;
1875
2260
  let hostPollGeneration = 0;
2261
+ let hostReceiveGeneration;
1876
2262
  const hostPendingAsks = /* @__PURE__ */ new Map();
1877
2263
  function stopHostAskPoll() {
1878
2264
  hostPollGeneration++;
@@ -1886,20 +2272,27 @@ function createExtension(deps = defaultDeps, options = {}) {
1886
2272
  const generation = hostPollGeneration;
1887
2273
  const intervalMs = options.pollIntervalMs ?? 2e3;
1888
2274
  const tick = async () => {
2275
+ const startedAt = Date.now();
1889
2276
  try {
1890
- const { asks } = await deps.brokerReceiveTool({});
1891
- if (generation !== hostPollGeneration) return;
1892
- for (const ask of asks) {
1893
- hostPendingAsks.set(ask.message_id, {
1894
- from_run_id: ask.from_run_id,
1895
- message_id: ask.message_id
1896
- });
1897
- const body = `**📨 Ask from sub-agent** (\`${ask.role && ask.role !== "agent" ? `${ask.from_run_id} (${ask.role})` : ask.from_run_id}\`)\n\n${ask.message}\n\nTo answer, use the intercom tool: avenor_reply({ from_run_id: ${JSON.stringify(ask.from_run_id)}, reply_to_message_id: ${JSON.stringify(ask.message_id)}, message: "..." })`;
1898
- pi.sendUserMessage(body, { deliverAs: "steer" });
2277
+ const receive = () => hostReceiveGeneration ? deps.brokerReceiveTool({
2278
+ generation: hostReceiveGeneration,
2279
+ timeout_ms: intervalMs
2280
+ }) : deps.brokerReceiveTool({ timeout_ms: intervalMs });
2281
+ let received;
2282
+ try {
2283
+ received = await receive();
2284
+ } catch (error) {
2285
+ if (!hostReceiveGeneration || !isGenerationMismatchError(error)) throw error;
2286
+ hostReceiveGeneration = void 0;
2287
+ disposeHostProviders();
2288
+ received = await receive();
1899
2289
  }
2290
+ if (generation !== hostPollGeneration) return;
2291
+ if (received.generation) hostReceiveGeneration = received.generation;
2292
+ for (const ask of received.asks) surfaceInboundAsk(hostPendingAsks, ask, "Ask from sub-agent");
1900
2293
  } catch {}
1901
2294
  if (generation !== hostPollGeneration) return;
1902
- hostPollTimer = setTimeout(() => void tick(), intervalMs);
2295
+ hostPollTimer = setTimeout(() => void tick(), Math.max(0, intervalMs - (Date.now() - startedAt)));
1903
2296
  hostPollTimer.unref?.();
1904
2297
  };
1905
2298
  hostPollTimer = setTimeout(() => void tick(), intervalMs);
@@ -2227,44 +2620,22 @@ function createExtension(deps = defaultDeps, options = {}) {
2227
2620
  await client.interruptAndPrompt(runtimeId, text);
2228
2621
  });
2229
2622
  },
2230
- followUpRun(run, text) {
2231
- return deps.followUpTool({
2232
- runId: run.runId,
2233
- message: text,
2234
- supervisorId: run.supervisorId
2235
- });
2623
+ resumeRun(run, text) {
2624
+ return resumeTrackedRun(run, text);
2236
2625
  }
2237
2626
  };
2238
2627
  }
2239
- async function followUpTrackedRun(run, text) {
2240
- const result = await deps.followUpTool({
2241
- runId: run.runId,
2242
- message: text,
2243
- supervisorId: run.supervisorId
2244
- });
2245
- try {
2246
- const status = asStatusResult(await deps.statusTool({
2247
- runId: result.run_id,
2248
- supervisorId: run.supervisorId
2249
- }));
2250
- upsertTrackedRun({
2251
- runId: result.run_id,
2252
- label: result.label,
2253
- agent: status?.agent ?? run.agent,
2254
- runtimeId: status?.runtime_id,
2255
- supervisorId: run.supervisorId
2256
- }, status, false);
2257
- startPolling();
2258
- } catch {
2259
- upsertTrackedRun({
2260
- runId: result.run_id,
2261
- label: result.label,
2262
- agent: run.agent,
2263
- supervisorId: run.supervisorId
2264
- }, void 0, false);
2265
- startPolling();
2266
- }
2267
- return result;
2628
+ async function resumeTrackedRun(run, text) {
2629
+ const provider = await hostMessageProvider(run.supervisorId);
2630
+ const result = await deps.messageTool({
2631
+ action: "resume",
2632
+ target: run.runId,
2633
+ message: text
2634
+ }, provider);
2635
+ return {
2636
+ run_id: result.runId ?? run.runId,
2637
+ label: result.label ?? run.runId
2638
+ };
2268
2639
  }
2269
2640
  async function waitForRunWithObserver(run, signal, onUpdate) {
2270
2641
  let observer = null;
@@ -2372,6 +2743,102 @@ function createExtension(deps = defaultDeps, options = {}) {
2372
2743
  if (observed.status || observed.aborted) return observed;
2373
2744
  return waitForRunByPolling(run, signal, onUpdate);
2374
2745
  }
2746
+ const hostProviders = /* @__PURE__ */ new Map();
2747
+ const hostAskClaims = /* @__PURE__ */ new Map();
2748
+ function disposeHostProvider(supervisorId) {
2749
+ const key = supervisorId ?? "";
2750
+ const binding = hostProviders.get(key);
2751
+ hostProviders.delete(key);
2752
+ hostAskClaims.delete(key);
2753
+ if (!binding || binding.isSingleton || !binding.client) return;
2754
+ try {
2755
+ binding.client.close();
2756
+ } catch {}
2757
+ }
2758
+ function disposeHostProviders() {
2759
+ for (const key of [...hostProviders.keys()]) disposeHostProvider(key);
2760
+ hostAskClaims.clear();
2761
+ }
2762
+ async function defaultHostProviderFor(supervisorId) {
2763
+ const key = supervisorId ?? "";
2764
+ const existing = hostProviders.get(key);
2765
+ if (existing) return existing.provider;
2766
+ const { client, isSingleton } = await (deps.getSupervisorClient ?? getSupervisorClient)(supervisorId, { owner: true });
2767
+ const preflight = await client.messagePreflight();
2768
+ const provider = createHostMessageProvider({
2769
+ surface: "pi-host",
2770
+ supervisorId: supervisorId ?? Supervisor.currentInstance()?.supervisorId ?? "",
2771
+ generation: preflight.generation,
2772
+ client,
2773
+ onResumed: (result) => trackResumed(supervisorId, result)
2774
+ });
2775
+ hostProviders.set(key, {
2776
+ provider,
2777
+ client,
2778
+ isSingleton
2779
+ });
2780
+ return provider;
2781
+ }
2782
+ async function trackResumed(supervisorId, result) {
2783
+ const run = {
2784
+ runId: result.runId ?? "",
2785
+ label: result.label ?? result.runId ?? "",
2786
+ supervisorId
2787
+ };
2788
+ try {
2789
+ const status = asStatusResult(await deps.statusTool({
2790
+ runId: run.runId,
2791
+ supervisorId
2792
+ }));
2793
+ upsertTrackedRun({
2794
+ runId: run.runId,
2795
+ label: run.label,
2796
+ agent: status?.agent,
2797
+ runtimeId: status?.runtime_id,
2798
+ supervisorId
2799
+ }, status, false);
2800
+ } catch {
2801
+ upsertTrackedRun(run, void 0, false);
2802
+ }
2803
+ startPolling();
2804
+ }
2805
+ const hostProviderFor = deps.hostProviderFor ?? defaultHostProviderFor;
2806
+ const childProvider = isSubAgentBrokerMode && subAgentEnv ? createChildMessageProvider({
2807
+ surface: "pi-child",
2808
+ runId: subAgentEnv.runId,
2809
+ broker: createBrokerChildBroker(subAgentEnv),
2810
+ pendingAsks: subAgentPendingAsks,
2811
+ pollGeneration: () => subAgentPollGeneration
2812
+ }) : null;
2813
+ async function hostMessageProvider(supervisorId) {
2814
+ const provider = await hostProviderFor(supervisorId);
2815
+ const key = supervisorId ?? "";
2816
+ let claims = hostAskClaims.get(key);
2817
+ if (!claims) {
2818
+ claims = /* @__PURE__ */ new Map();
2819
+ hostAskClaims.set(key, claims);
2820
+ }
2821
+ installAskClaims(provider, claims, hostPendingAsks, () => hostPollGeneration);
2822
+ if (!provider.onResumed) provider.onResumed = (result) => trackResumed(supervisorId, result);
2823
+ return provider;
2824
+ }
2825
+ async function messageProvider(supervisorId) {
2826
+ if (childProvider) return childProvider;
2827
+ return hostMessageProvider(supervisorId);
2828
+ }
2829
+ pi.on("turn_end", async (event) => {
2830
+ if (isSubAgentBrokerMode) return;
2831
+ if (!deps.Supervisor.currentInstance?.()) return;
2832
+ const turn = extractTurnText([event.message]);
2833
+ try {
2834
+ const provider = await hostProviderFor(void 0);
2835
+ if (!provider.recordLastTurn) return;
2836
+ const bounded = boundFinalOutput(turn.text);
2837
+ await provider.recordLastTurn(bounded, normalizeTurnStopReason(turn.stopReason), turn.text.length > bounded.length);
2838
+ } catch (error) {
2839
+ console.error("[avenor] session_last_set failed:", error instanceof Error ? error.message : error);
2840
+ }
2841
+ });
2375
2842
  pi.on("session_start", async (_event, ctx) => {
2376
2843
  sessionCtx = ctx;
2377
2844
  lastStatusEntries = [];
@@ -2383,6 +2850,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2383
2850
  pi.on("session_shutdown", async () => {
2384
2851
  stopHostAskPoll();
2385
2852
  stopSubAgentPoll();
2853
+ disposeHostProviders();
2386
2854
  hostPendingAsks.clear();
2387
2855
  subAgentPendingAsks.clear();
2388
2856
  await stopPolling();
@@ -2422,15 +2890,15 @@ function createExtension(deps = defaultDeps, options = {}) {
2422
2890
  pi.registerTool({
2423
2891
  name: "avenor_spawn",
2424
2892
  label: "Avenor Spawn",
2425
- description: `Dispatch an agent run via avenor. Optional thinking accepts ${[...THINKING_LEVELS].slice(0, -1).join(", ")}, or ${THINKING_LEVELS[THINKING_LEVELS.length - 1]}; unsupported backends reject explicit values. Blocks by default, showing live progress. Set wait=false for fire-and-forget. With several blocking spawns in parallel, the first stopping point (permission or completion) interrupts the other waits.`,
2893
+ description: `Dispatch an agent run via avenor. Optional thinking accepts ${[...THINKING_LEVELS].slice(0, -1).join(", ")}, or ${THINKING_LEVELS[THINKING_LEVELS.length - 1]}; unsupported backends reject explicit values. Blocks by default, showing live progress. Set wait=false for fire-and-forget. With several blocking spawns in parallel, the first stopping point (a permission request, an inbound ask, or completion) interrupts the other waits.`,
2426
2894
  promptSnippet: "Dispatch sub-agent runs via avenor (spawn, status, result, inspect, events, follow-up, shutdown)",
2427
2895
  promptGuidelines: [
2428
2896
  "Use avenor_spawn to delegate well-defined tasks to sub-agents.",
2429
2897
  "Use avenor_status (view \"lifecycle\") only to check progress or pending permissions.",
2430
- "Use avenor_result to retrieve the final result of a sub-agent; it waits by default, but set wait=false for runs that already completed. When awaiting multiple runs, the first stopping point (permission or completion) interrupts the other waits.",
2898
+ "Use avenor_result to retrieve the final result of a sub-agent; it waits by default, but set wait=false for runs that already completed. When awaiting multiple runs, the first stopping point (a permission request, an inbound ask, or completion) interrupts the other waits.",
2431
2899
  "Use avenor_inspect to review a bounded transcript and tool activity from a run.",
2432
2900
  "Use avenor_events only when you specifically need raw event payloads.",
2433
- "Use avenor_follow_up to iterate on a completed run.",
2901
+ "Use avenor_message with action \"resume\" to iterate on a completed run.",
2434
2902
  "Use avenor_shutdown when done delegating work.",
2435
2903
  "When roster_entry is set, the roster selects agent, model, and backend; do not pass those fields alongside it.",
2436
2904
  "If a roster spawn fails validation or resolution, do not retry the roster: respawn without roster_file/roster_entry. Direct selectors (agent, model, backend) are optional; omission uses runtime defaults."
@@ -2551,7 +3019,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2551
3019
  return {
2552
3020
  content: [{
2553
3021
  type: "text",
2554
- text: `Monitoring of "${label}" (run_id: ${result.run_id}) was interrupted: "${stop.label ?? stop.runId}" reached ${stop.status === "waiting" ? "a permission request" : stop.status}. Handle that first; this run's final result is delivered automatically.`
3022
+ text: `Monitoring of "${label}" (run_id: ${result.run_id}) was interrupted: "${stop.label ?? stop.runId}" ${describeStoppingPoint(stop)}. Handle that first; this run's final result is delivered automatically.`
2555
3023
  }],
2556
3024
  details: {
2557
3025
  run_id: result.run_id,
@@ -2717,7 +3185,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2717
3185
  pi.registerTool({
2718
3186
  name: "avenor_result",
2719
3187
  label: "Avenor Result",
2720
- description: `Retrieve the complete final output of a run without transcript or event details. Waits by default; set wait=false to retrieve an already-completed run without blocking. When several runs are pending, the first stopping point (a permission request or completion on any run) interrupts other blocking waits so they can be handled; re-issue the interrupted wait afterwards.`,
3188
+ description: `Retrieve the complete final output of a run without transcript or event details. Waits by default; set wait=false to retrieve an already-completed run without blocking. When several runs are pending, the first stopping point (a permission request, an inbound ask, or completion on any run) interrupts other blocking waits so they can be handled; re-issue the interrupted wait afterwards.`,
2721
3189
  parameters: Type.Object({
2722
3190
  run_id: Type.String({ description: "Run ID or label" }),
2723
3191
  wait: Type.Optional(Type.Boolean({ description: "Wait for a terminal result (default true); false retrieves the current state without blocking" })),
@@ -2852,205 +3320,94 @@ function createExtension(deps = defaultDeps, options = {}) {
2852
3320
  }
2853
3321
  });
2854
3322
  pi.registerTool({
2855
- name: "avenor_follow_up",
2856
- label: "Avenor Follow Up",
2857
- description: "Resume a completed run with a follow-up message.",
3323
+ name: "avenor_message",
3324
+ label: "Avenor Message",
3325
+ description: "Send a message to another agent run, answer an inbound ask, withdraw a pending ask, or resume a completed run. Every action is explicit and the result states whether a failed ask can still be withdrawn. `control_reply` is available only on the claude-channel surface.",
2858
3326
  parameters: Type.Object({
2859
- run_id: Type.String({ description: "Completed run ID to resume" }),
2860
- message: Type.String({ description: "Follow-up message text" }),
2861
- label: Type.Optional(Type.String({ description: "Override label (defaults to <original>-followup)" })),
2862
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3327
+ action: Type.Union([
3328
+ Type.Literal("tell"),
3329
+ Type.Literal("ask"),
3330
+ Type.Literal("reply"),
3331
+ Type.Literal("resume"),
3332
+ Type.Literal("withdraw"),
3333
+ Type.Literal("control_reply")
3334
+ ], { description: "tell | ask | reply | resume | withdraw | control_reply" }),
3335
+ target: Type.Optional(Type.String({ description: "Recipient for tell/ask/resume (a run id, a peer label, or \"self\"), or the opaque control message id for control_reply" })),
3336
+ message: Type.Optional(Type.String({ description: "Message text for tell/ask/reply/resume" })),
3337
+ reply_to: Type.Optional(Type.String({ description: "Inbound ask id being answered (reply)" })),
3338
+ message_id: Type.Optional(Type.String({ description: "Ask id being withdrawn (withdraw)" })),
3339
+ timeout_ms: Type.Optional(Type.Integer({ description: "Bound on the ask wait in milliseconds" })),
3340
+ label: Type.Optional(Type.String({ description: "Override label for the resumed run (resume)" })),
3341
+ idempotency_key: Type.Optional(Type.String({ description: "Forwarded verbatim to the resume authority (resume)" })),
3342
+ payload: Type.Optional(Type.Unknown({ description: "Control reply body (control_reply)" }))
2863
3343
  }),
2864
- async execute(_toolCallId, params) {
2865
- const result = await deps.followUpTool({
2866
- runId: params.run_id,
2867
- message: params.message,
2868
- label: params.label,
2869
- supervisorId: params.supervisor_id
2870
- });
2871
- try {
2872
- const status = asStatusResult(await deps.statusTool({
2873
- runId: result.run_id,
2874
- supervisorId: params.supervisor_id
2875
- }));
2876
- upsertTrackedRun({
2877
- runId: result.run_id,
2878
- label: result.label,
2879
- agent: status?.agent,
2880
- runtimeId: status?.runtime_id,
2881
- supervisorId: params.supervisor_id
2882
- }, status, false);
2883
- } catch {
2884
- upsertTrackedRun({
2885
- runId: result.run_id,
2886
- label: result.label,
2887
- supervisorId: params.supervisor_id
2888
- }, void 0, false);
2889
- }
2890
- startPolling();
3344
+ async execute(_toolCallId, params, signal) {
3345
+ const provider = await messageProvider(void 0);
3346
+ const result = await deps.messageTool(params, provider, { signal });
2891
3347
  return {
2892
3348
  content: [{
2893
3349
  type: "text",
2894
- text: JSON.stringify(result, null, 2)
3350
+ text: result.text ?? ""
2895
3351
  }],
2896
- details: result
3352
+ details: resultOutput(result)
2897
3353
  };
2898
3354
  },
2899
- renderCall(args, theme, context) {
2900
- return renderFollowUpCall(context.args, theme);
3355
+ renderCall(args, theme) {
3356
+ return renderMessageResumeCall(args, theme);
2901
3357
  },
2902
3358
  renderResult(result, { expanded, isPartial }, theme, context) {
2903
- return renderFollowUpResult(result, {
3359
+ return renderMessageResumeResult(result, {
2904
3360
  expanded,
2905
3361
  isPartial
2906
3362
  }, theme, context.args);
2907
3363
  }
2908
3364
  });
2909
3365
  pi.registerTool({
2910
- name: "avenor_ask",
2911
- label: "Avenor Ask",
2912
- description: "Ask another agent run a question and block until it replies. An optional timeout_ms bounds the wait; a failed ask reports its message_id and whether it is still pending (withdrawable via avenor_cancel) or already withdrawn.",
3366
+ name: "avenor_peers",
3367
+ label: "Avenor Peers",
3368
+ description: "List the visible agent sessions, rename a run, or read a run's last agent message. `last` returns the target's final text with its stop reason, or an empty result when the target has no completed turn yet.",
2913
3369
  parameters: Type.Object({
2914
- to_run_id: Type.String({ description: "Target run ID" }),
2915
- message: Type.String({ description: "Question to ask the other agent" }),
2916
- timeout_ms: Type.Optional(Type.Integer({ description: "Optional timeout in milliseconds for the ask" })),
2917
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path (ignored in a Pi sub-agent, which asks through its inherited broker)" }))
3370
+ action: Type.Optional(Type.Union([
3371
+ Type.Literal("list"),
3372
+ Type.Literal("rename"),
3373
+ Type.Literal("last")
3374
+ ], { description: "list (the default) | rename | last" })),
3375
+ name: Type.Optional(Type.String({ description: "New display name (rename)" })),
3376
+ session_id: Type.Optional(Type.String({ description: "Run to rename; omitted renames the caller (rename)" })),
3377
+ target: Type.Optional(Type.String({ description: "Peer to read: a run id, a label, or \"self\" (last)" }))
2918
3378
  }),
2919
- async execute(_toolCallId, params, signal) {
2920
- const result = isSubAgentBrokerMode && subAgentEnv ? await askViaBroker(subAgentEnv, {
2921
- toRunId: params.to_run_id,
2922
- message: params.message,
2923
- timeoutMs: params.timeout_ms,
2924
- signal
2925
- }) : await askTool({
2926
- toRunId: params.to_run_id,
2927
- message: params.message,
2928
- timeoutMs: params.timeout_ms,
2929
- supervisorId: params.supervisor_id
2930
- });
3379
+ async execute(_toolCallId, params) {
3380
+ const result = await peersTool(params, await messageProvider(void 0));
2931
3381
  return {
2932
3382
  content: [{
2933
3383
  type: "text",
2934
- text: `Reply from ${result.from_run_id || params.to_run_id}:\n${result.reply}`
3384
+ text: result.action === "list" ? [result.text, renderPeerList(result.peers)].filter((part) => part !== "").join("\n\n") : result.text
2935
3385
  }],
2936
- details: result
3386
+ details: peersDetails(result)
2937
3387
  };
2938
3388
  }
2939
3389
  });
2940
3390
  pi.registerTool({
2941
- name: "avenor_reply",
2942
- label: "Avenor Reply (host)",
2943
- description: "Reply to an inbound ask received from a sub-agent. Answer the most recent ask from a given sub-agent, or reply to a specific message_id from avenor_ask.",
3391
+ name: "avenor_workflow_cancel",
3392
+ label: "Avenor Workflow Cancel",
3393
+ description: "Cancel a workflow instance. The actor and reason are required.",
2944
3394
  parameters: Type.Object({
2945
- from_run_id: Type.Optional(Type.String({ description: "Sub-agent run id to reply to (the asker)" })),
2946
- reply_to_message_id: Type.Optional(Type.String({ description: "The ask's broker message id to answer specifically" })),
2947
- message: Type.String({ description: "Your answer" }),
2948
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3395
+ workflow_id: Type.String({ description: "Workflow ID" }),
3396
+ actor: Type.String({ description: "Who is cancelling" }),
3397
+ reason: Type.String({ description: "Why the workflow is being cancelled" }),
3398
+ supervisor_id: Type.Optional(Type.String({ description: "Supervisor ID for multi-supervisor mode" }))
2949
3399
  }),
2950
3400
  async execute(_toolCallId, params) {
2951
- if (isSubAgentBrokerMode && subAgentEnv) {
2952
- const target = resolveReplyTarget(subAgentPendingAsks, params.from_run_id, params.reply_to_message_id);
2953
- if (!target) return {
2954
- content: [{
2955
- type: "text",
2956
- text: "No pending ask found to reply to. Pass from_run_id and/or reply_to_message_id."
2957
- }],
2958
- details: { error: true }
2959
- };
2960
- const generation = subAgentPollGeneration;
2961
- subAgentPendingAsks.delete(target.message_id);
2962
- try {
2963
- await postBroker(subAgentEnv, "/send", buildAgentMessage({
2964
- fromRunId: subAgentEnv.runId,
2965
- toRunId: target.from_run_id,
2966
- messageId: randomUUID(),
2967
- message: params.message,
2968
- role: "agent",
2969
- replyTo: target.message_id
2970
- }));
2971
- } catch (error) {
2972
- if (generation === subAgentPollGeneration) subAgentPendingAsks.set(target.message_id, target);
2973
- throw error;
2974
- }
2975
- return { content: [{
2976
- type: "text",
2977
- text: "Reply sent"
2978
- }] };
2979
- }
2980
- const target = resolveReplyTarget(hostPendingAsks, params.from_run_id, params.reply_to_message_id);
2981
- if (!target) return {
2982
- content: [{
2983
- type: "text",
2984
- text: "No pending ask found to reply to. Pass from_run_id and/or reply_to_message_id."
2985
- }],
2986
- details: { error: true }
2987
- };
2988
- const generation = hostPollGeneration;
2989
- hostPendingAsks.delete(target.message_id);
2990
- try {
2991
- await deps.brokerReplyTool({
2992
- toRunId: target.from_run_id,
2993
- replyTo: target.message_id,
2994
- message: params.message,
2995
- supervisorId: params.supervisor_id
2996
- });
2997
- } catch (error) {
2998
- if (generation === hostPollGeneration) hostPendingAsks.set(target.message_id, target);
2999
- throw error;
3000
- }
3001
- return { content: [{
3002
- type: "text",
3003
- text: "Reply sent"
3004
- }] };
3005
- }
3006
- });
3007
- pi.registerTool({
3008
- name: "avenor_peers",
3009
- label: "Avenor Peers",
3010
- description: "List all connected agent sessions with their status and metadata.",
3011
- parameters: Type.Object({}),
3012
- async execute() {
3013
- const result = await peersTool({});
3014
- if (result.peers.length === 0) return { content: [{
3015
- type: "text",
3016
- text: "No other agent sessions connected."
3017
- }] };
3018
- return { content: [{
3019
- type: "text",
3020
- text: `**Active sessions:**\n${result.peers.map((p) => {
3021
- const parts = [p.label ? `${p.label} (${p.run_id})` : p.run_id];
3022
- const tags = [
3023
- p.status,
3024
- p.backend,
3025
- p.model
3026
- ].filter(Boolean);
3027
- if (tags.length) parts.push(`[${tags.join(", ")}]`);
3028
- return ` ${parts.join(" ")}`;
3029
- }).join("\n")}`
3030
- }] };
3031
- }
3032
- });
3033
- pi.registerTool({
3034
- name: "avenor_cancel",
3035
- label: "Avenor Cancel",
3036
- description: "Cancel a pending ask by its message ID.",
3037
- parameters: Type.Object({ message_id: Type.String({ description: "Message ID of the pending ask to cancel" }) }),
3038
- async execute(_toolCallId, params) {
3039
- if (isSubAgentBrokerMode && subAgentEnv) {
3040
- await withdrawPendingAsk(subAgentEnv, params.message_id);
3041
- return {
3042
- content: [{
3043
- type: "text",
3044
- text: `Cancelled ${params.message_id}`
3045
- }],
3046
- details: { cancelled: true }
3047
- };
3048
- }
3049
- const result = await cancelTool({ messageId: params.message_id });
3401
+ const result = await deps.workflowCancelTool({
3402
+ workflow_id: params.workflow_id,
3403
+ actor: params.actor,
3404
+ reason: params.reason,
3405
+ supervisor_id: params.supervisor_id
3406
+ });
3050
3407
  return {
3051
3408
  content: [{
3052
3409
  type: "text",
3053
- text: `Cancelled ${params.message_id}`
3410
+ text: JSON.stringify(result, null, 2)
3054
3411
  }],
3055
3412
  details: result
3056
3413
  };
@@ -3118,6 +3475,8 @@ function createExtension(deps = defaultDeps, options = {}) {
3118
3475
  supervisorId: params.supervisor_id,
3119
3476
  force: params.force
3120
3477
  });
3478
+ disposeHostProvider(params.supervisor_id);
3479
+ if (targetIsSingleton && target !== void 0) disposeHostProvider(void 0);
3121
3480
  return {
3122
3481
  content: [{
3123
3482
  type: "text",
@@ -3410,7 +3769,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3410
3769
  initialRunId: args?.trim() || void 0,
3411
3770
  deps: {
3412
3771
  ...createWatchDeps(),
3413
- followUpRun: (run, text) => followUpTrackedRun(run, text)
3772
+ resumeRun: (run, text) => resumeTrackedRun(run, text)
3414
3773
  },
3415
3774
  onTrackRun: (run, status) => {
3416
3775
  upsertTrackedRun(run, status, false);