@plurnk/plurnk-agui 1.14.1 → 1.15.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/SPEC.md +28 -4
- package/dist/AguiSurface.d.ts +2 -3
- package/dist/AguiSurface.d.ts.map +1 -1
- package/dist/BuiltinActions.d.ts +16 -0
- package/dist/BuiltinActions.d.ts.map +1 -0
- package/dist/BuiltinActions.js +339 -0
- package/dist/BuiltinActions.js.map +1 -0
- package/dist/EventRouter.d.ts +8 -4
- package/dist/EventRouter.d.ts.map +1 -1
- package/dist/EventRouter.js +4 -3
- package/dist/EventRouter.js.map +1 -1
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +23 -607
- package/dist/Module.js.map +1 -1
- package/dist/Portal.d.ts +5 -2
- package/dist/Portal.d.ts.map +1 -1
- package/dist/Portal.js +45 -14
- package/dist/Portal.js.map +1 -1
- package/dist/RunHandler.d.ts +25 -0
- package/dist/RunHandler.d.ts.map +1 -0
- package/dist/RunHandler.js +290 -0
- package/dist/RunHandler.js.map +1 -0
- package/dist/Translator.d.ts +8 -4
- package/dist/Translator.d.ts.map +1 -1
- package/dist/Translator.js +82 -12
- package/dist/Translator.js.map +1 -1
- package/dist/action-results.d.ts +9 -0
- package/dist/action-results.d.ts.map +1 -0
- package/dist/action-results.js +45 -0
- package/dist/action-results.js.map +1 -0
- package/dist/observe.d.ts +1 -3
- package/dist/observe.d.ts.map +1 -1
- package/dist/observe.js +2 -2
- package/dist/observe.js.map +1 -1
- package/dist/run-events.d.ts +5 -0
- package/dist/run-events.d.ts.map +1 -0
- package/dist/run-events.js +9 -0
- package/dist/run-events.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/Module.js
CHANGED
|
@@ -14,90 +14,32 @@
|
|
|
14
14
|
var _a;
|
|
15
15
|
import { createServer } from "node:http";
|
|
16
16
|
import Portal from "./Portal.js";
|
|
17
|
-
import { derivationActivity,
|
|
17
|
+
import { derivationActivity, statusState, actionResult } from "./AguiPlus.js";
|
|
18
18
|
import { EventType } from "./types.js";
|
|
19
19
|
import { aguiRouteTemplate, observed } from "./observe.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { AGUI_BUILTIN_ACTIONS, AGUI_NOTIFICATIONS, } from "./AguiSurface.js";
|
|
20
|
+
import { Validator } from "@plurnk/plurnk-contracts";
|
|
21
|
+
import { AGUI_BUILTIN_ACTIONS, AGUI_NOTIFICATIONS } from "./AguiSurface.js";
|
|
23
22
|
import { resolveModuleOptions } from "./config.js";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
retryable: false,
|
|
29
|
-
...extensions,
|
|
30
|
-
}),
|
|
31
|
-
});
|
|
32
|
-
const httpProblem = (code, status, detail, extensions = {}) => Problems.create("agui:http", code, status, detail, extensions);
|
|
33
|
-
class HttpProblemError extends Error {
|
|
34
|
-
problem;
|
|
35
|
-
constructor(problem) {
|
|
36
|
-
super(problem.detail);
|
|
37
|
-
this.name = "HttpProblemError";
|
|
38
|
-
this.problem = problem;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
const problemFromError = (error) => {
|
|
42
|
-
if (error instanceof HttpProblemError)
|
|
43
|
-
return error.problem;
|
|
44
|
-
if (typeof error !== "object" || error === null)
|
|
45
|
-
return null;
|
|
46
|
-
const result = error.result;
|
|
47
|
-
if (result !== undefined) {
|
|
48
|
-
try {
|
|
49
|
-
return Validator.assertOperationResult(result).problem ?? null;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const problem = error.problem;
|
|
56
|
-
if (problem !== undefined) {
|
|
57
|
-
try {
|
|
58
|
-
return Validator.assertProblemDetails(problem);
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
};
|
|
66
|
-
const operationOutcome = (result) => {
|
|
67
|
-
const exact = Validator.assertOperationResult(result);
|
|
68
|
-
return exact.problem === undefined
|
|
69
|
-
? { ok: true, result: exact }
|
|
70
|
-
: { ok: false, problem: exact.problem };
|
|
71
|
-
};
|
|
72
|
-
const parseFailureResult = ({ detail, line, column, source, severity, }) => ({
|
|
73
|
-
status: 400,
|
|
74
|
-
problem: Problems.create("agui:action", "parse-failed", 400, detail, {
|
|
75
|
-
line,
|
|
76
|
-
column,
|
|
77
|
-
source,
|
|
78
|
-
severity,
|
|
79
|
-
stage: "parsing",
|
|
80
|
-
retryable: false,
|
|
81
|
-
}),
|
|
82
|
-
});
|
|
23
|
+
import { HttpProblemError, actionFailure, problemFromError } from "./action-results.js";
|
|
24
|
+
import BuiltinActions from "./BuiltinActions.js";
|
|
25
|
+
import { httpProblem, runErrorEvents } from "./run-events.js";
|
|
26
|
+
import RunHandler from "./RunHandler.js";
|
|
83
27
|
const writeHttpProblem = (res, problem) => {
|
|
84
28
|
res.writeHead(problem.status, { "content-type": "application/problem+json" });
|
|
85
29
|
res.end(JSON.stringify(problem));
|
|
86
30
|
};
|
|
87
|
-
const runErrorEvents = (problem) => [
|
|
88
|
-
{ type: EventType.CUSTOM, name: "plurnk.problem", value: problem },
|
|
89
|
-
{ type: EventType.RUN_ERROR, message: problem.detail, code: problem.type },
|
|
90
|
-
];
|
|
91
31
|
class Module {
|
|
92
32
|
#seam;
|
|
93
33
|
#opts;
|
|
94
34
|
#portal;
|
|
95
35
|
#http;
|
|
96
|
-
#
|
|
97
|
-
#threadWorkers = new Map(); // threadId → conversation workerId
|
|
36
|
+
#threadEnvelopes = new Map(); // [workspace, threadId] → envelope
|
|
37
|
+
#threadWorkers = new Map(); // [workspace, threadId] → conversation workerId
|
|
98
38
|
#actions = new Map();
|
|
99
39
|
#listening = false;
|
|
100
40
|
#activated = false;
|
|
41
|
+
#builtins = new BuiltinActions({ seam: () => this.#seam, capabilities: this.#capabilities.bind(this), envelope: this.#envelope.bind(this), requireWorkspace: _a.#requireWorkspace });
|
|
42
|
+
#runs = new RunHandler({ seam: () => this.#seam, opts: () => this.#opts, portal: () => this.#portal, requiresWorkspace: this.#requiresWorkspace.bind(this), controlRun: this.#controlRun.bind(this), envelope: this.#envelope.bind(this), conversationWorker: this.#conversationWorker.bind(this), workerStatus: this.#workerStatus.bind(this), action: this.#action.bind(this) });
|
|
101
43
|
#closing = null;
|
|
102
44
|
constructor(opts) {
|
|
103
45
|
this.#opts = resolveModuleOptions(opts);
|
|
@@ -107,7 +49,7 @@ class Module {
|
|
|
107
49
|
for (const [name, contract] of Object.entries(AGUI_BUILTIN_ACTIONS)) {
|
|
108
50
|
this.#actions.set(name, {
|
|
109
51
|
...contract,
|
|
110
|
-
execute: (params, env, conversationWorkerId) => this.#executeBuiltin(name, params, env, conversationWorkerId),
|
|
52
|
+
execute: (params, env, conversationWorkerId) => this.#builtins.executeBuiltin(name, params, env, conversationWorkerId),
|
|
111
53
|
});
|
|
112
54
|
}
|
|
113
55
|
for (const descriptor of this.#seam.listModuleActions()) {
|
|
@@ -152,6 +94,9 @@ class Module {
|
|
|
152
94
|
throw new Error(`action '${kind}' operates within a workspace, but none is bound`);
|
|
153
95
|
return env;
|
|
154
96
|
}
|
|
97
|
+
static #threadKey(workspace, threadId) {
|
|
98
|
+
return JSON.stringify([workspace, threadId]);
|
|
99
|
+
}
|
|
155
100
|
static init(opts) {
|
|
156
101
|
return {
|
|
157
102
|
start: async (seam) => {
|
|
@@ -252,7 +197,7 @@ class Module {
|
|
|
252
197
|
return;
|
|
253
198
|
}
|
|
254
199
|
if (req.method === "POST" && (req.url === "/" || req.url === "/agui"))
|
|
255
|
-
return await this.#run(req, res);
|
|
200
|
+
return await this.#runs.run(req, res);
|
|
256
201
|
writeHttpProblem(res, httpProblem("route-not-found", 404, "The requested HTTP route does not exist.", {
|
|
257
202
|
method: req.method ?? null,
|
|
258
203
|
path: req.url ?? null,
|
|
@@ -300,7 +245,8 @@ class Module {
|
|
|
300
245
|
retryable: false,
|
|
301
246
|
}));
|
|
302
247
|
}
|
|
303
|
-
const
|
|
248
|
+
const key = _a.#threadKey(workspace, threadId);
|
|
249
|
+
const cached = this.#threadEnvelopes.get(key);
|
|
304
250
|
if (cached !== undefined)
|
|
305
251
|
return { env: cached, reattached: true };
|
|
306
252
|
const known = (await this.#seam.listWorkspaces()).find((s) => s.name === workspace);
|
|
@@ -325,22 +271,23 @@ class Module {
|
|
|
325
271
|
: {}),
|
|
326
272
|
});
|
|
327
273
|
}
|
|
328
|
-
this.#
|
|
274
|
+
this.#threadEnvelopes.set(key, env);
|
|
329
275
|
return { env, reattached };
|
|
330
276
|
}
|
|
331
277
|
// Resolve the thread's conversation worker within its world. Cached per
|
|
332
|
-
// threadId; worker names are immutable so the binding cannot rot. Durable
|
|
278
|
+
// workspace + threadId; worker names are immutable so the binding cannot rot. Durable
|
|
333
279
|
// capability changes use worker.capabilities.set; per-run attenuation belongs
|
|
334
280
|
// to the loop policy forwarded below.
|
|
335
281
|
async #conversationWorker(threadId, env) {
|
|
336
|
-
const
|
|
282
|
+
const key = _a.#threadKey(env.workspaceName, threadId);
|
|
283
|
+
const cached = this.#threadWorkers.get(key);
|
|
337
284
|
if (cached !== undefined)
|
|
338
285
|
return cached;
|
|
339
286
|
const workerId = threadId === env.workspaceName
|
|
340
287
|
? await this.#seam.ensureModelWorker(env.workspaceId)
|
|
341
288
|
: (await this.#seam.listWorkers(env.workspaceId)).find((r) => r.name === threadId)?.id
|
|
342
289
|
?? (await this.#seam.createConversationWorker({ workspaceId: env.workspaceId, name: threadId })).workerId;
|
|
343
|
-
this.#threadWorkers.set(
|
|
290
|
+
this.#threadWorkers.set(key, workerId);
|
|
344
291
|
return workerId;
|
|
345
292
|
}
|
|
346
293
|
async #workerStatus(workspaceId, workerId) {
|
|
@@ -350,222 +297,6 @@ class Module {
|
|
|
350
297
|
]);
|
|
351
298
|
return statusState(model, loops.at(-1) ?? null, derivationActivity(this.#seam.workspaceDerivationStatus(workspaceId)));
|
|
352
299
|
}
|
|
353
|
-
async #run(req, res) {
|
|
354
|
-
let decoded;
|
|
355
|
-
try {
|
|
356
|
-
decoded = JSON.parse(await _a.#body(req));
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
throw new HttpProblemError(httpProblem("invalid-json", 400, "The request body is not valid JSON.", { stage: "request-validation", retryable: false }));
|
|
360
|
-
}
|
|
361
|
-
const parsed = RunAgentInputSchema.safeParse(decoded);
|
|
362
|
-
if (!parsed.success) {
|
|
363
|
-
throw new HttpProblemError(httpProblem("invalid-run-input", 400, "The request body does not satisfy the AG-UI RunAgentInput contract.", {
|
|
364
|
-
stage: "request-validation",
|
|
365
|
-
issues: parsed.error.issues,
|
|
366
|
-
retryable: false,
|
|
367
|
-
}));
|
|
368
|
-
}
|
|
369
|
-
const input = parsed.data;
|
|
370
|
-
const forwarded = input.forwardedProps?.plurnk;
|
|
371
|
-
// Control plane FIRST: a management action that doesn't live in a world (and an
|
|
372
|
-
// unknown kind, which is no worker at all) answers without binding — or forging — a
|
|
373
|
-
// workspace. Only world-scoped actions and conversations reach #envelope below.
|
|
374
|
-
const action = parseAction(input.forwardedProps);
|
|
375
|
-
if (action !== null && !this.#requiresWorkspace(action.kind)) {
|
|
376
|
-
return await this.#controlRun(action, input, res);
|
|
377
|
-
}
|
|
378
|
-
let prompt = null;
|
|
379
|
-
if (action === null && input.resume === undefined) {
|
|
380
|
-
const lastUser = [...input.messages].reverse().find((message) => message.role === "user");
|
|
381
|
-
if (lastUser === undefined || typeof lastUser.content !== "string" || lastUser.content.length === 0) {
|
|
382
|
-
throw new HttpProblemError(httpProblem("user-message-required", 400, "A new AG-UI Run requires a non-empty textual user message.", {
|
|
383
|
-
stage: "request-validation",
|
|
384
|
-
recovery: "Provide a non-empty user message.",
|
|
385
|
-
retryable: false,
|
|
386
|
-
}));
|
|
387
|
-
}
|
|
388
|
-
prompt = lastUser.content;
|
|
389
|
-
}
|
|
390
|
-
const { env, reattached } = await this.#envelope(input.threadId, forwarded);
|
|
391
|
-
const workspaceId = env.workspaceId;
|
|
392
|
-
// {§agui-thread-binding} The threadId is the conversation over the
|
|
393
|
-
// world. threadId == workspace name binds the model worker (the default conversation);
|
|
394
|
-
// a distinct threadId names its own worker: found by name, else minted via
|
|
395
|
-
// createConversationWorker. The name is the identity at BOTH levels.
|
|
396
|
-
const workerId = await this.#conversationWorker(input.threadId, env);
|
|
397
|
-
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
398
|
-
let finished = false;
|
|
399
|
-
const interrupts = [];
|
|
400
|
-
// {§agui-configuration}: long generations can emit no events; SSE comments keep
|
|
401
|
-
// consumers fed without adding protocol events.
|
|
402
|
-
const cadence = this.#opts.heartbeatMs;
|
|
403
|
-
const heartbeat = cadence > 0 ? setInterval(() => { res.write(": hb\n\n"); }, cadence) : null;
|
|
404
|
-
const finish = () => {
|
|
405
|
-
if (finished)
|
|
406
|
-
return;
|
|
407
|
-
finished = true;
|
|
408
|
-
if (heartbeat !== null)
|
|
409
|
-
clearInterval(heartbeat);
|
|
410
|
-
this.#portal.closeRun(workspaceId, boundRun);
|
|
411
|
-
res.end();
|
|
412
|
-
};
|
|
413
|
-
const emit = (events) => {
|
|
414
|
-
for (const e of events) {
|
|
415
|
-
res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
416
|
-
// Terminate-resume, the terminate half: a stopped-world tool call
|
|
417
|
-
// ends this AG-UI Run while its core owner remains paused.
|
|
418
|
-
if (e.type === "TOOL_CALL_END") {
|
|
419
|
-
const interrupt = this.#portal.interruptForToolCall(e.toolCallId);
|
|
420
|
-
if (interrupt !== null)
|
|
421
|
-
interrupts.push(interrupt);
|
|
422
|
-
}
|
|
423
|
-
if (e.type === "RUN_FINISHED" || e.type === "RUN_ERROR")
|
|
424
|
-
finish();
|
|
425
|
-
}
|
|
426
|
-
if (interrupts.length > 0 && !finished) {
|
|
427
|
-
res.write(`data: ${JSON.stringify({
|
|
428
|
-
type: EventType.RUN_FINISHED,
|
|
429
|
-
threadId: input.threadId,
|
|
430
|
-
runId: input.runId,
|
|
431
|
-
outcome: { type: "interrupt", interrupts: interrupts.splice(0) },
|
|
432
|
-
})}\n\n`);
|
|
433
|
-
finish();
|
|
434
|
-
}
|
|
435
|
-
};
|
|
436
|
-
const lifecycleWorkerId = action?.kind === "op.exec" || action?.kind === "op.parse" ? env.workerId : workerId;
|
|
437
|
-
const notificationScope = action === null
|
|
438
|
-
? "conversation"
|
|
439
|
-
: action.kind === "op.exec" || action.kind === "op.parse"
|
|
440
|
-
? "operation"
|
|
441
|
-
: "result";
|
|
442
|
-
const boundRun = this.#portal.openThread({
|
|
443
|
-
workspaceId,
|
|
444
|
-
workerId: lifecycleWorkerId,
|
|
445
|
-
threadId: input.threadId,
|
|
446
|
-
notificationScope,
|
|
447
|
-
emit,
|
|
448
|
-
modelWorkerId: workerId,
|
|
449
|
-
inputRunId: input.runId,
|
|
450
|
-
...(input.resume === undefined ? {} : { resume: input.resume }),
|
|
451
|
-
});
|
|
452
|
-
const status = await this.#workerStatus(workspaceId, workerId);
|
|
453
|
-
emit([
|
|
454
|
-
{ type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
|
|
455
|
-
stateSnapshot({ providers: this.#seam.listProviders().aliases, workspace: { id: workspaceId, name: env.workspaceName, projectRoot: env.projectRoot }, status }),
|
|
456
|
-
]);
|
|
457
|
-
if (finished)
|
|
458
|
-
return;
|
|
459
|
-
try {
|
|
460
|
-
// §3 — a management-action AG-UI Run (forwardedProps.plurnk.action): execute via the
|
|
461
|
-
// seam; the outcome rides the workspace's CURRENT thread binding (Portal.finishRun),
|
|
462
|
-
// never this closure — a proposal-gated action (op.exec → 202) terminates THIS
|
|
463
|
-
// AG-UI Run and completes after the resume AG-UI Run rebinds the stream.
|
|
464
|
-
if (action !== null) {
|
|
465
|
-
const finishAction = (outcome) => {
|
|
466
|
-
const events = [actionResult(action.kind, outcome)];
|
|
467
|
-
// Plain action (stream still open): answer on OUR OWN stream — concurrent
|
|
468
|
-
// actions share a workspace, and the workspace binding is whoever bound last
|
|
469
|
-
// (results would cross streams). Only a proposal-pause (this stream already
|
|
470
|
-
// terminated) hands off to the workspace binding, which the resume AG-UI Run rebinds.
|
|
471
|
-
if (!finished) {
|
|
472
|
-
this.#portal.finishThread(boundRun, events);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
this.#portal.finishRun(workspaceId, lifecycleWorkerId, input.threadId, events);
|
|
476
|
-
};
|
|
477
|
-
void this.#action(action, env, workerId)
|
|
478
|
-
// One queue barrier: a dispatch's channel notifies are enqueued but not yet
|
|
479
|
-
// delivered when its promise resolves — drain them so Portal's stream
|
|
480
|
-
// bookkeeping arms BEFORE the finish decision (then stream/concluded,
|
|
481
|
-
// not a timer, releases any deferral).
|
|
482
|
-
.then(async (outcome) => { await new Promise((r) => setImmediate(r)); finishAction(outcome); })
|
|
483
|
-
.catch((err) => {
|
|
484
|
-
console.error(`AG-UI action '${action.kind}' failed:`, err);
|
|
485
|
-
const problem = problemFromError(err);
|
|
486
|
-
finishAction(problem === null
|
|
487
|
-
? actionFailure("action-failed", "The action failed unexpectedly.", 500)
|
|
488
|
-
: { ok: false, problem });
|
|
489
|
-
});
|
|
490
|
-
res.on("close", () => {
|
|
491
|
-
if (finished)
|
|
492
|
-
return;
|
|
493
|
-
this.#seam.cancelDrain(lifecycleWorkerId, "client_disconnected");
|
|
494
|
-
finish();
|
|
495
|
-
});
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
// AG-UI interrupt resume: this is a new AG-UI Run on the same thread. Bind it
|
|
499
|
-
// to the durable continuation before releasing the proposal.
|
|
500
|
-
if (input.resume !== undefined) {
|
|
501
|
-
await this.#portal.resolve(workspaceId, boundRun, input.resume);
|
|
502
|
-
res.on("close", finish); // client hangup on a resume just detaches; the loop is already active
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
if (prompt === null)
|
|
506
|
-
throw new Error("conversation AG-UI Run reached dispatch without a validated prompt");
|
|
507
|
-
if (reattached) {
|
|
508
|
-
const history = await this.#seam.readLog({ workspaceId, workerId, limit: 1000 }).catch(() => null);
|
|
509
|
-
if (history !== null)
|
|
510
|
-
emit(this.#portal.replay(boundRun, history));
|
|
511
|
-
}
|
|
512
|
-
const started = await this.#portal.run(boundRun, {
|
|
513
|
-
workspaceId, workerId, prompt,
|
|
514
|
-
...(forwarded !== undefined && Object.hasOwn(forwarded, "maxTurns")
|
|
515
|
-
? { maxTurns: forwarded.maxTurns }
|
|
516
|
-
: this.#opts.maxTurns !== undefined ? { maxTurns: this.#opts.maxTurns } : {}),
|
|
517
|
-
...(forwarded !== undefined && Object.hasOwn(forwarded, "policy")
|
|
518
|
-
? { policy: forwarded.policy }
|
|
519
|
-
: {}),
|
|
520
|
-
...(forwarded !== undefined && Object.hasOwn(forwarded, "openPaths")
|
|
521
|
-
? { openPaths: forwarded.openPaths }
|
|
522
|
-
: {}),
|
|
523
|
-
// {§methods-loop-run-model} The client may still forward one explicit
|
|
524
|
-
// alias-or-provider/model selector. The daemon
|
|
525
|
-
// persists an explicit selection onto the worker and snapshots it on the
|
|
526
|
-
// loop; an omitted selector continues the worker's durable model.
|
|
527
|
-
...(forwarded !== undefined && Object.hasOwn(forwarded, "selector")
|
|
528
|
-
? { selector: forwarded.selector }
|
|
529
|
-
: {}),
|
|
530
|
-
...(forwarded !== undefined && Object.hasOwn(forwarded, "childSelector")
|
|
531
|
-
? { childSelector: forwarded.childSelector }
|
|
532
|
-
: {}),
|
|
533
|
-
});
|
|
534
|
-
if (started !== null && !finished) {
|
|
535
|
-
const currentStatus = await this.#workerStatus(workspaceId, workerId);
|
|
536
|
-
if (!finished) {
|
|
537
|
-
emit([stateDelta([{
|
|
538
|
-
op: "replace",
|
|
539
|
-
path: "/plurnk/status",
|
|
540
|
-
value: currentStatus,
|
|
541
|
-
}])]);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
// A dropped SSE on a live AG-UI Run cancels the loop (hangup is the abort). A stream we
|
|
545
|
-
// finished ourselves — terminal event or proposal-terminate — leaves the engine
|
|
546
|
-
// alone (the paused loop is exactly what the resume AG-UI Run needs).
|
|
547
|
-
res.on("close", () => {
|
|
548
|
-
if (finished)
|
|
549
|
-
return;
|
|
550
|
-
this.#seam.cancelDrain(workerId, "client_disconnected");
|
|
551
|
-
finish();
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
catch (err) {
|
|
555
|
-
// {§agui-http-failure} After headers, the frame alone is
|
|
556
|
-
// not enough — the heartbeat interval and the Portal binding are live, and a
|
|
557
|
-
// throw that escapes past finish() leaks them forever (the drill-hang). emit()
|
|
558
|
-
// writes the terminal frame AND finish()es on RUN_ERROR — one door out.
|
|
559
|
-
const exactProblem = problemFromError(err);
|
|
560
|
-
if (exactProblem === null)
|
|
561
|
-
console.error("AG-UI Run failed:", err);
|
|
562
|
-
const problem = exactProblem ?? httpProblem("run-failed", 500, "The AG-UI Run failed unexpectedly.", {
|
|
563
|
-
stage: "run",
|
|
564
|
-
retryable: false,
|
|
565
|
-
});
|
|
566
|
-
emit(runErrorEvents(problem));
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
300
|
// A control-plane AG-UI Run: no world bound. Open the SSE, execute the worldless verb, answer on
|
|
570
301
|
// our own stream. No Portal thread, no model worker, and no ephemeral workspace.
|
|
571
302
|
async #controlRun(action, input, res) {
|
|
@@ -637,321 +368,6 @@ class Module {
|
|
|
637
368
|
return actionFailure("action-failed", "The action failed unexpectedly.", 500);
|
|
638
369
|
}
|
|
639
370
|
}
|
|
640
|
-
// The built-in implementation half of the declarative registry. The registry
|
|
641
|
-
// owns membership, scope, admission, projection, and discovery; this dispatch
|
|
642
|
-
// owns only the corresponding daemon operation.
|
|
643
|
-
async #executeBuiltin(kind, p, env, conversationWorkerId) {
|
|
644
|
-
try {
|
|
645
|
-
// Worldless actions never bind or forge a workspace.
|
|
646
|
-
switch (kind) {
|
|
647
|
-
case "ping": return { ok: true, result: {} };
|
|
648
|
-
case "discover": return { ok: true, result: await this.#capabilities() };
|
|
649
|
-
case "providers.list": return { ok: true, result: this.#seam.listProviders() };
|
|
650
|
-
case "models.list": {
|
|
651
|
-
return { ok: true, result: this.#seam.listModels(Validator.assertModelCatalogQuery(p)) };
|
|
652
|
-
}
|
|
653
|
-
case "workspace.list": return { ok: true, result: { workspaces: await this.#seam.listWorkspaces() } };
|
|
654
|
-
case "workspace.create": {
|
|
655
|
-
// The name IS the identity: an explicit name creates/attaches EXACTLY
|
|
656
|
-
// that workspace; no name = the daemon names it and the real name binds.
|
|
657
|
-
if (Object.hasOwn(p, "name") && (typeof p.name !== "string" || p.name.length === 0)) {
|
|
658
|
-
return actionFailure("invalid-action-parameters", "workspace.create name is not a non-empty string.", 400, { field: "name", recovery: "Provide a non-empty workspace name or omit it." });
|
|
659
|
-
}
|
|
660
|
-
if (typeof p.name === "string") {
|
|
661
|
-
// The name IS the world here — feed it as the workspace so #envelope
|
|
662
|
-
// creates/attaches exactly it (p carries no `workspace` of its own).
|
|
663
|
-
const { env: created } = await this.#envelope(p.name, { ...p, workspace: p.name });
|
|
664
|
-
return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
|
|
665
|
-
}
|
|
666
|
-
const created = await this.#seam.createWorkspace({
|
|
667
|
-
...(Object.hasOwn(p, "projectRoot")
|
|
668
|
-
? { projectRoot: p.projectRoot }
|
|
669
|
-
: {}),
|
|
670
|
-
...(Object.hasOwn(p, "constraints")
|
|
671
|
-
? { constraints: p.constraints }
|
|
672
|
-
: {}),
|
|
673
|
-
...(Object.hasOwn(p, "settings")
|
|
674
|
-
? { settings: p.settings }
|
|
675
|
-
: {}),
|
|
676
|
-
});
|
|
677
|
-
this.#threads.set(created.workspaceName, created);
|
|
678
|
-
return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
|
|
679
|
-
}
|
|
680
|
-
case "workspace.attach": {
|
|
681
|
-
// A REAL attach: rebind the thread map to the chosen workspace and hand
|
|
682
|
-
// back its envelope — the picker does what it says (the unwired kind +
|
|
683
|
-
// a nil-masking fallback produced the 2026-07-10 front-door disaster).
|
|
684
|
-
if (typeof p.id !== "number") {
|
|
685
|
-
return actionFailure("invalid-action-parameters", "workspace.attach requires a numeric workspace id.", 400, {
|
|
686
|
-
field: "id",
|
|
687
|
-
recovery: "Provide a workspace id returned by workspace.list.",
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
const att = await this.#seam.attachWorkspace({ workspaceId: p.id, ...(typeof p.workerId === "number" ? { workerId: p.workerId } : {}) });
|
|
691
|
-
this.#threads.set(att.workspaceName, att);
|
|
692
|
-
return { ok: true, result: { id: att.workspaceId, name: att.workspaceName, workerId: att.workerId } };
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
const world = _a.#requireWorkspace(kind, env);
|
|
696
|
-
switch (kind) {
|
|
697
|
-
case "workspace.workers": return { ok: true, result: { workers: await this.#seam.listWorkers(typeof p.id === "number" ? p.id : world.workspaceId) } };
|
|
698
|
-
case "log.read": {
|
|
699
|
-
// Default worker: the conversation; p.workerId pins another.
|
|
700
|
-
const readWorkerId = typeof p.workerId === "number" ? p.workerId : conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId);
|
|
701
|
-
const entries = await this.#seam.readLog({
|
|
702
|
-
workspaceId: world.workspaceId,
|
|
703
|
-
workerId: readWorkerId,
|
|
704
|
-
...(Object.hasOwn(p, "limit") ? { limit: p.limit } : {}),
|
|
705
|
-
...(Object.hasOwn(p, "sinceId") ? { sinceId: p.sinceId } : {}),
|
|
706
|
-
...(Object.hasOwn(p, "loopId") ? { loopId: p.loopId } : {}),
|
|
707
|
-
...(Object.hasOwn(p, "turnId") ? { turnId: p.turnId } : {}),
|
|
708
|
-
...(Object.hasOwn(p, "loopSeq") ? { loopSeq: p.loopSeq } : {}),
|
|
709
|
-
...(Object.hasOwn(p, "turnSeq") ? { turnSeq: p.turnSeq } : {}),
|
|
710
|
-
...(Object.hasOwn(p, "sequence") ? { sequence: p.sequence } : {}),
|
|
711
|
-
});
|
|
712
|
-
return { ok: true, result: { entries } };
|
|
713
|
-
}
|
|
714
|
-
case "loop.inject": {
|
|
715
|
-
if (typeof p.prompt !== "string" || p.prompt.length === 0) {
|
|
716
|
-
return actionFailure("invalid-action-parameters", "loop.inject requires a non-empty prompt.", 400, { field: "prompt", recovery: "Provide the prompt to inject." });
|
|
717
|
-
}
|
|
718
|
-
const ack = await this.#seam.runLoop({ workspaceId: world.workspaceId, workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId), prompt: p.prompt });
|
|
719
|
-
return operationOutcome(ack);
|
|
720
|
-
}
|
|
721
|
-
// The stop button (TUI /stop + Ctrl-C, nvim :PlurnkStop): abort the model
|
|
722
|
-
// worker's active drain. Mirrors the SSE-hangup abort, addressable as a verb.
|
|
723
|
-
case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId), typeof p.reason === "string" ? p.reason : undefined) } };
|
|
724
|
-
case "workspace.prompts": return {
|
|
725
|
-
ok: true,
|
|
726
|
-
result: {
|
|
727
|
-
prompts: await this.#seam.listPrompts(world.workspaceId, Object.hasOwn(p, "limit") ? p.limit : undefined),
|
|
728
|
-
},
|
|
729
|
-
};
|
|
730
|
-
case "workspace.rename": {
|
|
731
|
-
if (typeof p.name !== "string" || p.name.length === 0) {
|
|
732
|
-
return actionFailure("invalid-action-parameters", "workspace.rename requires a non-empty name.", 400, { field: "name", recovery: "Provide the new workspace name." });
|
|
733
|
-
}
|
|
734
|
-
return { ok: true, result: await this.#seam.renameWorkspace(world.workspaceId, p.name) };
|
|
735
|
-
}
|
|
736
|
-
case "workspace.derivation": return { ok: true, result: { status: this.#seam.workspaceDerivationStatus(world.workspaceId) } };
|
|
737
|
-
case "entry.read": {
|
|
738
|
-
if (typeof p.target !== "string") {
|
|
739
|
-
return actionFailure("invalid-action-parameters", "entry.read requires a string target.", 400, { field: "target", recovery: "Provide an entry URI." });
|
|
740
|
-
}
|
|
741
|
-
if (Object.hasOwn(p, "workerId")
|
|
742
|
-
&& (typeof p.workerId !== "number" || !Number.isSafeInteger(p.workerId) || p.workerId <= 0)) {
|
|
743
|
-
return actionFailure("invalid-action-parameters", "entry.read workerId must be a positive integer.", 400, { field: "workerId", recovery: "Use the workerId supplied with the entry notification." });
|
|
744
|
-
}
|
|
745
|
-
const result = Validator.assertEntryReadResult(await this.#seam.readEntry({
|
|
746
|
-
workspaceId: world.workspaceId,
|
|
747
|
-
workerId: typeof p.workerId === "number"
|
|
748
|
-
? p.workerId
|
|
749
|
-
: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
750
|
-
target: p.target,
|
|
751
|
-
...(Object.hasOwn(p, "channel") ? { channel: p.channel } : {}),
|
|
752
|
-
...(Object.hasOwn(p, "offset") ? { offset: p.offset } : {}),
|
|
753
|
-
}));
|
|
754
|
-
return operationOutcome(result);
|
|
755
|
-
}
|
|
756
|
-
case "op.exec": {
|
|
757
|
-
// EXEC constructed structurally (no DSL text): the model-facing shape,
|
|
758
|
-
// proposal-gated by the engine like any client op.
|
|
759
|
-
if (typeof p.command !== "string" || p.command.length === 0) {
|
|
760
|
-
return actionFailure("invalid-action-parameters", "op.exec requires a non-empty command.", 400, { field: "command", recovery: "Provide the command to execute." });
|
|
761
|
-
}
|
|
762
|
-
const statement = {
|
|
763
|
-
op: "EXEC", delimiter: "", annotation: null, signal: null, target: null,
|
|
764
|
-
metadata: null, lineMarker: null, body: p.command, position: UNKNOWN_POSITION,
|
|
765
|
-
};
|
|
766
|
-
// Client ops journal as client-origin turns in the client worker (worker split:
|
|
767
|
-
// only LOOPS live in the model worker) and execute in the attached Worker's
|
|
768
|
-
// Functionality ({§actor-boundary-attached-functionality}).
|
|
769
|
-
const [result] = await this.#seam.dispatchClientAction({
|
|
770
|
-
workspaceId: world.workspaceId,
|
|
771
|
-
workerId: world.workerId,
|
|
772
|
-
functionalityWorkerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
773
|
-
statements: [statement],
|
|
774
|
-
});
|
|
775
|
-
if (result === undefined)
|
|
776
|
-
throw new Error("op.exec dispatch returned no operation result");
|
|
777
|
-
return operationOutcome(result);
|
|
778
|
-
}
|
|
779
|
-
case "op.parse": {
|
|
780
|
-
// Raw DSL is parsed at the module's edge; statements and parser facts project
|
|
781
|
-
// through one ordered result contract. {§agui-op-parse}
|
|
782
|
-
if (typeof p.text !== "string" || p.text.length === 0) {
|
|
783
|
-
return actionFailure("invalid-action-parameters", "op.parse requires non-empty PLURNK text.", 400, { field: "text", recovery: "Provide PLURNK statements to parse." });
|
|
784
|
-
}
|
|
785
|
-
const parsed = PlurnkParser.parseClient(p.text);
|
|
786
|
-
const results = [];
|
|
787
|
-
const statements = [];
|
|
788
|
-
for (const item of parsed.items) {
|
|
789
|
-
if (item.kind === "error") {
|
|
790
|
-
results.push(parseFailureResult({
|
|
791
|
-
detail: item.error.message,
|
|
792
|
-
line: item.error.line,
|
|
793
|
-
column: item.error.column,
|
|
794
|
-
source: item.error.source,
|
|
795
|
-
severity: item.error.severity,
|
|
796
|
-
}));
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
if (item.kind !== "statement")
|
|
800
|
-
continue; // interstitial text isn't dispatchable
|
|
801
|
-
statements.push(item.statement);
|
|
802
|
-
results.push(null);
|
|
803
|
-
}
|
|
804
|
-
if (parsed.unparsedTail !== undefined) {
|
|
805
|
-
results.push(parseFailureResult({
|
|
806
|
-
detail: parsed.unparsedTail.reason,
|
|
807
|
-
line: parsed.unparsedTail.from.line,
|
|
808
|
-
column: parsed.unparsedTail.from.column,
|
|
809
|
-
source: "grammar",
|
|
810
|
-
severity: "error",
|
|
811
|
-
}));
|
|
812
|
-
}
|
|
813
|
-
const dispatched = statements.length === 0
|
|
814
|
-
? []
|
|
815
|
-
: await this.#seam.dispatchClientAction({
|
|
816
|
-
workspaceId: world.workspaceId,
|
|
817
|
-
workerId: world.workerId,
|
|
818
|
-
functionalityWorkerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
819
|
-
statements,
|
|
820
|
-
});
|
|
821
|
-
let index = 0;
|
|
822
|
-
for (let i = 0; i < results.length; i++) {
|
|
823
|
-
if (results[i] === null)
|
|
824
|
-
results[i] = dispatched[index++];
|
|
825
|
-
}
|
|
826
|
-
return { ok: true, result: { results } };
|
|
827
|
-
}
|
|
828
|
-
case "op.look": {
|
|
829
|
-
// {§agui-op-look}
|
|
830
|
-
if (typeof p.text !== "string" || p.text.length === 0) {
|
|
831
|
-
return actionFailure("invalid-action-parameters", "op.look requires non-empty PLURNK text.", 400, { field: "text", recovery: "Provide one PLURNK statement to observe." });
|
|
832
|
-
}
|
|
833
|
-
const parsed = PlurnkParser.parseClient(p.text);
|
|
834
|
-
const diagnostic = parsed.items.find((item) => item.kind === "error");
|
|
835
|
-
if (diagnostic !== undefined && diagnostic.kind === "error") {
|
|
836
|
-
return operationOutcome(parseFailureResult({
|
|
837
|
-
detail: diagnostic.error.message,
|
|
838
|
-
line: diagnostic.error.line,
|
|
839
|
-
column: diagnostic.error.column,
|
|
840
|
-
source: diagnostic.error.source,
|
|
841
|
-
severity: diagnostic.error.severity,
|
|
842
|
-
}));
|
|
843
|
-
}
|
|
844
|
-
if (parsed.unparsedTail !== undefined) {
|
|
845
|
-
return operationOutcome(parseFailureResult({
|
|
846
|
-
detail: parsed.unparsedTail.reason,
|
|
847
|
-
line: parsed.unparsedTail.from.line,
|
|
848
|
-
column: parsed.unparsedTail.from.column,
|
|
849
|
-
source: "grammar",
|
|
850
|
-
severity: "error",
|
|
851
|
-
}));
|
|
852
|
-
}
|
|
853
|
-
const textItem = parsed.items.find((item) => item.kind === "text");
|
|
854
|
-
if (textItem !== undefined && textItem.kind === "text") {
|
|
855
|
-
return actionFailure("invalid-action-parameters", "op.look parsed text outside the statement; only surrounding whitespace is allowed.", 400, {
|
|
856
|
-
field: "text",
|
|
857
|
-
line: textItem.position.line,
|
|
858
|
-
column: textItem.position.column,
|
|
859
|
-
recovery: "Remove text outside the LOOK statement.",
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
const statements = parsed.items.filter((item) => item.kind === "statement");
|
|
863
|
-
if (statements.length !== 1) {
|
|
864
|
-
return actionFailure("invalid-action-parameters", `op.look parsed ${statements.length} statements; exactly one LOOK statement is required.`, 400, { field: "text", recovery: "Provide exactly one valid LOOK statement." });
|
|
865
|
-
}
|
|
866
|
-
const [item] = statements;
|
|
867
|
-
if (item.statement.op !== "LOOK") {
|
|
868
|
-
return actionFailure("invalid-action-parameters", `op.look parsed ${item.statement.op}; the single statement must be LOOK.`, 400, { field: "text", recovery: "Use LOOK as the observation operation." });
|
|
869
|
-
}
|
|
870
|
-
const statement = { ...item.statement, op: "READ" };
|
|
871
|
-
return operationOutcome(await this.#seam.look({
|
|
872
|
-
workspaceId: world.workspaceId,
|
|
873
|
-
workerId: world.workerId,
|
|
874
|
-
functionalityWorkerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
875
|
-
statement,
|
|
876
|
-
}));
|
|
877
|
-
}
|
|
878
|
-
case "run.fork": return { ok: true, result: await this.#seam.forkWorker({ workspaceId: world.workspaceId, workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId), ...(typeof p.name === "string" ? { name: p.name } : {}) }) };
|
|
879
|
-
case "worker.model.get": {
|
|
880
|
-
const { model, spawnModel } = await this.#seam.readWorkerModel({
|
|
881
|
-
workspaceId: world.workspaceId,
|
|
882
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
883
|
-
});
|
|
884
|
-
return { ok: true, result: { model, spawnModel } };
|
|
885
|
-
}
|
|
886
|
-
case "worker.model.set": {
|
|
887
|
-
if (typeof p.selector !== "string" || p.selector.length === 0) {
|
|
888
|
-
return actionFailure("invalid-action-parameters", "worker.model.set requires a selector.", 400, { recovery: "Provide a declared alias or provider/model route." });
|
|
889
|
-
}
|
|
890
|
-
return { ok: true, result: await this.#seam.setWorkerModel({
|
|
891
|
-
workspaceId: world.workspaceId,
|
|
892
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
893
|
-
selector: p.selector,
|
|
894
|
-
}) };
|
|
895
|
-
}
|
|
896
|
-
case "worker.child.set": {
|
|
897
|
-
if (!Object.hasOwn(p, "selector")
|
|
898
|
-
|| (p.selector !== null && (typeof p.selector !== "string" || p.selector.length === 0))) {
|
|
899
|
-
return actionFailure("invalid-action-parameters", "worker.child.set requires a selector.", 400, { recovery: "Provide a declared alias or provider/model route; null means inherit." });
|
|
900
|
-
}
|
|
901
|
-
return { ok: true, result: await this.#seam.setWorkerSpawnModel({
|
|
902
|
-
workspaceId: world.workspaceId,
|
|
903
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
904
|
-
selector: p.selector,
|
|
905
|
-
}) };
|
|
906
|
-
}
|
|
907
|
-
case "worker.reasoning.get": {
|
|
908
|
-
return { ok: true, result: await this.#seam.readWorkerReasoning({
|
|
909
|
-
workspaceId: world.workspaceId,
|
|
910
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
911
|
-
}) };
|
|
912
|
-
}
|
|
913
|
-
case "worker.reasoning.set": {
|
|
914
|
-
if (!Object.hasOwn(p, "policy")) {
|
|
915
|
-
return actionFailure("invalid-action-parameters", "worker.reasoning.set requires a policy.", 400, { recovery: "Provide a reasoning policy." });
|
|
916
|
-
}
|
|
917
|
-
return { ok: true, result: await this.#seam.setWorkerReasoning({
|
|
918
|
-
workspaceId: world.workspaceId,
|
|
919
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
920
|
-
policy: p.policy,
|
|
921
|
-
}) };
|
|
922
|
-
}
|
|
923
|
-
case "worker.capabilities.get": {
|
|
924
|
-
return { ok: true, result: await this.#seam.readWorkerCapabilities({
|
|
925
|
-
workspaceId: world.workspaceId,
|
|
926
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
927
|
-
}) };
|
|
928
|
-
}
|
|
929
|
-
case "worker.capabilities.set": {
|
|
930
|
-
return { ok: true, result: await this.#seam.setWorkerCapabilities({
|
|
931
|
-
workspaceId: world.workspaceId,
|
|
932
|
-
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(world.workspaceId),
|
|
933
|
-
policy: p.policy,
|
|
934
|
-
}) };
|
|
935
|
-
}
|
|
936
|
-
default: throw new Error(`AG-UI built-in '${kind}' has no executor`);
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
catch (err) {
|
|
940
|
-
const problem = problemFromError(err);
|
|
941
|
-
if (problem !== null)
|
|
942
|
-
return { ok: false, problem };
|
|
943
|
-
console.error(`AG-UI action '${kind}' failed:`, err);
|
|
944
|
-
return actionFailure("action-failed", "The action failed unexpectedly.", 500);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
static #body(req) {
|
|
948
|
-
return new Promise((resolve, reject) => {
|
|
949
|
-
let data = "";
|
|
950
|
-
req.on("data", (c) => { data += c.toString(); });
|
|
951
|
-
req.on("end", () => resolve(data));
|
|
952
|
-
req.on("error", reject);
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
371
|
}
|
|
956
372
|
_a = Module;
|
|
957
373
|
export default Module;
|