@plurnk/plurnk-agui 3.1.0 → 4.0.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.
Files changed (74) hide show
  1. package/.env.defaults +17 -8
  2. package/README.md +33 -13
  3. package/SPEC.md +589 -268
  4. package/dist/AguiPlus.d.ts +24 -29
  5. package/dist/AguiPlus.d.ts.map +1 -1
  6. package/dist/AguiPlus.js +25 -36
  7. package/dist/AguiPlus.js.map +1 -1
  8. package/dist/AguiSurface.d.ts +1 -2
  9. package/dist/AguiSurface.d.ts.map +1 -1
  10. package/dist/AguiSurface.js +23 -16
  11. package/dist/AguiSurface.js.map +1 -1
  12. package/dist/AuthorizationDocuments.d.ts +33 -0
  13. package/dist/AuthorizationDocuments.d.ts.map +1 -0
  14. package/dist/AuthorizationDocuments.js +84 -0
  15. package/dist/AuthorizationDocuments.js.map +1 -0
  16. package/dist/BuiltinActions.d.ts +11 -5
  17. package/dist/BuiltinActions.d.ts.map +1 -1
  18. package/dist/BuiltinActions.js +73 -125
  19. package/dist/BuiltinActions.js.map +1 -1
  20. package/dist/EventRouter.d.ts +11 -3
  21. package/dist/EventRouter.d.ts.map +1 -1
  22. package/dist/EventRouter.js +17 -32
  23. package/dist/EventRouter.js.map +1 -1
  24. package/dist/MessageAddress.d.ts +7 -5
  25. package/dist/MessageAddress.d.ts.map +1 -1
  26. package/dist/MessageAddress.js +14 -12
  27. package/dist/MessageAddress.js.map +1 -1
  28. package/dist/Module.d.ts +5 -4
  29. package/dist/Module.d.ts.map +1 -1
  30. package/dist/Module.js +221 -82
  31. package/dist/Module.js.map +1 -1
  32. package/dist/OAuthServer.d.ts +30 -0
  33. package/dist/OAuthServer.d.ts.map +1 -0
  34. package/dist/OAuthServer.js +607 -0
  35. package/dist/OAuthServer.js.map +1 -0
  36. package/dist/Portal.d.ts +39 -9
  37. package/dist/Portal.d.ts.map +1 -1
  38. package/dist/Portal.js +414 -141
  39. package/dist/Portal.js.map +1 -1
  40. package/dist/ProposalHitl.d.ts +17 -4
  41. package/dist/ProposalHitl.d.ts.map +1 -1
  42. package/dist/ProposalHitl.js +30 -43
  43. package/dist/ProposalHitl.js.map +1 -1
  44. package/dist/RunHandler.d.ts +18 -8
  45. package/dist/RunHandler.d.ts.map +1 -1
  46. package/dist/RunHandler.js +214 -96
  47. package/dist/RunHandler.js.map +1 -1
  48. package/dist/ThreadAddress.d.ts +8 -0
  49. package/dist/ThreadAddress.d.ts.map +1 -0
  50. package/dist/ThreadAddress.js +18 -0
  51. package/dist/ThreadAddress.js.map +1 -0
  52. package/dist/Translator.d.ts +52 -2
  53. package/dist/Translator.d.ts.map +1 -1
  54. package/dist/Translator.js +390 -117
  55. package/dist/Translator.js.map +1 -1
  56. package/dist/client.d.ts +2 -0
  57. package/dist/client.d.ts.map +1 -0
  58. package/dist/client.js +2 -0
  59. package/dist/client.js.map +1 -0
  60. package/dist/config.d.ts +22 -2
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +65 -13
  63. package/dist/config.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/run-events.d.ts +1 -0
  69. package/dist/run-events.d.ts.map +1 -1
  70. package/dist/run-events.js +4 -2
  71. package/dist/run-events.js.map +1 -1
  72. package/dist/types.d.ts +4 -22
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +12 -8
package/dist/Portal.js CHANGED
@@ -4,28 +4,29 @@
4
4
  // the seam, and route standard resume entries to resolveProposal. Transport-agnostic —
5
5
  // the HTTP/SSE listener (the outward edge) and workspace establishment (workspace-lifecycle
6
6
  // hook, pending) wrap this; the engine is testable against a mock seam today.
7
- var _a;
8
7
  import EventRouter from "./EventRouter.js";
9
- import { aliveChildren, descendantsState, stateDelta } from "./AguiPlus.js";
10
- import { Problems, selectWorkerLoop } from "@plurnk/plurnk-contracts";
8
+ import { aliveChildren, spendState, stateDelta } from "./AguiPlus.js";
9
+ import { Problems, Validator, selectWorkerLoop } from "@plurnk/plurnk-contracts";
11
10
  import { HttpProblemError } from "./action-results.js";
12
11
  import { runErrorEvents, serviceStoppingProblem } from "./run-events.js";
12
+ import Translator, {} from "./Translator.js";
13
13
  import ProposalHitl, {} from "./ProposalHitl.js";
14
14
  import { EventType } from "./types.js";
15
- class Portal {
15
+ export default class Portal {
16
16
  #seam;
17
17
  // A workspace may have several simultaneous AG-UI Runs. The worker/loop carried by
18
18
  // each notification selects its owner; a Run is not a second broadcast subscription.
19
19
  #threads = new Map();
20
20
  #hitl;
21
- #activeInterrupts = new Map();
22
21
  #continuations = new Map();
23
22
  #deliveryTails = new Map();
23
+ // {§agui-subagents} The loops whose terminal the daemon has published and the Runs have yet to receive, in order.
24
+ #terminalsDue = new Set();
24
25
  #off = null;
25
26
  #stopped = false;
26
27
  constructor(seam) {
27
28
  this.#seam = seam;
28
- this.#hitl = new ProposalHitl(seam, (workspaceId, delivery) => this.#routeHitl(workspaceId, delivery));
29
+ this.#hitl = new ProposalHitl(seam, (workspaceId, delivery) => this.#enqueueDelivery(workspaceId, async () => this.#routeHitl(workspaceId, await delivery)));
29
30
  }
30
31
  // One subscription for the whole module: render each event only to its owning Run.
31
32
  start() {
@@ -33,25 +34,44 @@ class Portal {
33
34
  this.#off = this.#seam.subscribeToEvents((workspaceId, method, params) => {
34
35
  if (workspaceId === null)
35
36
  return; // global (workspace/created) handled out-of-band
36
- // {§agui-proposal-resolve} A descendant gate may require asynchronous
37
- // topology lookup. A later terminal cannot close its controlling Run first.
38
- if (method === "loop/terminated" && this.#deliveryTails.has(workspaceId)) {
37
+ if (method === "loop/terminated")
38
+ this.#terminalsDue.add(params.loopId);
39
+ // {§agui-proposal-resolve} {§agui-subagents} A workspace's events reach its Runs in the order the daemon
40
+ // published them: while a descendant's topology or loop is being read, every later event waits for it.
41
+ if (this.#deliveryTails.has(workspaceId)) {
39
42
  this.#enqueueDelivery(workspaceId, async () => this.#routeNotification(workspaceId, method, params));
40
43
  return;
41
44
  }
42
- this.#routeNotification(workspaceId, method, params);
45
+ const observing = this.#routeNotification(workspaceId, method, params);
46
+ if (observing !== undefined)
47
+ this.#enqueueDelivery(workspaceId, async () => observing);
43
48
  });
44
49
  }
50
+ // Returns the descendants' events still being read for some Run's presentation, when there are any.
45
51
  #routeNotification(workspaceId, method, params) {
52
+ if (method === "loop/terminated")
53
+ this.#terminalsDue.delete(params.loopId);
54
+ const observing = [];
46
55
  for (const thread of this.#threads.get(workspaceId) ?? []) {
47
- if (_a.#touchesChildren(thread, method, params))
48
- void this.#refreshChildren(workspaceId, thread);
49
- if (thread.descendants && _a.#observedActor(thread, method, params) !== null) {
50
- this.#observeDescendant(workspaceId, thread, method, params);
56
+ if (Portal.#touchesStatus(thread, method, params))
57
+ void this.#refreshStatus(thread);
58
+ const actor = thread.descendants ? Portal.#observedActor(thread, method, params) : null;
59
+ if (actor !== null) {
60
+ observing.push(this.#observeDescendant(workspaceId, thread, actor, method, params));
51
61
  continue;
52
62
  }
53
- if (!_a.#ownsNotification(thread, method, params))
63
+ if (thread.descendants && method === "loop/terminated") {
64
+ const concluded = Portal.#concluded(thread, params);
65
+ if (concluded !== null) {
66
+ if (concluded.length > 0)
67
+ thread.emit(concluded);
68
+ continue;
69
+ }
70
+ }
71
+ if (!Portal.#ownsNotification(thread, method, params)) {
72
+ Portal.#holdUnannounced(thread, method, params);
54
73
  continue;
74
+ }
55
75
  if (method === "loop/packet" && thread.loopId === null) {
56
76
  const loopId = params.loopId;
57
77
  if (typeof loopId === "number")
@@ -70,40 +90,43 @@ class Portal {
70
90
  }
71
91
  // {§agui-broadcast-fan} — the row that announces an execution owns its stream for this Run
72
92
  // before any channel write: a stream/event is a race the action result must not win.
73
- const opened = _a.#openedStream(method, params);
93
+ const opened = Portal.#openedStream(method, params);
74
94
  if (opened !== null)
75
95
  thread.openStreams.add(opened);
76
96
  if (method === "stream/concluded")
77
- thread.openStreams.delete(_a.#streamTarget(params));
78
- const out = thread.router.route(method, params);
97
+ thread.openStreams.delete(Portal.#streamTarget(params));
98
+ const out = Portal.#finishing(thread, thread.router.route(method, params));
79
99
  if (out.length > 0)
80
100
  thread.emit(out);
101
+ if (method === "log/entry" && opened !== null)
102
+ this.#releaseUnannounced(thread, opened);
81
103
  // {§agui-gate-deferral} — a terminal leaves the held gate durable for the next Run; any other
82
104
  // event that closed the reasoning lifecycle presents it now.
83
105
  if (method === "loop/terminated")
84
106
  thread.deferredDelivery = null;
85
107
  else
86
108
  this.#presentDeferred(workspaceId, thread);
87
- if (method === "stream/concluded" && thread.openStreams.size === 0 && thread.deferredFinish !== null) {
109
+ if (thread.openStreams.size === 0 && thread.deferredFinish !== null) {
88
110
  const deferred = thread.deferredFinish;
89
111
  thread.deferredFinish = null;
90
- this.#finishThread(thread, deferred);
112
+ this.#finishThread(thread, deferred.events, deferred.result);
91
113
  }
92
114
  }
115
+ return observing.length === 0 ? undefined : Promise.all(observing).then(() => undefined);
93
116
  }
94
- // {§agui-status-children} {§agui-status-descendants} — the events after which the bound Worker's
95
- // alive direct children or its descendants' spend may differ: another worker's loop concluded or
96
- // began (it may be a child), another worker's turn settled its spend, or the bound Worker's own
117
+ // {§agui-status-children} {§agui-status-spend} — the events after which the bound Worker's alive
118
+ // direct children or its spend may differ: another worker's loop concluded or began (it may be a
119
+ // child), a turn settled its spend (its own or another worker's), or the bound Worker's own
97
120
  // WORK/FORK/KILL row landed (it spawned or killed one). The daemon is asked, never inferred.
98
- static #touchesChildren(thread, method, params) {
99
- if (thread.notificationScope !== "conversation")
121
+ static #touchesStatus(thread, method, params) {
122
+ if (thread.notificationScope !== "worker")
100
123
  return false;
101
124
  const payload = params;
102
125
  if (method === "loop/terminated" || method === "loop/packet") {
103
126
  return typeof payload.workerId === "number" && payload.workerId !== thread.workerId;
104
127
  }
105
128
  if (method === "notice/event") {
106
- return typeof payload.workerId === "number" && payload.workerId !== thread.workerId
129
+ return typeof payload.workerId === "number"
107
130
  && payload.notice?.source === "engine:turn" && payload.notice.kind === "turn_generated";
108
131
  }
109
132
  if (method === "log/entry") {
@@ -112,27 +135,46 @@ class Portal {
112
135
  }
113
136
  return false;
114
137
  }
115
- async #refreshChildren(workspaceId, thread) {
138
+ // Publish only what differs from the gauge this Run's client holds, and nothing before its snapshot.
139
+ async #refreshStatus(thread) {
140
+ if (thread.gauge === null) {
141
+ thread.staleGauge = true;
142
+ return;
143
+ }
144
+ const workerId = thread.workerId;
145
+ if (workerId === null)
146
+ return;
147
+ const refresh = ++thread.gaugeRefresh;
116
148
  const [rows, accounting] = await Promise.all([
117
- this.#seam.listWorkers(workspaceId, { parentWorkerId: thread.workerId }),
118
- this.#seam.descendantAccounting({ workspaceId, workerId: thread.workerId, loopId: thread.loopId }),
149
+ this.#seam.listWorkers(thread.workspaceId, { parentWorkerId: workerId }),
150
+ this.#seam.workerAccounting({ workspaceId: thread.workspaceId, workerId }),
119
151
  ]);
120
- if (!(this.#threads.get(workspaceId)?.has(thread) ?? false))
152
+ if (refresh !== thread.gaugeRefresh || thread.gauge === null)
153
+ return;
154
+ if (!(this.#threads.get(thread.workspaceId)?.has(thread) ?? false))
121
155
  return;
122
156
  const delta = [];
123
157
  const children = aliveChildren(rows);
124
- if (children !== thread.children) {
125
- thread.children = children;
158
+ if (children !== thread.gauge.children) {
126
159
  delta.push({ op: "replace", path: "/plurnk/status/children", value: children });
127
160
  }
128
- const delegation = descendantsState(accounting);
129
- if (thread.delegation === null || JSON.stringify(delegation) !== JSON.stringify(thread.delegation)) {
130
- thread.delegation = delegation;
131
- delta.push({ op: "replace", path: "/plurnk/status/descendants", value: delegation });
161
+ const spend = spendState(accounting);
162
+ if (JSON.stringify(spend) !== JSON.stringify(thread.gauge.spend)) {
163
+ delta.push({ op: "replace", path: "/plurnk/status/spend", value: spend });
132
164
  }
165
+ thread.gauge = { children, spend };
133
166
  if (delta.length > 0)
134
167
  thread.emit([stateDelta(delta)]);
135
168
  }
169
+ // The client now holds this status. A refresh asked for before, or racing, its reads runs after it.
170
+ #presentGauge(thread, status) {
171
+ const stale = thread.staleGauge || thread.gauge !== null;
172
+ thread.gauge = { children: status.children, spend: status.spend };
173
+ thread.staleGauge = false;
174
+ thread.gaugeRefresh += 1;
175
+ if (stale)
176
+ void this.#refreshStatus(thread);
177
+ }
136
178
  // {§agui-delegation-observation} — the actor of a row or stream event that is neither the bound
137
179
  // Worker nor a known stranger; null for everything else, so terminals, packets, reasoning and
138
180
  // notices stay the bound Worker's alone.
@@ -145,40 +187,167 @@ class Portal {
145
187
  return null;
146
188
  return actor;
147
189
  }
148
- // Descendants' events ride the workspace's delivery chain: ordered among themselves, introduced
149
- // once each from the daemon's topology, and never touching this Run's step, streams or terminal.
150
- #observeDescendant(workspaceId, thread, method, params) {
151
- const actor = _a.#observedActor(thread, method, params);
152
- if (actor === null)
190
+ // A descendant's event is introduced once per actor from the daemon's topology, and never touches this Run's step,
191
+ // streams or terminal. The workspace's later events wait for it ({§agui-subagents}).
192
+ async #observeDescendant(workspaceId, thread, actor, method, params) {
193
+ let node = thread.tree?.get(actor);
194
+ if (node === undefined && thread.workerId !== null) {
195
+ thread.tree = await this.#descendantTree(workspaceId, thread.workerId);
196
+ node = thread.tree.get(actor);
197
+ }
198
+ if (!(this.#threads.get(workspaceId)?.has(thread) ?? false))
153
199
  return;
154
- this.#enqueueDelivery(workspaceId, async () => {
155
- if (!(this.#threads.get(workspaceId)?.has(thread) ?? false))
156
- return;
157
- let node = thread.tree?.get(actor);
158
- if (node === undefined) {
159
- thread.tree = await this.#descendantTree(workspaceId, thread.workerId);
160
- node = thread.tree.get(actor);
161
- }
162
- if (node === undefined) {
163
- thread.unrelated.add(actor);
164
- return;
165
- }
166
- if (!thread.introduced.has(actor)) {
167
- thread.introduced.add(actor);
168
- thread.emit([{ type: EventType.CUSTOM, name: "plurnk.descendant", value: node }]);
200
+ if (node === undefined) {
201
+ thread.unrelated.add(actor);
202
+ return;
203
+ }
204
+ const subagent = method === "log/entry" ? await this.#rowSubagent(thread, node, params.entry.loop_id) : null;
205
+ if (!(this.#threads.get(workspaceId)?.has(thread) ?? false))
206
+ return;
207
+ // {§agui-delegation-observation} A subagent's start introduces its worker; any other descendant is introduced once
208
+ // before its first event.
209
+ const introduction = thread.introduced.has(actor) || subagent !== null ? []
210
+ : [{ type: EventType.CUSTOM, name: "plurnk.descendant", value: node }];
211
+ thread.introduced.add(actor);
212
+ const out = subagent === null
213
+ ? [...introduction, ...thread.router.route(method, params)]
214
+ : [...Portal.#present(subagent), ...subagent.translator.logEntry(params)];
215
+ if (out.length > 0)
216
+ thread.emit(out);
217
+ }
218
+ // {§agui-subagents} The subagent a descendant's row belongs to, when a call in view opened its loop.
219
+ async #rowSubagent(thread, node, loopId) {
220
+ const judged = thread.judged.get(loopId);
221
+ if (judged !== undefined)
222
+ return judged;
223
+ const loop = (await this.#seam.listWorkerLoops({ workspaceId: thread.workspaceId, workerId: node.workerId })).find(({ id }) => id === loopId);
224
+ if (loop !== undefined)
225
+ return this.#judge(thread, node, loop);
226
+ const unlisted = Promise.resolve(null);
227
+ thread.judged.set(loopId, unlisted);
228
+ return unlisted;
229
+ }
230
+ // {§agui-subagents} A descendant loop is a subagent of this Run when a call in view opened it: the bound Worker's,
231
+ // or one a subagent of this Run made. Each loop is judged once, from the daemon's record of what opened it, and the
232
+ // live and snapshot projections share the judgment.
233
+ #judge(thread, node, loop) {
234
+ let judged = thread.judged.get(loop.id);
235
+ if (judged === undefined) {
236
+ judged = this.#judgeLoop(thread, node, loop);
237
+ thread.judged.set(loop.id, judged);
238
+ }
239
+ return judged;
240
+ }
241
+ async #judgeLoop(thread, node, loop) {
242
+ const parent = loop.invocation === null ? null : await this.#opener(thread, loop.invocation);
243
+ if (parent === null)
244
+ return null;
245
+ const runId = `ops://${node.name}/${loop.sequence}`;
246
+ const subagent = {
247
+ runId, loopId: loop.id, workerId: node.workerId, worker: node.name, descendant: node, loopSequence: loop.sequence, parent,
248
+ translator: thread.router.subagentTranslator({ modelWorkerId: node.workerId, subagent: { runId, worker: node.name } }),
249
+ // A conclusion still on its way to this Run ends the segment when it arrives.
250
+ result: this.#terminalsDue.has(loop.id) ? null : loop.terminalResult,
251
+ segment: "unstarted",
252
+ };
253
+ thread.subagents.set(loop.id, subagent);
254
+ return subagent;
255
+ }
256
+ // {§agui-subagents} The call that opened a loop, as this Run's client identifies it, when this Run sees it.
257
+ async #opener(thread, invocation) {
258
+ const call = Translator.callOf(invocation.address);
259
+ if (invocation.workerId === thread.workerId)
260
+ return { call };
261
+ let node = thread.tree?.get(invocation.workerId);
262
+ if (node === undefined && thread.workerId !== null) {
263
+ thread.tree = await this.#descendantTree(thread.workspaceId, thread.workerId);
264
+ node = thread.tree.get(invocation.workerId);
265
+ }
266
+ if (node === undefined)
267
+ return null;
268
+ const loopSequence = Number(call.split("/")[0]);
269
+ const loop = (await this.#seam.listWorkerLoops({ workspaceId: thread.workspaceId, workerId: node.workerId })).find(({ sequence }) => sequence === loopSequence);
270
+ const subagent = loop === undefined ? null : await this.#judge(thread, node, loop);
271
+ return subagent === null ? null : { call: `${Translator.subagentIds(subagent.worker)}#${call}`, subagent };
272
+ }
273
+ // {§agui-subagents} A subagent's segment of this Run starts the first time the Run presents its work, after its
274
+ // parent's; a loop that concluded before the Run could see it ends it at once.
275
+ static #present(subagent) {
276
+ if (subagent.segment !== "unstarted")
277
+ return [];
278
+ const parent = subagent.parent.subagent;
279
+ const events = parent === undefined ? [] : Portal.#present(parent);
280
+ subagent.segment = "open";
281
+ events.push({
282
+ type: EventType.SUBAGENT_STARTED, subagentRunId: subagent.runId, name: subagent.worker, parentToolCallId: subagent.parent.call,
283
+ ...(parent === undefined ? {} : { parentSubagentRunId: parent.runId }),
284
+ metadata: { plurnk: { ...subagent.descendant } },
285
+ });
286
+ if (subagent.result !== null)
287
+ events.push(...Portal.#end(subagent, subagent.result));
288
+ return events;
289
+ }
290
+ // {§agui-subagents} A loop that completed finishes its subagent with its exact result; any other conclusion, a stop
291
+ // included, is the subagent's error, from its Problem Details.
292
+ static #end(subagent, result) {
293
+ subagent.segment = "ended";
294
+ const end = result.status < 400
295
+ ? { type: EventType.SUBAGENT_FINISHED, subagentRunId: subagent.runId, outcome: { type: "success" }, result }
296
+ : { type: EventType.SUBAGENT_ERROR, subagentRunId: subagent.runId, message: result.problem.detail, code: result.problem.type };
297
+ return [...subagent.translator.finish(), end];
298
+ }
299
+ // {§agui-subagents} A subagent's loop concluded: an open segment ends now, an unstarted one when it is presented;
300
+ // null for a loop that is no subagent of this Run.
301
+ static #concluded(thread, terminal) {
302
+ const subagent = thread.subagents.get(terminal.loopId);
303
+ if (subagent === undefined)
304
+ return null;
305
+ subagent.result = Validator.assertOperationResult(terminal.result);
306
+ return subagent.segment === "open" ? Portal.#end(subagent, subagent.result) : [];
307
+ }
308
+ // {§agui-subagents} The bound Worker's events, preceded by the end of every subagent segment when they finish the Run.
309
+ static #finishing(thread, events) {
310
+ return events.some(({ type }) => type === EventType.RUN_FINISHED) ? [...Portal.#closeSubagents(thread), ...events] : events;
311
+ }
312
+ // {§agui-subagents} A Run that finishes ends every segment it opened: a subagent still running is suspended, its
313
+ // work going on for a later Run to present, and each subagent's open step closes first.
314
+ static #closeSubagents(thread) {
315
+ return [...thread.subagents.values()].filter(({ segment }) => segment !== "unstarted").flatMap((subagent) => {
316
+ const events = subagent.translator.finish();
317
+ if (subagent.segment === "open") {
318
+ subagent.segment = "ended";
319
+ events.push({ type: EventType.SUBAGENT_FINISHED, subagentRunId: subagent.runId, outcome: { type: "suspended" } });
169
320
  }
170
- const out = thread.router.route(method, params);
171
- if (out.length > 0)
172
- thread.emit(out);
321
+ return events;
173
322
  });
174
323
  }
324
+ // {§agui-snapshot} {§agui-subagents} The subagent runs a snapshot replays: each descendant loop a call in view opened,
325
+ // with the rows `history` reads from its worker's log.
326
+ async subagentHistory(thread, history) {
327
+ const bound = thread;
328
+ if (!bound.descendants || bound.workerId === null)
329
+ return [];
330
+ bound.tree = await this.#descendantTree(bound.workspaceId, bound.workerId);
331
+ const parts = [];
332
+ for (const node of [...bound.tree.values()].toSorted((left, right) => left.depth - right.depth)) {
333
+ const loops = await this.#seam.listWorkerLoops({ workspaceId: bound.workspaceId, workerId: node.workerId });
334
+ const subagents = (await Promise.all(loops.map((loop) => this.#judge(bound, node, loop))))
335
+ .filter((subagent) => subagent !== null);
336
+ if (subagents.length === 0)
337
+ continue;
338
+ const rows = await history(node.workerId);
339
+ for (const subagent of subagents)
340
+ parts.push({ subagent, entries: rows.filter(({ loop_id }) => loop_id === subagent.loopId) });
341
+ }
342
+ return parts;
343
+ }
175
344
  async #descendantTree(workspaceId, rootId) {
176
- const byId = _a.#workerMap(await this.#seam.listWorkers(workspaceId));
345
+ const byId = Portal.#workerMap(await this.#seam.listWorkers(workspaceId));
177
346
  const tree = new Map();
178
347
  for (const candidate of byId.values()) {
179
348
  if (candidate.id === rootId || candidate.parentWorkerId === null)
180
349
  continue;
181
- const depth = _a.#ancestors(candidate.id, byId).indexOf(rootId) + 1;
350
+ const depth = Portal.#ancestors(candidate.id, byId).indexOf(rootId) + 1;
182
351
  if (depth > 0)
183
352
  tree.set(candidate.id, { workerId: candidate.id, name: candidate.name, parentWorkerId: candidate.parentWorkerId, depth });
184
353
  }
@@ -195,7 +364,7 @@ class Portal {
195
364
  const attrs = entry.attrs;
196
365
  return typeof attrs?.stream === "string" && attrs.detached !== true ? attrs.stream : null;
197
366
  }
198
- return method === "stream/event" ? _a.#streamTarget(params) : null;
367
+ return method === "stream/event" ? Portal.#streamTarget(params) : null;
199
368
  }
200
369
  static #streamTarget(params) {
201
370
  const target = params.target;
@@ -203,28 +372,67 @@ class Portal {
203
372
  throw new Error(`A stream notification names no target: ${JSON.stringify(params)}`);
204
373
  return target;
205
374
  }
375
+ // {§agui-broadcast-fan} An operation Run shares its runtime Worker with every other client operation and the
376
+ // daemon's own work, so it owns exactly its Loop: that Loop's rows and notices, and the streams those rows
377
+ // announced. Its action result settles it, never the Loop's terminal.
206
378
  static #ownsNotification(thread, method, params) {
207
379
  if (thread.notificationScope === "result")
208
380
  return false;
381
+ const operation = thread.notificationScope === "operation";
209
382
  const payload = params;
210
383
  const owns = (workerId, loopId) => typeof workerId === "number"
211
384
  && workerId === thread.workerId
212
- && (thread.loopId === null || typeof loopId !== "number" || loopId === thread.loopId);
385
+ && (operation
386
+ ? thread.loopId !== null && loopId === thread.loopId
387
+ : thread.loopId === null || typeof loopId !== "number" || loopId === thread.loopId);
213
388
  switch (method) {
214
389
  case "workspace/preparation": return true;
215
390
  case "log/entry": return owns(payload.entry?.worker_id, payload.entry?.loop_id);
216
- case "loop/terminated":
391
+ case "loop/terminated": return !operation && owns(payload.workerId, payload.loopId);
217
392
  case "loop/packet":
218
393
  case "reasoning/event":
219
394
  case "outside/event": return owns(payload.workerId, payload.loopId);
220
395
  case "notice/event": return payload.workerId === null
221
- ? thread.notificationScope === "conversation"
396
+ ? thread.notificationScope === "worker"
222
397
  : owns(payload.workerId, payload.loopId);
223
398
  case "stream/event":
224
- case "stream/concluded": return owns(payload.workerId);
399
+ case "stream/concluded": return operation
400
+ ? payload.workerId === thread.workerId && thread.openStreams.has(Portal.#streamTarget(params))
401
+ : owns(payload.workerId);
225
402
  default: return false;
226
403
  }
227
404
  }
405
+ // A stream of an operation Run's Worker that no row of its Loop has announced: it may be this Run's own
406
+ // execution whose row is still to arrive, so its latest snapshot and conclusion wait for that row.
407
+ static #holdUnannounced(thread, method, params) {
408
+ if (thread.notificationScope !== "operation" || thread.loopId === null)
409
+ return;
410
+ if (method !== "stream/event" && method !== "stream/concluded")
411
+ return;
412
+ if (params.workerId !== thread.workerId)
413
+ return;
414
+ const target = Portal.#streamTarget(params);
415
+ const held = thread.unannounced.get(target) ?? { event: null, concluded: null };
416
+ thread.unannounced.set(target, method === "stream/event" ? { ...held, event: params } : { ...held, concluded: params });
417
+ }
418
+ // The row that announces a stream delivers what it held, in order; a stream already concluded stays closed.
419
+ #releaseUnannounced(thread, target) {
420
+ const held = thread.unannounced.get(target);
421
+ if (held === undefined)
422
+ return;
423
+ thread.unannounced.delete(target);
424
+ if (held.event !== null) {
425
+ const out = thread.router.route("stream/event", held.event);
426
+ if (out.length > 0)
427
+ thread.emit(out);
428
+ }
429
+ if (held.concluded !== null) {
430
+ thread.openStreams.delete(target);
431
+ const out = thread.router.route("stream/concluded", held.concluded);
432
+ if (out.length > 0)
433
+ thread.emit(out);
434
+ }
435
+ }
228
436
  stop() {
229
437
  if (this.#stopped)
230
438
  return;
@@ -242,7 +450,7 @@ class Portal {
242
450
  this.#threads.clear();
243
451
  }
244
452
  #emitDelivery(workspaceId, delivery, threads) {
245
- if (delivery.batch.events.length === 0)
453
+ if (delivery.batch.gates.length === 0)
246
454
  return;
247
455
  for (const thread of threads) {
248
456
  const controlLoopId = thread.loopId
@@ -253,6 +461,9 @@ class Portal {
253
461
  thread.deferredDelivery ??= delivery;
254
462
  continue;
255
463
  }
464
+ // {§agui-proposal-resolve} Each gate's call reaches this Run before its interrupt, then the continuation records
465
+ // it as held, so the Run that resumes folds the settlement onto the call its client holds.
466
+ const shaped = delivery.batch.gates.map((gate) => ({ gate, ...Portal.#concerning(thread, gate) }));
256
467
  const paused = thread.router.interrupt();
257
468
  const continuation = {
258
469
  cancelOnDisconnect: thread.cancelOnDisconnect,
@@ -260,27 +471,39 @@ class Portal {
260
471
  owner: delivery.recipient,
261
472
  gate: { workerId: delivery.workerId, loopId: delivery.loopId },
262
473
  binding: { workerId: thread.workerId, loopId: controlLoopId },
474
+ modelWorkerId: thread.modelWorkerId,
263
475
  threadId: thread.threadId,
264
- notificationScope: thread.notificationScope,
265
476
  state: {
266
477
  ...paused.continuation,
267
478
  currentTurn: paused.continuation.currentTurn
268
479
  ?? (thread.workerId === delivery.workerId ? delivery.turnId : null),
269
480
  },
270
481
  };
271
- const interrupts = delivery.batch.interrupts.map((original) => {
272
- const interrupt = { ...original, id: crypto.randomUUID() };
273
- this.#continuations.set(interrupt.id, {
274
- toolCallId: original.toolCallId ?? original.id,
275
- continuation,
276
- });
277
- return interrupt;
278
- });
279
- this.#withHitl({ events: delivery.batch.events, interrupts }, () => {
280
- thread.emit([...delivery.batch.events, ...paused.events]);
482
+ const interrupts = shaped.map(({ gate, concerns }) => {
483
+ const id = crypto.randomUUID();
484
+ this.#continuations.set(id, { key: gate.key, kind: gate.kind, continuation });
485
+ return { ...gate.interrupt, id, ...concerns };
281
486
  });
487
+ // {§agui-subagents} The interrupt finishes the Run, so its subagents' segments end before it.
488
+ thread.emit([...delivery.batch.events, ...shaped.flatMap(({ events }) => events), ...paused.events, ...Portal.#closeSubagents(thread)], interrupts);
282
489
  }
283
490
  }
491
+ // {§agui-proposal-resolve} The call a gate's interrupt names on this Run, and the events that present it: the operation's
492
+ // own call, its worker's or an open subagent's, sent unless this Run's client holds it; none on a Run whose conversation
493
+ // holds no call of that worker.
494
+ static #concerning(thread, gate) {
495
+ if (gate.call === undefined)
496
+ return { concerns: {}, events: [] };
497
+ const { workerId, loopId, coordinate, entry } = gate.call;
498
+ if (thread.workerId === workerId)
499
+ return { concerns: { toolCallId: coordinate }, events: thread.router.gateCall(entry) };
500
+ const subagent = thread.subagents.get(loopId);
501
+ if (subagent?.workerId === workerId && subagent.segment === "open") {
502
+ return { concerns: { toolCallId: `${Translator.subagentIds(subagent.worker)}#${coordinate}`, subagentRunId: subagent.runId },
503
+ events: subagent.translator.gateCall(entry) };
504
+ }
505
+ return { concerns: {}, events: [] };
506
+ }
284
507
  #presentDeferred(workspaceId, thread) {
285
508
  const delivery = thread.deferredDelivery;
286
509
  if (delivery === null || thread.router.reasoningOpen)
@@ -288,25 +511,13 @@ class Portal {
288
511
  thread.deferredDelivery = null;
289
512
  this.#emitDelivery(workspaceId, delivery, [thread]);
290
513
  }
291
- #withHitl(batch, emit) {
292
- for (const interrupt of batch.interrupts) {
293
- this.#activeInterrupts.set(interrupt.toolCallId ?? interrupt.id, interrupt);
294
- }
295
- try {
296
- emit(batch.events);
297
- }
298
- finally {
299
- for (const interrupt of batch.interrupts) {
300
- this.#activeInterrupts.delete(interrupt.toolCallId ?? interrupt.id);
301
- }
302
- }
303
- }
514
+ // {§agui-worker-owner} A gate reaches its owner's Runs that observe a worker: the one bound to its worker, else
515
+ // every one. An operation or result Run never presents one.
304
516
  #routeHitl(workspaceId, delivery) {
305
517
  const threads = [...(this.#threads.get(workspaceId) ?? [])]
306
- .filter((thread) => thread.owner === delivery.recipient && thread.notificationScope !== "result");
518
+ .filter((thread) => thread.owner === delivery.recipient && thread.notificationScope === "worker");
307
519
  const exact = threads.filter((thread) => thread.workerId === delivery.workerId);
308
- const recipients = exact.length > 0 ? exact : threads.filter((thread) => thread.notificationScope === "conversation");
309
- this.#emitDelivery(workspaceId, delivery, recipients);
520
+ this.#emitDelivery(workspaceId, delivery, exact.length > 0 ? exact : threads);
310
521
  }
311
522
  #enqueueDelivery(workspaceId, task) {
312
523
  const prior = this.#deliveryTails.get(workspaceId) ?? Promise.resolve();
@@ -358,16 +569,22 @@ class Portal {
358
569
  .filter(({ terminatedAt, terminalResult }) => terminatedAt === null && terminalResult === null));
359
570
  return active?.id ?? null;
360
571
  }
361
- interruptForToolCall(toolCallId) {
362
- return this.#activeInterrupts.get(toolCallId) ?? null;
572
+ // {§agui-action-binding} The model worker the Run that presented these interrupts bound, which their
573
+ // resume binds in turn; undefined when it bound none or the set names no one presentation of this thread.
574
+ resumedWorker(workspaceId, threadId, resume) {
575
+ const continuations = resume.map(({ interruptId }) => this.#continuations.get(interruptId)?.continuation);
576
+ const first = continuations[0];
577
+ if (first === undefined || continuations.some((candidate) => candidate !== first))
578
+ return undefined;
579
+ if (first.workspaceId !== workspaceId || first.threadId !== threadId)
580
+ return undefined;
581
+ return first.modelWorkerId ?? undefined;
363
582
  }
364
583
  // Bind a client's SSE to a workspace and AG-UI Run. The emit consumer ends its stream when it
365
584
  // sees RUN_FINISHED / RUN_ERROR (the router's terminal projection) — the engine
366
- // owns notification routing; the edge owns the socket lifecycle. `workerId` is the lifecycle actor
367
- // (client worker for client ops, conversation worker otherwise); `modelWorkerId`
368
- // binds the render (null → the router lazily
369
- // adopts the first model-origin row's worker — a fresh workspace's model worker is born
370
- // at the drain).
585
+ // owns notification routing; the edge owns the socket lifecycle. `workerId` is the Worker the Run observes,
586
+ // null for one that observes none or, for an operation Run, until bindOperation; `modelWorkerId` is the model
587
+ // worker the Run binds and renders, null when it binds none ({§agui-action-binding}).
371
588
  openThread(args) {
372
589
  if (this.#stopped)
373
590
  throw new HttpProblemError(serviceStoppingProblem());
@@ -380,21 +597,26 @@ class Portal {
380
597
  const continuation = restored?.state;
381
598
  const router = new EventRouter({
382
599
  threadId: args.threadId,
600
+ ...(args.replyThreads === undefined ? {} : { replyThreads: args.replyThreads }),
383
601
  runId: args.inputRunId ?? String(args.workerId),
384
602
  modelWorkerId: args.modelWorkerId ?? null,
385
603
  workspaceId: args.workspaceId,
386
604
  ...(continuation === undefined ? {} : { continuation }),
387
605
  });
388
606
  const t = {
607
+ workspaceId: args.workspaceId,
389
608
  owner: args.owner ?? null,
609
+ family: args.family ?? null,
390
610
  workerId: restored?.binding.workerId ?? args.workerId,
391
611
  loopId: restored?.binding.loopId ?? null,
392
- notificationScope: restored?.notificationScope ?? args.notificationScope,
612
+ notificationScope: args.notificationScope,
613
+ modelWorkerId: args.modelWorkerId ?? null,
393
614
  router,
394
615
  emit: args.emit,
395
616
  threadId: args.threadId,
396
617
  inputRunId: args.inputRunId ?? String(args.workerId),
397
618
  openStreams: new Set(),
619
+ unannounced: new Map(),
398
620
  deferredFinish: null,
399
621
  pendingTerminations: [],
400
622
  resolvingInterrupts: args.resume !== undefined,
@@ -405,8 +627,11 @@ class Portal {
405
627
  tree: null,
406
628
  introduced: new Set(),
407
629
  unrelated: new Set(),
408
- children: null,
409
- delegation: null,
630
+ judged: new Map(),
631
+ subagents: new Map(),
632
+ gauge: null,
633
+ staleGauge: false,
634
+ gaugeRefresh: 0,
410
635
  };
411
636
  let set = this.#threads.get(args.workspaceId);
412
637
  if (set === undefined) {
@@ -417,55 +642,98 @@ class Portal {
417
642
  return t;
418
643
  }
419
644
  closeRun(workspaceId, t) { this.#threads.get(workspaceId)?.delete(t); }
645
+ // {§agui-run-disconnect} A message Run cancels its bound worker; an operation Run cancels exactly its own Loop,
646
+ // at once when known, else when its dispatch opens it.
420
647
  disconnect(thread) {
421
648
  const bound = thread;
422
649
  if (bound.disconnected)
423
650
  return;
424
651
  bound.disconnected = true;
425
652
  if (bound.cancelOnDisconnect)
426
- this.#seam.cancelDrain(bound.workerId, "client_disconnected");
653
+ this.#cancelOwned(bound);
427
654
  }
428
- runStarted(thread, state) {
429
- return thread.router.runStarted(state);
430
- }
431
- replay(thread, entries, currentUser) {
432
- return thread.router.replay(entries, currentUser);
655
+ #cancelOwned(thread) {
656
+ if (thread.notificationScope === "operation") {
657
+ const loopId = thread.loopId;
658
+ if (loopId === null)
659
+ return;
660
+ void this.#seam.cancelLoop({ workspaceId: thread.workspaceId, loopId, reason: "client_disconnected" }).catch((cause) => {
661
+ console.error(`AG-UI: cancelling client operation loop ${loopId} failed:`, cause);
662
+ });
663
+ return;
664
+ }
665
+ if (thread.workerId !== null)
666
+ this.#seam.cancelDrain(thread.workerId, "client_disconnected");
433
667
  }
434
- #finishThread(thread, events) {
435
- thread.emit([...events, ...thread.router.finish(), { type: EventType.RUN_FINISHED, threadId: thread.threadId, runId: thread.inputRunId, outcome: { type: "success" } }]);
668
+ // {§agui-broadcast-fan} An operation Run observes the Loop its dispatch opened, from before any statement runs.
669
+ bindOperation(thread, loop) {
670
+ const bound = thread;
671
+ bound.workerId = loop.workerId;
672
+ bound.loopId = loop.loopId;
673
+ if (bound.disconnected && bound.cancelOnDisconnect)
674
+ this.#cancelOwned(bound);
436
675
  }
437
- finishThread(thread, events) {
676
+ // {§agui-status-children} {§agui-status-spend} — the snapshot is the gauge the status deltas patch.
677
+ runStarted(thread, state) {
678
+ const bound = thread;
679
+ const events = bound.router.runStarted(state);
680
+ const status = state?.type === EventType.STATE_SNAPSHOT
681
+ ? state.snapshot.plurnk?.status
682
+ : undefined;
683
+ if (status !== undefined)
684
+ this.#presentGauge(bound, status);
685
+ return events;
686
+ }
687
+ // A whole-gauge replacement: the client's gauge is this status from here on.
688
+ replaceStatus(thread, status) {
689
+ this.#presentGauge(thread, status);
690
+ return [stateDelta([{ op: "replace", path: "/plurnk/status", value: status }])];
691
+ }
692
+ // {§agui-snapshot} {§agui-subagents} The conversation, then the subagent runs it holds, each presented.
693
+ replay(thread, entries, currentUser, subagents = []) {
694
+ return [
695
+ ...thread.router.replay(entries, currentUser, subagents.map(({ subagent, entries: rows }) => ({ translator: subagent.translator, entries: rows }))),
696
+ ...subagents.flatMap(({ subagent }) => Portal.#present(subagent)),
697
+ ];
698
+ }
699
+ #finishThread(thread, events, result) {
700
+ const finished = { type: EventType.RUN_FINISHED, threadId: thread.threadId, runId: thread.inputRunId, outcome: { type: "success" },
701
+ ...(result === undefined ? {} : { result }) };
702
+ thread.emit([...events, ...thread.router.finish(), ...Portal.#closeSubagents(thread), finished]);
703
+ }
704
+ // The Run finishes with `events`, and `result` as its result when it has one.
705
+ finishThread(thread, events, result) {
438
706
  const bound = thread;
439
707
  if (bound.openStreams.size > 0) {
440
- bound.deferredFinish = events;
708
+ bound.deferredFinish = { events, result };
441
709
  return;
442
710
  }
443
- this.#finishThread(bound, events);
711
+ this.#finishThread(bound, events, result);
444
712
  }
445
713
  failThread(thread, events) {
446
714
  const bound = thread;
447
- bound.emit([...bound.router.finish(), ...events]);
448
- }
449
- // Emit extra events + RUN_FINISHED through the resumed Run that owns the
450
- // original action's worker and client thread —
451
- // an action that paused on a proposal completes after the resume AG-UI Run rebound the
452
- // stream, so its result must ride whichever response is live now, never the
453
- // closure of the request that spawned it.
454
- finishRun(workspaceId, workerId, threadId, events) {
455
- for (const t of this.#threads.get(workspaceId) ?? []) {
456
- if (t.notificationScope === "result" || t.workerId !== workerId || t.threadId !== threadId)
457
- continue;
458
- this.finishThread(t, events);
715
+ bound.emit([...bound.router.fail(), ...events]);
716
+ }
717
+ // {§agui-oauth-revocation} — every Run admitted under the family ends with the given events; the
718
+ // loops those Runs observed continue, since a revoked observer is not a cancelling one.
719
+ failFamily(family, events) {
720
+ for (const threads of this.#threads.values()) {
721
+ for (const thread of threads) {
722
+ if (thread.family !== family)
723
+ continue;
724
+ this.failThread(thread, events);
725
+ threads.delete(thread);
726
+ }
459
727
  }
460
728
  }
461
729
  async #resurfaceControlled(workspaceId, thread) {
462
730
  if (thread.owner === null)
463
731
  return false;
464
732
  const deliveries = await this.#hitl.resurface(workspaceId, thread.owner);
465
- const pendingIds = new Set(deliveries.flatMap(({ batch }) => batch.interrupts.map(({ toolCallId, id }) => toolCallId ?? id)));
733
+ const pendingKeys = new Set(deliveries.flatMap(({ batch }) => batch.gates.map(({ key }) => key)));
466
734
  for (const [id, candidate] of this.#continuations) {
467
735
  if (candidate.continuation.workspaceId === workspaceId && candidate.continuation.owner === thread.owner
468
- && !pendingIds.has(candidate.toolCallId))
736
+ && !pendingKeys.has(candidate.key))
469
737
  this.#continuations.delete(id);
470
738
  }
471
739
  if (deliveries.length === 0)
@@ -476,7 +744,7 @@ class Portal {
476
744
  if (thread.loopId === null) {
477
745
  thread.loopId = delivery.workerId === thread.workerId
478
746
  ? delivery.loopId
479
- : await this.#activeLoopId(workspaceId, thread.workerId);
747
+ : thread.workerId === null ? null : await this.#activeLoopId(workspaceId, thread.workerId);
480
748
  }
481
749
  this.#emitDelivery(workspaceId, delivery, [thread]);
482
750
  return true;
@@ -487,17 +755,19 @@ class Portal {
487
755
  thread.pendingTerminations = [];
488
756
  if (terminal === undefined)
489
757
  return false;
490
- const out = thread.router.route("loop/terminated", terminal);
758
+ const out = Portal.#finishing(thread, thread.router.route("loop/terminated", terminal));
491
759
  if (out.length > 0)
492
760
  thread.emit(out);
493
761
  return true;
494
762
  }
495
- // Synchronize one AG-UI observer with the durable conversation. A pending
763
+ // Synchronize one AG-UI observer with the bound worker's durable log. A pending
496
764
  // stopped-world is re-presented first; otherwise an active Loop stays attached
497
- // and an idle conversation settles immediately. Returns true only while this
765
+ // and an idle worker settles immediately. Returns true only while this
498
766
  // thread remains attached to independently-owned live work.
499
767
  async synchronize(workspaceId, thread, waitForWork = false) {
500
768
  const bound = thread;
769
+ if (bound.workerId === null)
770
+ throw new Error("a synchronization Run reached the Portal without its worker");
501
771
  if (await this.#resurfaceControlled(workspaceId, bound))
502
772
  return false;
503
773
  const loop = selectWorkerLoop((await this.#seam.listWorkerLoops({ workspaceId, workerId: bound.workerId }))
@@ -515,10 +785,14 @@ class Portal {
515
785
  // Core before any pending stopped-world is re-presented.
516
786
  async run(thread, args) {
517
787
  const bound = thread;
788
+ // {§agui-snapshot} The submitting Run's client holds its own message; every other Run receives it.
789
+ const submitted = args.envelope?.message?.id;
790
+ if (typeof submitted === "string")
791
+ bound.router.submitted(submitted);
518
792
  const ack = await this.#seam.runLoop(args);
519
793
  if (bound.disconnected) {
520
794
  if (bound.cancelOnDisconnect)
521
- this.#seam.cancelDrain(bound.workerId, "client_disconnected");
795
+ this.#cancelOwned(bound);
522
796
  return null;
523
797
  }
524
798
  if (!this.#threads.get(args.workspaceId)?.has(bound))
@@ -548,23 +822,24 @@ class Portal {
548
822
  if (recorded.some((candidate) => candidate.continuation !== continuation)) {
549
823
  throw new HttpProblemError(Problems.create("agui:interrupt", "interrupt-binding-invalid", 400, "The resume combines interrupts from different Runs."));
550
824
  }
551
- const resolutions = entries.map((entry, index) => ({ ...entry, interruptId: recorded[index].toolCallId }));
825
+ const resolutions = entries.map((entry, index) => ({ ...entry, interruptId: recorded[index].key }));
552
826
  await this.#hitl.resolve(workspaceId, bound.owner, resolutions, (resolution) => {
553
827
  if (continuation.workspaceId !== workspaceId
554
828
  || continuation.owner !== bound.owner
555
829
  || continuation.threadId !== bound.threadId
556
830
  || continuation.gate.workerId !== resolution.workerId
557
831
  || continuation.gate.loopId !== resolution.loopId) {
558
- throw new HttpProblemError(Problems.create("agui:interrupt", "interrupt-binding-invalid", 400, "The resume does not match its interrupt's conversation."));
832
+ throw new HttpProblemError(Problems.create("agui:interrupt", "interrupt-binding-invalid", 400, "The resume does not match its interrupt's binding."));
559
833
  }
560
834
  bound.workerId = continuation.binding.workerId;
561
835
  bound.loopId = continuation.binding.loopId;
562
- bound.notificationScope = continuation.notificationScope;
563
836
  bound.cancelOnDisconnect = continuation.cancelOnDisconnect;
564
837
  if (bound.disconnected)
565
838
  throw new HttpProblemError(Problems.create("agui:interrupt", "connection-closed", 499, "The connection closed before the interrupt was resolved."));
566
839
  });
567
- bound.emit(resolutions.map((entry) => ({
840
+ // A client interaction is its own call, answered by the response; a proposal's call is answered by its
841
+ // operation's settlement, which arrives as that row.
842
+ bound.emit(resolutions.filter((_entry, index) => recorded[index].kind === "interaction").map((entry) => ({
568
843
  type: EventType.TOOL_CALL_RESULT,
569
844
  toolCallId: entry.interruptId,
570
845
  messageId: `${entry.interruptId}/result`,
@@ -577,7 +852,7 @@ class Portal {
577
852
  this.#bindLoop(bound, bound.loopId);
578
853
  const resolvedIds = new Set(resolutions.map(({ interruptId }) => interruptId));
579
854
  for (const [id, candidate] of this.#continuations) {
580
- if (candidate.continuation.workspaceId === workspaceId && resolvedIds.has(candidate.toolCallId))
855
+ if (candidate.continuation.workspaceId === workspaceId && resolvedIds.has(candidate.key))
581
856
  this.#continuations.delete(id);
582
857
  }
583
858
  if (this.#threads.get(workspaceId)?.has(bound)) {
@@ -587,6 +862,4 @@ class Portal {
587
862
  }
588
863
  }
589
864
  }
590
- _a = Portal;
591
- export default Portal;
592
865
  //# sourceMappingURL=Portal.js.map