@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.
- package/.env.defaults +17 -8
- package/README.md +37 -13
- package/SPEC.md +627 -268
- package/dist/AguiPlus.d.ts +24 -29
- package/dist/AguiPlus.d.ts.map +1 -1
- package/dist/AguiPlus.js +25 -36
- package/dist/AguiPlus.js.map +1 -1
- package/dist/AguiSurface.d.ts +2 -2
- package/dist/AguiSurface.d.ts.map +1 -1
- package/dist/AguiSurface.js +25 -16
- package/dist/AguiSurface.js.map +1 -1
- package/dist/AuthorizationDocuments.d.ts +33 -0
- package/dist/AuthorizationDocuments.d.ts.map +1 -0
- package/dist/AuthorizationDocuments.js +84 -0
- package/dist/AuthorizationDocuments.js.map +1 -0
- package/dist/BuiltinActions.d.ts +11 -5
- package/dist/BuiltinActions.d.ts.map +1 -1
- package/dist/BuiltinActions.js +84 -119
- package/dist/BuiltinActions.js.map +1 -1
- package/dist/EventRouter.d.ts +11 -3
- package/dist/EventRouter.d.ts.map +1 -1
- package/dist/EventRouter.js +17 -32
- package/dist/EventRouter.js.map +1 -1
- package/dist/MessageAddress.d.ts +7 -5
- package/dist/MessageAddress.d.ts.map +1 -1
- package/dist/MessageAddress.js +14 -12
- package/dist/MessageAddress.js.map +1 -1
- package/dist/Module.d.ts +5 -4
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +222 -83
- package/dist/Module.js.map +1 -1
- package/dist/OAuthServer.d.ts +30 -0
- package/dist/OAuthServer.d.ts.map +1 -0
- package/dist/OAuthServer.js +607 -0
- package/dist/OAuthServer.js.map +1 -0
- package/dist/Portal.d.ts +41 -9
- package/dist/Portal.d.ts.map +1 -1
- package/dist/Portal.js +497 -185
- package/dist/Portal.js.map +1 -1
- package/dist/ProposalHitl.d.ts +18 -4
- package/dist/ProposalHitl.d.ts.map +1 -1
- package/dist/ProposalHitl.js +31 -41
- package/dist/ProposalHitl.js.map +1 -1
- package/dist/RunHandler.d.ts +18 -8
- package/dist/RunHandler.d.ts.map +1 -1
- package/dist/RunHandler.js +256 -137
- package/dist/RunHandler.js.map +1 -1
- package/dist/ThreadAddress.d.ts +8 -0
- package/dist/ThreadAddress.d.ts.map +1 -0
- package/dist/ThreadAddress.js +18 -0
- package/dist/ThreadAddress.js.map +1 -0
- package/dist/Translator.d.ts +52 -2
- package/dist/Translator.d.ts.map +1 -1
- package/dist/Translator.js +391 -121
- package/dist/Translator.js.map +1 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +22 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +65 -13
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/run-events.d.ts +2 -0
- package/dist/run-events.d.ts.map +1 -1
- package/dist/run-events.js +5 -2
- package/dist/run-events.js.map +1 -1
- package/dist/types.d.ts +4 -22
- package/dist/types.d.ts.map +1 -1
- 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,
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
45
|
-
void this.#
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
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 =
|
|
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(
|
|
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 (
|
|
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-
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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 #
|
|
96
|
-
if (thread.notificationScope !== "
|
|
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"
|
|
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
|
-
|
|
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:
|
|
115
|
-
this.#seam.
|
|
149
|
+
this.#seam.listWorkers(thread.workspaceId, { parentWorkerId: workerId }),
|
|
150
|
+
this.#seam.workerAccounting({ workspaceId: thread.workspaceId, workerId }),
|
|
116
151
|
]);
|
|
117
|
-
if (
|
|
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
|
|
126
|
-
if (
|
|
127
|
-
|
|
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
|
-
//
|
|
146
|
-
//
|
|
147
|
-
#observeDescendant(workspaceId, thread, method, params) {
|
|
148
|
-
|
|
149
|
-
if (
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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" ?
|
|
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
|
-
&& (
|
|
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 === "
|
|
396
|
+
? thread.notificationScope === "worker"
|
|
219
397
|
: owns(payload.workerId, payload.loopId);
|
|
220
398
|
case "stream/event":
|
|
221
|
-
case "stream/concluded": return
|
|
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.
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
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.#
|
|
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
|
|
518
|
+
.filter((thread) => thread.owner === delivery.recipient && thread.notificationScope === "worker");
|
|
285
519
|
const exact = threads.filter((thread) => thread.workerId === delivery.workerId);
|
|
286
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
345
|
-
//
|
|
346
|
-
//
|
|
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
|
|
353
|
-
|
|
354
|
-
&&
|
|
355
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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.
|
|
414
|
-
}
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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.#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
494
|
-
if (
|
|
495
|
-
throw new
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|