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