@plurnk/plurnk-agui 3.0.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 +37 -13
  3. package/SPEC.md +627 -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 +2 -2
  9. package/dist/AguiSurface.d.ts.map +1 -1
  10. package/dist/AguiSurface.js +25 -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 +84 -119
  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 +222 -83
  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 +41 -9
  37. package/dist/Portal.d.ts.map +1 -1
  38. package/dist/Portal.js +497 -185
  39. package/dist/Portal.js.map +1 -1
  40. package/dist/ProposalHitl.d.ts +18 -4
  41. package/dist/ProposalHitl.d.ts.map +1 -1
  42. package/dist/ProposalHitl.js +31 -41
  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 +256 -137
  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 +391 -121
  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 +2 -0
  69. package/dist/run-events.d.ts.map +1 -1
  70. package/dist/run-events.js +5 -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,25 +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 { selectWorkerLoop } from "@plurnk/plurnk-contracts";
8
+ import { aliveChildren, spendState, stateDelta } from "./AguiPlus.js";
9
+ import { Problems, Validator, selectWorkerLoop } from "@plurnk/plurnk-contracts";
10
+ import { HttpProblemError } from "./action-results.js";
11
+ import { runErrorEvents, serviceStoppingProblem } from "./run-events.js";
12
+ import Translator, {} from "./Translator.js";
11
13
  import ProposalHitl, {} from "./ProposalHitl.js";
12
14
  import { EventType } from "./types.js";
13
- class Portal {
15
+ export default class Portal {
14
16
  #seam;
15
17
  // A workspace may have several simultaneous AG-UI Runs. The worker/loop carried by
16
18
  // each notification selects its owner; a Run is not a second broadcast subscription.
17
19
  #threads = new Map();
18
20
  #hitl;
19
- #activeInterrupts = new Map();
20
21
  #continuations = new Map();
21
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();
22
25
  #off = null;
26
+ #stopped = false;
23
27
  constructor(seam) {
24
28
  this.#seam = seam;
25
- 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)));
26
30
  }
27
31
  // One subscription for the whole module: render each event only to its owning Run.
28
32
  start() {
@@ -30,25 +34,44 @@ class Portal {
30
34
  this.#off = this.#seam.subscribeToEvents((workspaceId, method, params) => {
31
35
  if (workspaceId === null)
32
36
  return; // global (workspace/created) handled out-of-band
33
- // {§agui-proposal-resolve} A descendant gate may require asynchronous
34
- // topology lookup. A later terminal cannot close its controlling Run first.
35
- 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)) {
36
42
  this.#enqueueDelivery(workspaceId, async () => this.#routeNotification(workspaceId, method, params));
37
43
  return;
38
44
  }
39
- this.#routeNotification(workspaceId, method, params);
45
+ const observing = this.#routeNotification(workspaceId, method, params);
46
+ if (observing !== undefined)
47
+ this.#enqueueDelivery(workspaceId, async () => observing);
40
48
  });
41
49
  }
50
+ // Returns the descendants' events still being read for some Run's presentation, when there are any.
42
51
  #routeNotification(workspaceId, method, params) {
52
+ if (method === "loop/terminated")
53
+ this.#terminalsDue.delete(params.loopId);
54
+ const observing = [];
43
55
  for (const thread of this.#threads.get(workspaceId) ?? []) {
44
- if (_a.#touchesChildren(thread, method, params))
45
- void this.#refreshChildren(workspaceId, thread);
46
- if (thread.descendants && _a.#observedActor(thread, method, params) !== null) {
47
- 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));
48
61
  continue;
49
62
  }
50
- 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);
51
73
  continue;
74
+ }
52
75
  if (method === "loop/packet" && thread.loopId === null) {
53
76
  const loopId = params.loopId;
54
77
  if (typeof loopId === "number")
@@ -67,40 +90,43 @@ class Portal {
67
90
  }
68
91
  // {§agui-broadcast-fan} — the row that announces an execution owns its stream for this Run
69
92
  // before any channel write: a stream/event is a race the action result must not win.
70
- const opened = _a.#openedStream(method, params);
93
+ const opened = Portal.#openedStream(method, params);
71
94
  if (opened !== null)
72
95
  thread.openStreams.add(opened);
73
96
  if (method === "stream/concluded")
74
- thread.openStreams.delete(_a.#streamTarget(params));
75
- 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));
76
99
  if (out.length > 0)
77
100
  thread.emit(out);
101
+ if (method === "log/entry" && opened !== null)
102
+ this.#releaseUnannounced(thread, opened);
78
103
  // {§agui-gate-deferral} — a terminal leaves the held gate durable for the next Run; any other
79
104
  // event that closed the reasoning lifecycle presents it now.
80
105
  if (method === "loop/terminated")
81
106
  thread.deferredDelivery = null;
82
107
  else
83
108
  this.#presentDeferred(workspaceId, thread);
84
- if (method === "stream/concluded" && thread.openStreams.size === 0 && thread.deferredFinish !== null) {
109
+ if (thread.openStreams.size === 0 && thread.deferredFinish !== null) {
85
110
  const deferred = thread.deferredFinish;
86
111
  thread.deferredFinish = null;
87
- this.#finishThread(thread, deferred);
112
+ this.#finishThread(thread, deferred.events, deferred.result);
88
113
  }
89
114
  }
115
+ return observing.length === 0 ? undefined : Promise.all(observing).then(() => undefined);
90
116
  }
91
- // {§agui-status-children} {§agui-status-descendants} — the events after which the bound Worker's
92
- // alive direct children or its descendants' spend may differ: another worker's loop concluded or
93
- // 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
94
120
  // WORK/FORK/KILL row landed (it spawned or killed one). The daemon is asked, never inferred.
95
- static #touchesChildren(thread, method, params) {
96
- if (thread.notificationScope !== "conversation")
121
+ static #touchesStatus(thread, method, params) {
122
+ if (thread.notificationScope !== "worker")
97
123
  return false;
98
124
  const payload = params;
99
125
  if (method === "loop/terminated" || method === "loop/packet") {
100
126
  return typeof payload.workerId === "number" && payload.workerId !== thread.workerId;
101
127
  }
102
128
  if (method === "notice/event") {
103
- return typeof payload.workerId === "number" && payload.workerId !== thread.workerId
129
+ return typeof payload.workerId === "number"
104
130
  && payload.notice?.source === "engine:turn" && payload.notice.kind === "turn_generated";
105
131
  }
106
132
  if (method === "log/entry") {
@@ -109,27 +135,46 @@ class Portal {
109
135
  }
110
136
  return false;
111
137
  }
112
- 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;
113
148
  const [rows, accounting] = await Promise.all([
114
- this.#seam.listWorkers(workspaceId, { parentWorkerId: thread.workerId }),
115
- 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 }),
116
151
  ]);
117
- 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))
118
155
  return;
119
156
  const delta = [];
120
157
  const children = aliveChildren(rows);
121
- if (children !== thread.children) {
122
- thread.children = children;
158
+ if (children !== thread.gauge.children) {
123
159
  delta.push({ op: "replace", path: "/plurnk/status/children", value: children });
124
160
  }
125
- const delegation = descendantsState(accounting);
126
- if (thread.delegation === null || JSON.stringify(delegation) !== JSON.stringify(thread.delegation)) {
127
- thread.delegation = delegation;
128
- 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 });
129
164
  }
165
+ thread.gauge = { children, spend };
130
166
  if (delta.length > 0)
131
167
  thread.emit([stateDelta(delta)]);
132
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
+ }
133
178
  // {§agui-delegation-observation} — the actor of a row or stream event that is neither the bound
134
179
  // Worker nor a known stranger; null for everything else, so terminals, packets, reasoning and
135
180
  // notices stay the bound Worker's alone.
@@ -142,40 +187,167 @@ class Portal {
142
187
  return null;
143
188
  return actor;
144
189
  }
145
- // Descendants' events ride the workspace's delivery chain: ordered among themselves, introduced
146
- // once each from the daemon's topology, and never touching this Run's step, streams or terminal.
147
- #observeDescendant(workspaceId, thread, method, params) {
148
- const actor = _a.#observedActor(thread, method, params);
149
- 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))
150
199
  return;
151
- this.#enqueueDelivery(workspaceId, async () => {
152
- if (!(this.#threads.get(workspaceId)?.has(thread) ?? false))
153
- return;
154
- let node = thread.tree?.get(actor);
155
- if (node === undefined) {
156
- thread.tree = await this.#descendantTree(workspaceId, thread.workerId);
157
- node = thread.tree.get(actor);
158
- }
159
- if (node === undefined) {
160
- thread.unrelated.add(actor);
161
- return;
162
- }
163
- if (!thread.introduced.has(actor)) {
164
- thread.introduced.add(actor);
165
- 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" } });
166
320
  }
167
- const out = thread.router.route(method, params);
168
- if (out.length > 0)
169
- thread.emit(out);
321
+ return events;
170
322
  });
171
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
+ }
172
344
  async #descendantTree(workspaceId, rootId) {
173
- const byId = _a.#workerMap(await this.#seam.listWorkers(workspaceId));
345
+ const byId = Portal.#workerMap(await this.#seam.listWorkers(workspaceId));
174
346
  const tree = new Map();
175
347
  for (const candidate of byId.values()) {
176
348
  if (candidate.id === rootId || candidate.parentWorkerId === null)
177
349
  continue;
178
- const depth = _a.#ancestors(candidate.id, byId).indexOf(rootId) + 1;
350
+ const depth = Portal.#ancestors(candidate.id, byId).indexOf(rootId) + 1;
179
351
  if (depth > 0)
180
352
  tree.set(candidate.id, { workerId: candidate.id, name: candidate.name, parentWorkerId: candidate.parentWorkerId, depth });
181
353
  }
@@ -192,7 +364,7 @@ class Portal {
192
364
  const attrs = entry.attrs;
193
365
  return typeof attrs?.stream === "string" && attrs.detached !== true ? attrs.stream : null;
194
366
  }
195
- return method === "stream/event" ? _a.#streamTarget(params) : null;
367
+ return method === "stream/event" ? Portal.#streamTarget(params) : null;
196
368
  }
197
369
  static #streamTarget(params) {
198
370
  const target = params.target;
@@ -200,36 +372,85 @@ class Portal {
200
372
  throw new Error(`A stream notification names no target: ${JSON.stringify(params)}`);
201
373
  return target;
202
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.
203
378
  static #ownsNotification(thread, method, params) {
204
379
  if (thread.notificationScope === "result")
205
380
  return false;
381
+ const operation = thread.notificationScope === "operation";
206
382
  const payload = params;
207
383
  const owns = (workerId, loopId) => typeof workerId === "number"
208
384
  && workerId === thread.workerId
209
- && (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);
210
388
  switch (method) {
211
389
  case "workspace/preparation": return true;
212
390
  case "log/entry": return owns(payload.entry?.worker_id, payload.entry?.loop_id);
213
- case "loop/terminated":
391
+ case "loop/terminated": return !operation && owns(payload.workerId, payload.loopId);
214
392
  case "loop/packet":
215
393
  case "reasoning/event":
216
394
  case "outside/event": return owns(payload.workerId, payload.loopId);
217
395
  case "notice/event": return payload.workerId === null
218
- ? thread.notificationScope === "conversation"
396
+ ? thread.notificationScope === "worker"
219
397
  : owns(payload.workerId, payload.loopId);
220
398
  case "stream/event":
221
- 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);
222
402
  default: return false;
223
403
  }
224
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
+ }
225
436
  stop() {
437
+ if (this.#stopped)
438
+ return;
439
+ this.#stopped = true;
226
440
  this.#hitl.stop();
227
441
  this.#off?.();
228
442
  this.#off = null;
229
443
  this.#continuations.clear();
444
+ // {§agui-shutdown} Module close follows core settlement; remaining Runs are
445
+ // transport lifetimes, not unfinished worker obligations to await again.
446
+ for (const threads of this.#threads.values()) {
447
+ for (const thread of threads)
448
+ this.failThread(thread, runErrorEvents(serviceStoppingProblem()));
449
+ }
450
+ this.#threads.clear();
230
451
  }
231
452
  #emitDelivery(workspaceId, delivery, threads) {
232
- if (delivery.batch.events.length === 0)
453
+ if (delivery.batch.gates.length === 0)
233
454
  return;
234
455
  for (const thread of threads) {
235
456
  const controlLoopId = thread.loopId
@@ -240,51 +461,63 @@ class Portal {
240
461
  thread.deferredDelivery ??= delivery;
241
462
  continue;
242
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) }));
243
467
  const paused = thread.router.interrupt();
244
- for (const interrupt of delivery.batch.interrupts) {
245
- const key = interrupt.toolCallId ?? interrupt.id;
246
- const existing = this.#continuations.get(key);
247
- if (existing === undefined || existing.inputRunId === thread.inputRunId)
248
- this.#continuations.set(key, {
249
- workspaceId,
250
- owner: delivery.recipient,
251
- gate: { workerId: delivery.workerId, loopId: delivery.loopId },
252
- control: { workerId: thread.workerId, loopId: controlLoopId },
253
- threadId: thread.threadId,
254
- inputRunId: thread.inputRunId,
255
- notificationScope: thread.notificationScope,
256
- state: paused.continuation,
257
- });
258
- }
259
- thread.emit([...delivery.batch.events, ...paused.events]);
468
+ const continuation = {
469
+ cancelOnDisconnect: thread.cancelOnDisconnect,
470
+ workspaceId,
471
+ owner: delivery.recipient,
472
+ gate: { workerId: delivery.workerId, loopId: delivery.loopId },
473
+ binding: { workerId: thread.workerId, loopId: controlLoopId },
474
+ modelWorkerId: thread.modelWorkerId,
475
+ threadId: thread.threadId,
476
+ state: {
477
+ ...paused.continuation,
478
+ currentTurn: paused.continuation.currentTurn
479
+ ?? (thread.workerId === delivery.workerId ? delivery.turnId : null),
480
+ },
481
+ };
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 };
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);
260
489
  }
261
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
+ }
262
507
  #presentDeferred(workspaceId, thread) {
263
508
  const delivery = thread.deferredDelivery;
264
509
  if (delivery === null || thread.router.reasoningOpen)
265
510
  return;
266
511
  thread.deferredDelivery = null;
267
- this.#withHitl(delivery.batch, () => this.#emitDelivery(workspaceId, delivery, [thread]));
268
- }
269
- #withHitl(batch, emit) {
270
- for (const interrupt of batch.interrupts) {
271
- this.#activeInterrupts.set(interrupt.toolCallId ?? interrupt.id, interrupt);
272
- }
273
- try {
274
- emit(batch.events);
275
- }
276
- finally {
277
- for (const interrupt of batch.interrupts) {
278
- this.#activeInterrupts.delete(interrupt.toolCallId ?? interrupt.id);
279
- }
280
- }
512
+ this.#emitDelivery(workspaceId, delivery, [thread]);
281
513
  }
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.
282
516
  #routeHitl(workspaceId, delivery) {
283
517
  const threads = [...(this.#threads.get(workspaceId) ?? [])]
284
- .filter((thread) => thread.owner === delivery.recipient && thread.notificationScope !== "result");
518
+ .filter((thread) => thread.owner === delivery.recipient && thread.notificationScope === "worker");
285
519
  const exact = threads.filter((thread) => thread.workerId === delivery.workerId);
286
- const recipients = exact.length > 0 ? exact : threads.filter((thread) => thread.notificationScope === "conversation");
287
- this.#withHitl(delivery.batch, () => this.#emitDelivery(workspaceId, delivery, recipients));
520
+ this.#emitDelivery(workspaceId, delivery, exact.length > 0 ? exact : threads);
288
521
  }
289
522
  #enqueueDelivery(workspaceId, task) {
290
523
  const prior = this.#deliveryTails.get(workspaceId) ?? Promise.resolve();
@@ -336,51 +569,69 @@ class Portal {
336
569
  .filter(({ terminatedAt, terminalResult }) => terminatedAt === null && terminalResult === null));
337
570
  return active?.id ?? null;
338
571
  }
339
- interruptForToolCall(toolCallId) {
340
- 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;
341
582
  }
342
583
  // Bind a client's SSE to a workspace and AG-UI Run. The emit consumer ends its stream when it
343
584
  // sees RUN_FINISHED / RUN_ERROR (the router's terminal projection) — the engine
344
- // owns notification routing; the edge owns the socket lifecycle. `workerId` is the lifecycle actor
345
- // (client worker for client ops, conversation worker otherwise); `modelWorkerId`
346
- // binds the render (null → the router lazily
347
- // adopts the first model-origin row's worker — a fresh workspace's model worker is born
348
- // 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}).
349
588
  openThread(args) {
589
+ if (this.#stopped)
590
+ throw new HttpProblemError(serviceStoppingProblem());
350
591
  const candidates = args.resume
351
- ?.map(({ interruptId }) => this.#continuations.get(interruptId)) ?? [];
352
- const restored = candidates.find((candidate) => candidate !== undefined
353
- && candidate.workspaceId === args.workspaceId
354
- && candidate.owner === args.owner
355
- && candidate.threadId === args.threadId);
592
+ ?.map(({ interruptId }) => this.#continuations.get(interruptId)?.continuation) ?? [];
593
+ const first = candidates[0];
594
+ const restored = first !== undefined && candidates.every((candidate) => candidate === first)
595
+ && first.workspaceId === args.workspaceId && first.owner === args.owner && first.threadId === args.threadId
596
+ ? first : undefined;
356
597
  const continuation = restored?.state;
357
598
  const router = new EventRouter({
358
599
  threadId: args.threadId,
600
+ ...(args.replyThreads === undefined ? {} : { replyThreads: args.replyThreads }),
359
601
  runId: args.inputRunId ?? String(args.workerId),
360
602
  modelWorkerId: args.modelWorkerId ?? null,
361
603
  workspaceId: args.workspaceId,
362
604
  ...(continuation === undefined ? {} : { continuation }),
363
605
  });
364
606
  const t = {
607
+ workspaceId: args.workspaceId,
365
608
  owner: args.owner ?? null,
366
- workerId: restored?.control.workerId ?? args.workerId,
367
- loopId: restored?.control.loopId ?? null,
368
- notificationScope: restored?.notificationScope ?? args.notificationScope,
609
+ family: args.family ?? null,
610
+ workerId: restored?.binding.workerId ?? args.workerId,
611
+ loopId: restored?.binding.loopId ?? null,
612
+ notificationScope: args.notificationScope,
613
+ modelWorkerId: args.modelWorkerId ?? null,
369
614
  router,
370
615
  emit: args.emit,
371
616
  threadId: args.threadId,
372
617
  inputRunId: args.inputRunId ?? String(args.workerId),
373
618
  openStreams: new Set(),
619
+ unannounced: new Map(),
374
620
  deferredFinish: null,
375
621
  pendingTerminations: [],
376
622
  resolvingInterrupts: args.resume !== undefined,
623
+ cancelOnDisconnect: restored?.cancelOnDisconnect ?? args.cancelOnDisconnect === true,
624
+ disconnected: false,
377
625
  deferredDelivery: null,
378
626
  descendants: args.descendants === true,
379
627
  tree: null,
380
628
  introduced: new Set(),
381
629
  unrelated: new Set(),
382
- children: null,
383
- delegation: null,
630
+ judged: new Map(),
631
+ subagents: new Map(),
632
+ gauge: null,
633
+ staleGauge: false,
634
+ gaugeRefresh: 0,
384
635
  };
385
636
  let set = this.#threads.get(args.workspaceId);
386
637
  if (set === undefined) {
@@ -391,43 +642,100 @@ class Portal {
391
642
  return t;
392
643
  }
393
644
  closeRun(workspaceId, t) { this.#threads.get(workspaceId)?.delete(t); }
394
- runStarted(thread, state) {
395
- return thread.router.runStarted(state);
396
- }
397
- replay(thread, entries, currentUser) {
398
- return thread.router.replay(entries, currentUser);
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.
647
+ disconnect(thread) {
648
+ const bound = thread;
649
+ if (bound.disconnected)
650
+ return;
651
+ bound.disconnected = true;
652
+ if (bound.cancelOnDisconnect)
653
+ this.#cancelOwned(bound);
654
+ }
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");
399
667
  }
400
- #finishThread(thread, events) {
401
- 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);
402
675
  }
403
- 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) {
404
706
  const bound = thread;
405
707
  if (bound.openStreams.size > 0) {
406
- bound.deferredFinish = events;
708
+ bound.deferredFinish = { events, result };
407
709
  return;
408
710
  }
409
- this.#finishThread(bound, events);
711
+ this.#finishThread(bound, events, result);
410
712
  }
411
713
  failThread(thread, events) {
412
714
  const bound = thread;
413
- bound.emit([...bound.router.finish(), ...events]);
414
- }
415
- // Emit extra events + RUN_FINISHED through the resumed Run that owns the
416
- // original action's worker and client thread —
417
- // an action that paused on a proposal completes after the resume AG-UI Run rebound the
418
- // stream, so its result must ride whichever response is live now, never the
419
- // closure of the request that spawned it.
420
- finishRun(workspaceId, workerId, threadId, events) {
421
- for (const t of this.#threads.get(workspaceId) ?? []) {
422
- if (t.notificationScope === "result" || t.workerId !== workerId || t.threadId !== threadId)
423
- continue;
424
- 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
+ }
425
727
  }
426
728
  }
427
729
  async #resurfaceControlled(workspaceId, thread) {
428
730
  if (thread.owner === null)
429
731
  return false;
430
732
  const deliveries = await this.#hitl.resurface(workspaceId, thread.owner);
733
+ const pendingKeys = new Set(deliveries.flatMap(({ batch }) => batch.gates.map(({ key }) => key)));
734
+ for (const [id, candidate] of this.#continuations) {
735
+ if (candidate.continuation.workspaceId === workspaceId && candidate.continuation.owner === thread.owner
736
+ && !pendingKeys.has(candidate.key))
737
+ this.#continuations.delete(id);
738
+ }
431
739
  if (deliveries.length === 0)
432
740
  return false;
433
741
  const delivery = deliveries.find(({ workerId }) => thread.workerId === workerId) ?? deliveries[0];
@@ -436,9 +744,9 @@ class Portal {
436
744
  if (thread.loopId === null) {
437
745
  thread.loopId = delivery.workerId === thread.workerId
438
746
  ? delivery.loopId
439
- : await this.#activeLoopId(workspaceId, thread.workerId);
747
+ : thread.workerId === null ? null : await this.#activeLoopId(workspaceId, thread.workerId);
440
748
  }
441
- this.#withHitl(delivery.batch, () => this.#emitDelivery(workspaceId, delivery, [thread]));
749
+ this.#emitDelivery(workspaceId, delivery, [thread]);
442
750
  return true;
443
751
  }
444
752
  #bindLoop(thread, loopId) {
@@ -447,17 +755,19 @@ class Portal {
447
755
  thread.pendingTerminations = [];
448
756
  if (terminal === undefined)
449
757
  return false;
450
- const out = thread.router.route("loop/terminated", terminal);
758
+ const out = Portal.#finishing(thread, thread.router.route("loop/terminated", terminal));
451
759
  if (out.length > 0)
452
760
  thread.emit(out);
453
761
  return true;
454
762
  }
455
- // 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
456
764
  // stopped-world is re-presented first; otherwise an active Loop stays attached
457
- // and an idle conversation settles immediately. Returns true only while this
765
+ // and an idle worker settles immediately. Returns true only while this
458
766
  // thread remains attached to independently-owned live work.
459
767
  async synchronize(workspaceId, thread, waitForWork = false) {
460
768
  const bound = thread;
769
+ if (bound.workerId === null)
770
+ throw new Error("a synchronization Run reached the Portal without its worker");
461
771
  if (await this.#resurfaceControlled(workspaceId, bound))
462
772
  return false;
463
773
  const loop = selectWorkerLoop((await this.#seam.listWorkerLoops({ workspaceId, workerId: bound.workerId }))
@@ -475,14 +785,25 @@ class Portal {
475
785
  // Core before any pending stopped-world is re-presented.
476
786
  async run(thread, args) {
477
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);
478
792
  const ack = await this.#seam.runLoop(args);
793
+ if (bound.disconnected) {
794
+ if (bound.cancelOnDisconnect)
795
+ this.#cancelOwned(bound);
796
+ return null;
797
+ }
798
+ if (!this.#threads.get(args.workspaceId)?.has(bound))
799
+ return null;
479
800
  if (await this.#resurfaceControlled(args.workspaceId, bound))
480
801
  return null;
481
802
  this.#bindLoop(bound, ack.loopId);
482
803
  return { loopId: ack.loopId };
483
804
  }
484
805
  cancel(workerId) { return this.#seam.cancelDrain(workerId); }
485
- // A standard resume AG-UI Run binds to the persisted continuation before
806
+ // A standard resume AG-UI Run binds to its presentation's continuation before
486
807
  // releasing every addressed interrupt.
487
808
  async resolve(workspaceId, thread, entries) {
488
809
  const bound = thread;
@@ -490,45 +811,35 @@ class Portal {
490
811
  throw new Error("an observer cannot resolve interrupts");
491
812
  bound.resolvingInterrupts = true;
492
813
  const continuations = entries.map(({ interruptId }) => this.#continuations.get(interruptId));
493
- const persisted = continuations.filter((continuation) => continuation !== undefined);
494
- if (persisted.length > 0 && persisted.length !== entries.length) {
495
- throw new Error("resume mixes persisted and unbound interrupts");
496
- }
497
- const continuation = persisted[0];
498
- if (continuation !== undefined) {
499
- for (const candidate of persisted) {
500
- if (candidate.workspaceId !== continuation.workspaceId
501
- || candidate.owner !== continuation.owner
502
- || candidate.threadId !== continuation.threadId
503
- || candidate.gate.workerId !== continuation.gate.workerId
504
- || candidate.gate.loopId !== continuation.gate.loopId
505
- || candidate.control.workerId !== continuation.control.workerId
506
- || candidate.control.loopId !== continuation.control.loopId) {
507
- throw new Error("resume interrupts do not share one persisted control binding");
508
- }
509
- }
814
+ const recorded = continuations.filter((continuation) => continuation !== undefined);
815
+ if (recorded.length !== entries.length || recorded.length === 0) {
816
+ throw new HttpProblemError(Problems.create("agui:interrupt", "interrupt-not-pending", 409, "The resume addresses an unknown interrupt presentation.", {
817
+ stage: "interrupt-resolution", retryable: false,
818
+ recovery: "Refresh pending interrupts before resuming.",
819
+ }));
510
820
  }
511
- await this.#hitl.resolve(workspaceId, bound.owner, entries, async (resolution) => {
512
- if (continuation !== undefined) {
513
- if (continuation.workspaceId !== workspaceId
514
- || continuation.owner !== bound.owner
515
- || continuation.threadId !== bound.threadId
516
- || continuation.gate.workerId !== resolution.workerId
517
- || continuation.gate.loopId !== resolution.loopId) {
518
- throw new Error("resume does not match its persisted interrupt binding");
519
- }
520
- bound.workerId = continuation.control.workerId;
521
- bound.loopId = continuation.control.loopId;
522
- bound.notificationScope = continuation.notificationScope;
523
- }
524
- else {
525
- const exact = resolution.workerId === bound.workerId;
526
- bound.loopId = exact
527
- ? resolution.loopId
528
- : await this.#activeLoopId(workspaceId, bound.workerId);
821
+ const continuation = recorded[0].continuation;
822
+ if (recorded.some((candidate) => candidate.continuation !== continuation)) {
823
+ throw new HttpProblemError(Problems.create("agui:interrupt", "interrupt-binding-invalid", 400, "The resume combines interrupts from different Runs."));
824
+ }
825
+ const resolutions = entries.map((entry, index) => ({ ...entry, interruptId: recorded[index].key }));
826
+ await this.#hitl.resolve(workspaceId, bound.owner, resolutions, (resolution) => {
827
+ if (continuation.workspaceId !== workspaceId
828
+ || continuation.owner !== bound.owner
829
+ || continuation.threadId !== bound.threadId
830
+ || continuation.gate.workerId !== resolution.workerId
831
+ || continuation.gate.loopId !== resolution.loopId) {
832
+ throw new HttpProblemError(Problems.create("agui:interrupt", "interrupt-binding-invalid", 400, "The resume does not match its interrupt's binding."));
529
833
  }
834
+ bound.workerId = continuation.binding.workerId;
835
+ bound.loopId = continuation.binding.loopId;
836
+ bound.cancelOnDisconnect = continuation.cancelOnDisconnect;
837
+ if (bound.disconnected)
838
+ throw new HttpProblemError(Problems.create("agui:interrupt", "connection-closed", 499, "The connection closed before the interrupt was resolved."));
530
839
  });
531
- bound.emit(entries.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) => ({
532
843
  type: EventType.TOOL_CALL_RESULT,
533
844
  toolCallId: entry.interruptId,
534
845
  messageId: `${entry.interruptId}/result`,
@@ -539,8 +850,11 @@ class Portal {
539
850
  bound.resolvingInterrupts = false;
540
851
  if (bound.loopId !== null)
541
852
  this.#bindLoop(bound, bound.loopId);
542
- for (const { interruptId } of entries)
543
- this.#continuations.delete(interruptId);
853
+ const resolvedIds = new Set(resolutions.map(({ interruptId }) => interruptId));
854
+ for (const [id, candidate] of this.#continuations) {
855
+ if (candidate.continuation.workspaceId === workspaceId && resolvedIds.has(candidate.key))
856
+ this.#continuations.delete(id);
857
+ }
544
858
  if (this.#threads.get(workspaceId)?.has(bound)) {
545
859
  const surfaced = await this.#resurfaceControlled(workspaceId, bound);
546
860
  if (!surfaced && bound.loopId === null)
@@ -548,6 +862,4 @@ class Portal {
548
862
  }
549
863
  }
550
864
  }
551
- _a = Portal;
552
- export default Portal;
553
865
  //# sourceMappingURL=Portal.js.map