@plurnk/plurnk-agui 1.3.12 → 1.4.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 +8 -6
- package/README.md +11 -3
- package/SPEC.md +285 -88
- package/dist/AguiPlus.d.ts +2 -1
- package/dist/AguiPlus.d.ts.map +1 -1
- package/dist/AguiPlus.js +5 -5
- package/dist/AguiPlus.js.map +1 -1
- package/dist/DaemonSeam.d.ts +13 -26
- package/dist/DaemonSeam.d.ts.map +1 -1
- package/dist/DaemonSeam.js +1 -1
- package/dist/DaemonSeam.js.map +1 -1
- package/dist/EventRouter.d.ts.map +1 -1
- package/dist/EventRouter.js +16 -5
- package/dist/EventRouter.js.map +1 -1
- package/dist/Module.d.ts +5 -7
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +425 -158
- package/dist/Module.js.map +1 -1
- package/dist/Portal.d.ts +1 -0
- package/dist/Portal.d.ts.map +1 -1
- package/dist/Portal.js +12 -8
- package/dist/Portal.js.map +1 -1
- package/dist/ProposalHitl.d.ts.map +1 -1
- package/dist/ProposalHitl.js +48 -39
- package/dist/ProposalHitl.js.map +1 -1
- package/dist/Translator.d.ts +1 -1
- package/dist/Translator.d.ts.map +1 -1
- package/dist/Translator.js +90 -69
- package/dist/Translator.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +30 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/observe.d.ts +8 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/observe.js +56 -0
- package/dist/observe.js.map +1 -0
- package/dist/types.d.ts +8 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +13 -8
package/dist/Module.js
CHANGED
|
@@ -1,32 +1,105 @@
|
|
|
1
|
-
// The in-process transport module
|
|
1
|
+
// {§agui-daemon-client} The in-process transport module is what the daemon's boot plug-point
|
|
2
2
|
// activates: registerModule(aguiModule(opts)) hands this the CoreSeam handle; it opens
|
|
3
3
|
// the AG-UI+ HTTP/SSE listener and owns the client interface from there.
|
|
4
4
|
//
|
|
5
|
-
// This is the
|
|
5
|
+
// This is the single external client interface:
|
|
6
6
|
// POST / — the only endpoint. A worker streams SSE. HITL is terminate-resume: a
|
|
7
7
|
// stopped-world emits a request_approval/request_user_input TOOL_CALL and finishes
|
|
8
|
-
// with an AG-UI interrupt outcome (the loop stays paused in-engine); the next
|
|
8
|
+
// with an AG-UI interrupt outcome (the loop stays paused in-engine); the next AG-UI Run's
|
|
9
9
|
// standard resume entries resolve the durable proposal and continue the exact loop.
|
|
10
|
-
// Reads ride STATE_SNAPSHOT on RUN_STARTED;
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Reads ride STATE_SNAPSHOT on RUN_STARTED; there are no side-channel action
|
|
11
|
+
// or proposal-resolution endpoints.
|
|
12
|
+
// An AG-UI threadId names one conversation worker inside the explicitly forwarded
|
|
13
|
+
// workspace ({§agui-thread-binding}); no prefix or inferred workspace is minted.
|
|
13
14
|
var _a;
|
|
14
15
|
import { createServer } from "node:http";
|
|
15
16
|
import Portal from "./Portal.js";
|
|
16
17
|
import { stateSnapshot, parseAction, actionResult } from "./AguiPlus.js";
|
|
17
|
-
import { PlurnkParser } from "@plurnk/plurnk-
|
|
18
|
-
import { authorize as mcpAuthorize, poll as mcpPoll } from "@plurnk/plurnk-execs-mcp";
|
|
18
|
+
import { PlurnkParser, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
|
|
19
19
|
import { EventType } from "./types.js";
|
|
20
|
+
import { aguiRouteTemplate, observed } from "./observe.js";
|
|
20
21
|
import { RunAgentInputSchema } from "@ag-ui/core";
|
|
21
22
|
import { logEntryIdFromToolCallId, proposalInterrupt } from "./AguiPlus.js";
|
|
23
|
+
import { Problems, Validator } from "@plurnk/plurnk-contracts";
|
|
24
|
+
import { resolveModuleOptions } from "./config.js";
|
|
25
|
+
const actionFailure = (code, detail, status = 400, extensions = {}) => ({
|
|
26
|
+
ok: false,
|
|
27
|
+
problem: Problems.create("agui:action", code, status, detail, {
|
|
28
|
+
stage: status < 500 ? "action-validation" : "action-execution",
|
|
29
|
+
retryable: false,
|
|
30
|
+
...extensions,
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const httpProblem = (code, status, detail, extensions = {}) => Problems.create("agui:http", code, status, detail, extensions);
|
|
34
|
+
class HttpProblemError extends Error {
|
|
35
|
+
problem;
|
|
36
|
+
constructor(problem) {
|
|
37
|
+
super(problem.detail);
|
|
38
|
+
this.name = "HttpProblemError";
|
|
39
|
+
this.problem = problem;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const problemFromError = (error) => {
|
|
43
|
+
if (error instanceof HttpProblemError)
|
|
44
|
+
return error.problem;
|
|
45
|
+
if (typeof error !== "object" || error === null)
|
|
46
|
+
return null;
|
|
47
|
+
const result = error.result;
|
|
48
|
+
if (result !== undefined) {
|
|
49
|
+
try {
|
|
50
|
+
return Validator.assertOperationResult(result).problem ?? null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const problem = error.problem;
|
|
57
|
+
if (problem !== undefined) {
|
|
58
|
+
try {
|
|
59
|
+
return Validator.assertProblemDetails(problem);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
const operationOutcome = (result) => {
|
|
68
|
+
const exact = Validator.assertOperationResult(result);
|
|
69
|
+
return exact.problem === undefined
|
|
70
|
+
? { ok: true, result: exact }
|
|
71
|
+
: { ok: false, problem: exact.problem };
|
|
72
|
+
};
|
|
73
|
+
const parseFailureResult = ({ detail, line, column, source, severity, }) => ({
|
|
74
|
+
status: 400,
|
|
75
|
+
problem: Problems.create("agui:action", "parse-failed", 400, detail, {
|
|
76
|
+
line,
|
|
77
|
+
column,
|
|
78
|
+
source,
|
|
79
|
+
severity,
|
|
80
|
+
stage: "parsing",
|
|
81
|
+
retryable: false,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
const writeHttpProblem = (res, problem) => {
|
|
85
|
+
res.writeHead(problem.status, { "content-type": "application/problem+json" });
|
|
86
|
+
res.end(JSON.stringify(problem));
|
|
87
|
+
};
|
|
88
|
+
const runErrorEvents = (problem) => [
|
|
89
|
+
{ type: EventType.CUSTOM, name: "plurnk.problem", value: problem },
|
|
90
|
+
{ type: EventType.RUN_ERROR, message: problem.detail, code: problem.type },
|
|
91
|
+
];
|
|
22
92
|
class Module {
|
|
23
93
|
#seam;
|
|
24
94
|
#opts;
|
|
25
95
|
#portal;
|
|
26
96
|
#http;
|
|
27
97
|
#threads = new Map(); // threadId → envelope
|
|
28
|
-
#
|
|
29
|
-
|
|
98
|
+
#threadWorkers = new Map(); // threadId → conversation workerId
|
|
99
|
+
static #CONTROL_ACTIONS = Object.freeze([
|
|
100
|
+
"ping", "discover", "providers.list", "workspace.list", "workspace.create", "workspace.attach",
|
|
101
|
+
]);
|
|
102
|
+
// The control plane vs the world. An AG-UI Run lives in a world (a conversation, or an action
|
|
30
103
|
// that reads/writes a workspace's log); a control-plane action (list/create/attach/discover/
|
|
31
104
|
// auth) does NOT — so it must not bind or forge a workspace (operator ruling 2026-07-10:
|
|
32
105
|
// "every worker/thread requires a world, not everything"). Only these kinds bind a workspace.
|
|
@@ -35,18 +108,41 @@ class Module {
|
|
|
35
108
|
"workspace.constrain", "workspace.unconstrain", "workspace.constraints", "entry.read",
|
|
36
109
|
"workspace.derivation", "op.exec", "op.parse", "workspace.members", "op.look", "run.fork",
|
|
37
110
|
]));
|
|
111
|
+
static #BUILTIN_ACTIONS = Object.freeze(new Set([
|
|
112
|
+
...this.#CONTROL_ACTIONS,
|
|
113
|
+
...this.#WORLD_SCOPED,
|
|
114
|
+
]));
|
|
115
|
+
static #NOTIFICATIONS = Object.freeze([
|
|
116
|
+
"log/entry", "loop/terminated", "loop/proposal", "notice/event",
|
|
117
|
+
"stream/event", "stream/concluded", "workspace/branch-batch",
|
|
118
|
+
]);
|
|
38
119
|
constructor(seam, opts) {
|
|
39
120
|
this.#seam = seam;
|
|
40
|
-
this.#opts = opts;
|
|
121
|
+
this.#opts = resolveModuleOptions(opts);
|
|
122
|
+
this.#moduleActionNames();
|
|
41
123
|
this.#portal = new Portal(seam);
|
|
42
124
|
this.#http = createServer((req, res) => { void this.#route(req, res); });
|
|
43
125
|
}
|
|
44
|
-
|
|
126
|
+
#moduleActionNames() {
|
|
127
|
+
const names = this.#seam.listModuleActions();
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
if (_a.#BUILTIN_ACTIONS.has(name)) {
|
|
131
|
+
throw new Error(`module action '${name}' collides with AG-UI built-in action`);
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(name))
|
|
134
|
+
throw new Error(`module action '${name}' is registered more than once`);
|
|
135
|
+
seen.add(name);
|
|
136
|
+
}
|
|
137
|
+
return names;
|
|
138
|
+
}
|
|
45
139
|
static init(opts) {
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
140
|
+
return {
|
|
141
|
+
start: async (seam) => {
|
|
142
|
+
const module = new _a(seam, opts);
|
|
143
|
+
await module.listen();
|
|
144
|
+
return module;
|
|
145
|
+
},
|
|
50
146
|
};
|
|
51
147
|
}
|
|
52
148
|
async listen() {
|
|
@@ -68,6 +164,10 @@ class Module {
|
|
|
68
164
|
await new Promise((resolve, reject) => this.#http.close((e) => (e ? reject(e) : resolve())));
|
|
69
165
|
}
|
|
70
166
|
async #route(req, res) {
|
|
167
|
+
return observed(// {§observability-boundary} — only a bounded route class leaves the perimeter.
|
|
168
|
+
"agui.http", { route: aguiRouteTemplate(req.method, req.url) }, () => this.#routeSettled(req, res));
|
|
169
|
+
}
|
|
170
|
+
async #routeSettled(req, res) {
|
|
71
171
|
try {
|
|
72
172
|
res.setHeader("access-control-allow-origin", "*");
|
|
73
173
|
res.setHeader("access-control-allow-headers", "content-type, authorization");
|
|
@@ -75,44 +175,65 @@ class Module {
|
|
|
75
175
|
res.writeHead(204).end();
|
|
76
176
|
return;
|
|
77
177
|
}
|
|
78
|
-
// The perimeter (§
|
|
178
|
+
// The perimeter ({§agui-http-authorization}): bearer check before any body read.
|
|
79
179
|
const token = this.#opts.token ?? "";
|
|
80
180
|
if (token.length > 0 && req.headers.authorization !== `Bearer ${token}`) {
|
|
81
|
-
res
|
|
181
|
+
writeHttpProblem(res, httpProblem("bearer-token-required", 401, "The request did not provide the required bearer token.", {
|
|
182
|
+
stage: "authorization",
|
|
183
|
+
recovery: "Provide the configured bearer token.",
|
|
184
|
+
retryable: false,
|
|
185
|
+
}));
|
|
82
186
|
return;
|
|
83
187
|
}
|
|
84
188
|
if (req.method === "POST" && (req.url === "/" || req.url === "/agui"))
|
|
85
189
|
return await this.#run(req, res);
|
|
86
|
-
res
|
|
190
|
+
writeHttpProblem(res, httpProblem("route-not-found", 404, "The requested HTTP route does not exist.", {
|
|
191
|
+
method: req.method ?? null,
|
|
192
|
+
path: req.url ?? null,
|
|
193
|
+
stage: "routing",
|
|
194
|
+
retryable: false,
|
|
195
|
+
}));
|
|
87
196
|
}
|
|
88
197
|
catch (err) {
|
|
89
|
-
const
|
|
198
|
+
const exactProblem = problemFromError(err);
|
|
199
|
+
if (exactProblem === null) {
|
|
200
|
+
console.error("AG-UI request failed:", err);
|
|
201
|
+
}
|
|
202
|
+
const problem = exactProblem ?? httpProblem("request-failed", 500, "The AG-UI request failed unexpectedly.", {
|
|
203
|
+
stage: "request",
|
|
204
|
+
retryable: false,
|
|
205
|
+
});
|
|
90
206
|
if (!res.headersSent) {
|
|
91
|
-
res
|
|
92
|
-
res.end(JSON.stringify({ error: message }));
|
|
207
|
+
writeHttpProblem(res, problem);
|
|
93
208
|
return;
|
|
94
209
|
}
|
|
95
|
-
//
|
|
96
|
-
// invisible to the event parser
|
|
97
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
210
|
+
// {§agui-http-failure} The SSE is already open, so a JSON body is
|
|
211
|
+
// invisible to the event parser. Preserve a contract Problem exactly when
|
|
212
|
+
// one exists; unexpected exceptions become one generic boundary failure.
|
|
213
|
+
for (const event of runErrorEvents(problem)) {
|
|
214
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
215
|
+
}
|
|
100
216
|
res.end();
|
|
101
217
|
}
|
|
102
218
|
}
|
|
103
219
|
// THE PLURNK PARADIGM (operator ruling 2026-07-10): the name IS the identity,
|
|
104
|
-
// verbatim. The
|
|
220
|
+
// verbatim. The workspace is the world ({§agui-thread-binding}) — selected by name via
|
|
105
221
|
// `forwardedProps.plurnk.workspace`; attach it if it exists, create it with EXACTLY that
|
|
106
222
|
// name if it doesn't. No prefixes, no forged names, no dual lookup. The workspace is
|
|
107
223
|
// REQUIRED: a worker has no existence without a world, so its absence is a contract
|
|
108
224
|
// violation the client must fix — never a workspace forged from the threadId.
|
|
109
225
|
// The threadId is the CONVERSATION over that world — resolved to a worker by
|
|
110
|
-
// #
|
|
226
|
+
// #conversationWorker ({§agui-thread-binding}: the three doors are ensureModelWorker, forkWorker,
|
|
111
227
|
// createConversationWorker).
|
|
112
228
|
async #envelope(threadId, forwarded) {
|
|
113
229
|
const workspace = forwarded?.workspace;
|
|
114
|
-
if (typeof workspace !== "string" || workspace.length === 0)
|
|
115
|
-
throw new
|
|
230
|
+
if (typeof workspace !== "string" || workspace.length === 0) {
|
|
231
|
+
throw new HttpProblemError(httpProblem("workspace-required", 400, "forwardedProps.plurnk.workspace must name a workspace.", {
|
|
232
|
+
stage: "request-validation",
|
|
233
|
+
recovery: "Provide a non-empty workspace name.",
|
|
234
|
+
retryable: false,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
116
237
|
const cached = this.#threads.get(threadId);
|
|
117
238
|
if (cached !== undefined)
|
|
118
239
|
return { env: cached, reattached: true };
|
|
@@ -127,9 +248,15 @@ class Module {
|
|
|
127
248
|
const opts = forwarded ?? {};
|
|
128
249
|
env = await this.#seam.createWorkspace({
|
|
129
250
|
name: workspace,
|
|
130
|
-
...(
|
|
131
|
-
|
|
132
|
-
|
|
251
|
+
...(Object.hasOwn(opts, "projectRoot")
|
|
252
|
+
? { projectRoot: opts.projectRoot }
|
|
253
|
+
: {}),
|
|
254
|
+
...(Object.hasOwn(opts, "constraints")
|
|
255
|
+
? { constraints: opts.constraints }
|
|
256
|
+
: {}),
|
|
257
|
+
...(Object.hasOwn(opts, "settings")
|
|
258
|
+
? { settings: opts.settings }
|
|
259
|
+
: {}),
|
|
133
260
|
});
|
|
134
261
|
}
|
|
135
262
|
this.#threads.set(threadId, env);
|
|
@@ -137,19 +264,34 @@ class Module {
|
|
|
137
264
|
}
|
|
138
265
|
// Resolve the thread's conversation worker within its world. Cached per threadId;
|
|
139
266
|
// worker names are immutable so the binding can't rot.
|
|
140
|
-
async #
|
|
141
|
-
const cached = this.#
|
|
267
|
+
async #conversationWorker(threadId, env) {
|
|
268
|
+
const cached = this.#threadWorkers.get(threadId);
|
|
142
269
|
if (cached !== undefined)
|
|
143
270
|
return cached;
|
|
144
271
|
const workerId = threadId === env.workspaceName
|
|
145
272
|
? await this.#seam.ensureModelWorker(env.workspaceId)
|
|
146
273
|
: (await this.#seam.listWorkers(env.workspaceId)).find((r) => r.name === threadId)?.id
|
|
147
274
|
?? (await this.#seam.createConversationWorker({ workspaceId: env.workspaceId, name: threadId })).workerId;
|
|
148
|
-
this.#
|
|
275
|
+
this.#threadWorkers.set(threadId, workerId);
|
|
149
276
|
return workerId;
|
|
150
277
|
}
|
|
151
278
|
async #run(req, res) {
|
|
152
|
-
|
|
279
|
+
let decoded;
|
|
280
|
+
try {
|
|
281
|
+
decoded = JSON.parse(await _a.#body(req));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new HttpProblemError(httpProblem("invalid-json", 400, "The request body is not valid JSON.", { stage: "request-validation", retryable: false }));
|
|
285
|
+
}
|
|
286
|
+
const parsed = RunAgentInputSchema.safeParse(decoded);
|
|
287
|
+
if (!parsed.success) {
|
|
288
|
+
throw new HttpProblemError(httpProblem("invalid-run-input", 400, "The request body does not satisfy the AG-UI RunAgentInput contract.", {
|
|
289
|
+
stage: "request-validation",
|
|
290
|
+
issues: parsed.error.issues,
|
|
291
|
+
retryable: false,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
const input = parsed.data;
|
|
153
295
|
const forwarded = input.forwardedProps?.plurnk;
|
|
154
296
|
// Control plane FIRST: a management action that doesn't live in a world (and an
|
|
155
297
|
// unknown kind, which is no worker at all) answers without binding — or forging — a
|
|
@@ -157,21 +299,31 @@ class Module {
|
|
|
157
299
|
const action = parseAction(input.forwardedProps);
|
|
158
300
|
if (action !== null && !_a.#WORLD_SCOPED.has(action.kind))
|
|
159
301
|
return await this.#controlRun(action, input, res);
|
|
302
|
+
let prompt = null;
|
|
303
|
+
if (action === null && input.resume === undefined) {
|
|
304
|
+
const lastUser = [...input.messages].reverse().find((message) => message.role === "user");
|
|
305
|
+
if (lastUser === undefined || typeof lastUser.content !== "string" || lastUser.content.length === 0) {
|
|
306
|
+
throw new HttpProblemError(httpProblem("user-message-required", 400, "A new AG-UI Run requires a non-empty textual user message.", {
|
|
307
|
+
stage: "request-validation",
|
|
308
|
+
recovery: "Provide a non-empty user message.",
|
|
309
|
+
retryable: false,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
prompt = lastUser.content;
|
|
313
|
+
}
|
|
160
314
|
const { env, reattached } = await this.#envelope(input.threadId, forwarded);
|
|
161
315
|
const workspaceId = env.workspaceId;
|
|
162
|
-
//
|
|
316
|
+
// {§agui-thread-binding} The threadId is the conversation over the
|
|
163
317
|
// world. threadId == workspace name binds the model worker (the default conversation);
|
|
164
318
|
// a distinct threadId names its own worker: found by name, else minted via
|
|
165
319
|
// createConversationWorker. The name is the identity at BOTH levels.
|
|
166
|
-
const workerId = await this.#
|
|
320
|
+
const workerId = await this.#conversationWorker(input.threadId, env);
|
|
167
321
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
168
322
|
let finished = false;
|
|
169
323
|
const interrupts = [];
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
// every few seconds keeps every consumer fed; parsers skip comments by spec.
|
|
174
|
-
const cadence = this.#opts.heartbeatMs ?? 15_000;
|
|
324
|
+
// {§agui-configuration}: long generations can emit no events; SSE comments keep
|
|
325
|
+
// consumers fed without adding protocol events.
|
|
326
|
+
const cadence = this.#opts.heartbeatMs;
|
|
175
327
|
const heartbeat = cadence > 0 ? setInterval(() => { res.write(": hb\n\n"); }, cadence) : null;
|
|
176
328
|
const finish = () => {
|
|
177
329
|
if (finished)
|
|
@@ -186,7 +338,7 @@ class Module {
|
|
|
186
338
|
for (const e of events) {
|
|
187
339
|
res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
188
340
|
// Terminate-resume, the terminate half: a proposal tool-call ENDS this
|
|
189
|
-
//
|
|
341
|
+
// AG-UI Run (the loop stays paused in-engine awaiting the resume Run).
|
|
190
342
|
if (e.type === "TOOL_CALL_END") {
|
|
191
343
|
const logEntryId = logEntryIdFromToolCallId(e.toolCallId);
|
|
192
344
|
if (logEntryId !== null)
|
|
@@ -214,17 +366,17 @@ class Module {
|
|
|
214
366
|
if (finished)
|
|
215
367
|
return;
|
|
216
368
|
try {
|
|
217
|
-
// §3 — a management
|
|
369
|
+
// §3 — a management-action AG-UI Run (forwardedProps.plurnk.action): execute via the
|
|
218
370
|
// seam; the outcome rides the workspace's CURRENT thread binding (Portal.finishRun),
|
|
219
371
|
// never this closure — a proposal-gated action (op.exec → 202) terminates THIS
|
|
220
|
-
//
|
|
372
|
+
// AG-UI Run and completes after the resume AG-UI Run rebinds the stream.
|
|
221
373
|
if (action !== null) {
|
|
222
374
|
const finishAction = (outcome) => {
|
|
223
375
|
const events = [actionResult(action.kind, outcome)];
|
|
224
376
|
// Plain action (stream still open): answer on OUR OWN stream — concurrent
|
|
225
377
|
// actions share a workspace, and the workspace binding is whoever bound last
|
|
226
378
|
// (results would cross streams). Only a proposal-pause (this stream already
|
|
227
|
-
// terminated) hands off to the workspace binding, which the resume
|
|
379
|
+
// terminated) hands off to the workspace binding, which the resume AG-UI Run rebinds.
|
|
228
380
|
if (!finished) {
|
|
229
381
|
this.#portal.finishThread(boundRun, events);
|
|
230
382
|
return;
|
|
@@ -237,7 +389,13 @@ class Module {
|
|
|
237
389
|
// bookkeeping arms BEFORE the finish decision (then stream/concluded,
|
|
238
390
|
// not a timer, releases any deferral).
|
|
239
391
|
.then(async (outcome) => { await new Promise((r) => setImmediate(r)); finishAction(outcome); })
|
|
240
|
-
.catch((err) =>
|
|
392
|
+
.catch((err) => {
|
|
393
|
+
console.error(`AG-UI action '${action.kind}' failed:`, err);
|
|
394
|
+
const problem = problemFromError(err);
|
|
395
|
+
finishAction(problem === null
|
|
396
|
+
? actionFailure("action-failed", "The action failed unexpectedly.", 500)
|
|
397
|
+
: { ok: false, problem });
|
|
398
|
+
});
|
|
241
399
|
res.on("close", () => {
|
|
242
400
|
if (finished)
|
|
243
401
|
return;
|
|
@@ -246,38 +404,44 @@ class Module {
|
|
|
246
404
|
});
|
|
247
405
|
return;
|
|
248
406
|
}
|
|
249
|
-
// AG-UI interrupt resume: this is a
|
|
407
|
+
// AG-UI interrupt resume: this is a new AG-UI Run on the same thread. Bind it
|
|
250
408
|
// to the durable continuation before releasing the proposal.
|
|
251
409
|
if (input.resume !== undefined) {
|
|
252
410
|
await this.#portal.resolve(workspaceId, boundRun, input.resume);
|
|
253
|
-
res.on("close", finish); // client hangup on a resume just detaches; the loop already
|
|
411
|
+
res.on("close", finish); // client hangup on a resume just detaches; the loop is already active
|
|
254
412
|
return;
|
|
255
413
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
throw new Error("RunAgentInput.messages must carry a user message");
|
|
259
|
-
if (typeof lastUser.content !== "string")
|
|
260
|
-
throw new Error("PLURNK currently requires textual user message content");
|
|
261
|
-
if (lastUser.content.length === 0)
|
|
262
|
-
throw new Error("RunAgentInput.messages must carry a non-empty user message");
|
|
414
|
+
if (prompt === null)
|
|
415
|
+
throw new Error("conversation AG-UI Run reached dispatch without a validated prompt");
|
|
263
416
|
if (reattached) {
|
|
264
417
|
const history = await this.#seam.readLog({ workspaceId, workerId, limit: 1000 }).catch(() => null);
|
|
265
418
|
if (history !== null)
|
|
266
|
-
emit(
|
|
419
|
+
emit(this.#portal.replay(boundRun, history));
|
|
267
420
|
}
|
|
268
421
|
await this.#portal.run(boundRun, {
|
|
269
|
-
workspaceId, workerId, prompt
|
|
270
|
-
...(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
422
|
+
workspaceId, workerId, prompt,
|
|
423
|
+
...(forwarded !== undefined && Object.hasOwn(forwarded, "maxTurns")
|
|
424
|
+
? { maxTurns: forwarded.maxTurns }
|
|
425
|
+
: this.#opts.maxTurns !== undefined ? { maxTurns: this.#opts.maxTurns } : {}),
|
|
426
|
+
...(forwarded !== undefined && Object.hasOwn(forwarded, "flags")
|
|
427
|
+
? { flags: forwarded.flags }
|
|
428
|
+
: {}),
|
|
429
|
+
...(forwarded !== undefined && Object.hasOwn(forwarded, "openPaths")
|
|
430
|
+
? { openPaths: forwarded.openPaths }
|
|
431
|
+
: {}),
|
|
432
|
+
// {§methods-loop-run-model} The client sends alias and model on every loop;
|
|
433
|
+
// model is the client-resolved <provider>/<model>. Forward both, and the daemon's
|
|
274
434
|
// runLoop applies precedence (model wins) and resolves the provider per loop.
|
|
275
|
-
...(
|
|
276
|
-
|
|
435
|
+
...(forwarded !== undefined && Object.hasOwn(forwarded, "alias")
|
|
436
|
+
? { alias: forwarded.alias }
|
|
437
|
+
: {}),
|
|
438
|
+
...(forwarded !== undefined && Object.hasOwn(forwarded, "model")
|
|
439
|
+
? { model: forwarded.model }
|
|
440
|
+
: {}),
|
|
277
441
|
});
|
|
278
|
-
// A dropped SSE on a
|
|
442
|
+
// A dropped SSE on a live AG-UI Run cancels the loop (hangup is the abort). A stream we
|
|
279
443
|
// finished ourselves — terminal event or proposal-terminate — leaves the engine
|
|
280
|
-
// alone (the paused loop is exactly what the resume
|
|
444
|
+
// alone (the paused loop is exactly what the resume AG-UI Run needs).
|
|
281
445
|
res.on("close", () => {
|
|
282
446
|
if (finished)
|
|
283
447
|
return;
|
|
@@ -286,65 +450,94 @@ class Module {
|
|
|
286
450
|
});
|
|
287
451
|
}
|
|
288
452
|
catch (err) {
|
|
289
|
-
//
|
|
453
|
+
// {§agui-http-failure} After headers, the frame alone is
|
|
290
454
|
// not enough — the heartbeat interval and the Portal binding are live, and a
|
|
291
455
|
// throw that escapes past finish() leaks them forever (the drill-hang). emit()
|
|
292
456
|
// writes the terminal frame AND finish()es on RUN_ERROR — one door out.
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
457
|
+
const exactProblem = problemFromError(err);
|
|
458
|
+
if (exactProblem === null)
|
|
459
|
+
console.error("AG-UI Run failed:", err);
|
|
460
|
+
const problem = exactProblem ?? httpProblem("run-failed", 500, "The AG-UI Run failed unexpectedly.", {
|
|
461
|
+
stage: "run",
|
|
462
|
+
retryable: false,
|
|
463
|
+
});
|
|
464
|
+
emit(runErrorEvents(problem));
|
|
296
465
|
}
|
|
297
466
|
}
|
|
298
|
-
// A control-plane
|
|
467
|
+
// A control-plane AG-UI Run: no world bound. Open the SSE, execute the worldless verb, answer on
|
|
299
468
|
// our own stream. No Portal thread, no model worker — nothing to forge (operator ruling:
|
|
300
469
|
// workspace-plane actions must not spin an ephemeral workspace).
|
|
301
470
|
async #controlRun(action, input, res) {
|
|
302
471
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
303
472
|
const emit = (e) => { res.write(`data: ${JSON.stringify(e)}\n\n`); };
|
|
304
473
|
emit({ type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId });
|
|
305
|
-
const outcome = await this.#action(action, null)
|
|
474
|
+
const outcome = await this.#action(action, null)
|
|
475
|
+
.catch((err) => {
|
|
476
|
+
console.error(`AG-UI action '${action.kind}' failed:`, err);
|
|
477
|
+
const problem = problemFromError(err);
|
|
478
|
+
return problem === null
|
|
479
|
+
? actionFailure("action-failed", "The action failed unexpectedly.", 500)
|
|
480
|
+
: { ok: false, problem };
|
|
481
|
+
});
|
|
306
482
|
emit(actionResult(action.kind, outcome));
|
|
307
483
|
emit({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId, outcome: { type: "success" } });
|
|
308
484
|
res.end();
|
|
309
485
|
}
|
|
310
|
-
// The capability manifest a client probes (`discover`)
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
#capabilities() {
|
|
486
|
+
// The capability manifest a client probes (`discover`) for exact action/event membership.
|
|
487
|
+
// The built-ins come from the same inventories routing uses; extension names come from core.
|
|
488
|
+
async #capabilities() {
|
|
314
489
|
const methods = {};
|
|
315
|
-
for (const k of [
|
|
490
|
+
for (const k of [
|
|
491
|
+
..._a.#CONTROL_ACTIONS,
|
|
492
|
+
..._a.#WORLD_SCOPED,
|
|
493
|
+
...this.#moduleActionNames(),
|
|
494
|
+
]) {
|
|
495
|
+
if (methods[k])
|
|
496
|
+
throw new Error(`AG-UI action '${k}' is registered more than once`);
|
|
316
497
|
methods[k] = true;
|
|
498
|
+
}
|
|
317
499
|
const notifications = {};
|
|
318
|
-
for (const n of
|
|
500
|
+
for (const n of _a.#NOTIFICATIONS)
|
|
319
501
|
notifications[n] = true;
|
|
320
|
-
|
|
502
|
+
const display = Validator.assertClientDisplayCapabilities(await this.#seam.listClientDisplayCapabilities());
|
|
503
|
+
return { methods, notifications, display };
|
|
321
504
|
}
|
|
322
505
|
// The action executor — the verb surface. The control plane runs worldless; everything
|
|
323
506
|
// below the guard operates within a bound workspace. An unknown kind is an honest error,
|
|
324
507
|
// never a silent pass-through. loop.inject rides here too (§4): the seam's unified
|
|
325
508
|
// runLoop folds a prompt into the active drain; the steered effect streams on the SSE.
|
|
326
|
-
async #action(a, env,
|
|
509
|
+
async #action(a, env, conversationWorkerId) {
|
|
327
510
|
const p = a.params;
|
|
511
|
+
const moduleActions = this.#moduleActionNames();
|
|
328
512
|
try {
|
|
329
513
|
// The control plane — worldless verbs (no bound workspace; #WORLD_SCOPED gates this).
|
|
330
514
|
switch (a.kind) {
|
|
331
515
|
case "ping": return { ok: true, result: {} };
|
|
332
|
-
case "discover": return { ok: true, result: this.#capabilities() };
|
|
516
|
+
case "discover": return { ok: true, result: await this.#capabilities() };
|
|
333
517
|
case "providers.list": return { ok: true, result: this.#seam.listProviders() };
|
|
334
518
|
case "workspace.list": return { ok: true, result: { workspaces: await this.#seam.listWorkspaces() } };
|
|
335
519
|
case "workspace.create": {
|
|
336
520
|
// The name IS the identity: an explicit name creates/attaches EXACTLY
|
|
337
521
|
// that workspace; no name = the daemon names it and the real name binds.
|
|
338
|
-
if (typeof p.name
|
|
522
|
+
if (Object.hasOwn(p, "name") && (typeof p.name !== "string" || p.name.length === 0)) {
|
|
523
|
+
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." });
|
|
524
|
+
}
|
|
525
|
+
if (typeof p.name === "string") {
|
|
339
526
|
// The name IS the world here — feed it as the workspace so #envelope
|
|
340
527
|
// creates/attaches exactly it (p carries no `workspace` of its own).
|
|
341
528
|
const { env: created } = await this.#envelope(p.name, { ...p, workspace: p.name });
|
|
342
529
|
return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
|
|
343
530
|
}
|
|
344
531
|
const created = await this.#seam.createWorkspace({
|
|
345
|
-
...(
|
|
346
|
-
|
|
347
|
-
|
|
532
|
+
...(Object.hasOwn(p, "projectRoot")
|
|
533
|
+
? { projectRoot: p.projectRoot }
|
|
534
|
+
: {}),
|
|
535
|
+
...(Object.hasOwn(p, "constraints")
|
|
536
|
+
? { constraints: p.constraints }
|
|
537
|
+
: {}),
|
|
538
|
+
...(Object.hasOwn(p, "settings")
|
|
539
|
+
? { settings: p.settings }
|
|
540
|
+
: {}),
|
|
348
541
|
});
|
|
349
542
|
this.#threads.set(created.workspaceName, created);
|
|
350
543
|
return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
|
|
@@ -353,94 +546,132 @@ class Module {
|
|
|
353
546
|
// A REAL attach: rebind the thread map to the chosen workspace and hand
|
|
354
547
|
// back its envelope — the picker does what it says (the unwired kind +
|
|
355
548
|
// a nil-masking fallback produced the 2026-07-10 front-door disaster).
|
|
356
|
-
if (typeof p.id !== "number")
|
|
357
|
-
return
|
|
549
|
+
if (typeof p.id !== "number") {
|
|
550
|
+
return actionFailure("invalid-action-parameters", "workspace.attach requires a numeric workspace id.", 400, {
|
|
551
|
+
field: "id",
|
|
552
|
+
recovery: "Provide a workspace id returned by workspace.list.",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
358
555
|
const att = await this.#seam.attachWorkspace({ workspaceId: p.id, ...(typeof p.workerId === "number" ? { workerId: p.workerId } : {}) });
|
|
359
556
|
this.#threads.set(att.workspaceName, att);
|
|
360
|
-
return { ok: true, result: { id: att.workspaceId, name: att.workspaceName, workerId: att.workerId
|
|
361
|
-
}
|
|
362
|
-
case "auth.authorize": {
|
|
363
|
-
// Stateless relay to the execs-mcp driver (settled: no auth seam — the
|
|
364
|
-
// driver owns its mechanics; the bearer overlays its own config registry).
|
|
365
|
-
if (typeof p.target !== "string" || p.target.length === 0)
|
|
366
|
-
return { ok: false, error: "auth.authorize requires target" };
|
|
367
|
-
return { ok: true, result: await mcpAuthorize(p.target) };
|
|
368
|
-
}
|
|
369
|
-
case "auth.authorize.poll": {
|
|
370
|
-
if (typeof p.target !== "string" || p.target.length === 0)
|
|
371
|
-
return { ok: false, error: "auth.authorize.poll requires target" };
|
|
372
|
-
return { ok: true, result: await mcpPoll(p.target, { device: p.device }) };
|
|
557
|
+
return { ok: true, result: { id: att.workspaceId, name: att.workspaceName, workerId: att.workerId } };
|
|
373
558
|
}
|
|
374
559
|
}
|
|
560
|
+
if (moduleActions.includes(a.kind)) {
|
|
561
|
+
return { ok: true, result: await this.#seam.invokeModuleAction(a.kind, p) };
|
|
562
|
+
}
|
|
375
563
|
// Below this line lives IN a world. An unknown kind is no worker at all; a
|
|
376
564
|
// world-scoped kind with no bound workspace is a routing bug — both surface plainly.
|
|
377
|
-
if (!_a.#WORLD_SCOPED.has(a.kind))
|
|
378
|
-
return
|
|
565
|
+
if (!_a.#WORLD_SCOPED.has(a.kind)) {
|
|
566
|
+
return actionFailure("unknown-action", `Action '${a.kind}' is not registered.`, 404, {
|
|
567
|
+
requestedAction: a.kind,
|
|
568
|
+
recovery: "Use an action advertised by discover.",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
379
571
|
if (env === null)
|
|
380
572
|
throw new Error(`action '${a.kind}' operates within a workspace, but none is bound`);
|
|
381
573
|
switch (a.kind) {
|
|
382
574
|
case "workspace.workers": return { ok: true, result: { workers: await this.#seam.listWorkers(typeof p.id === "number" ? p.id : env.workspaceId) } };
|
|
383
575
|
case "log.read": {
|
|
384
|
-
// Default
|
|
385
|
-
const
|
|
386
|
-
const entries = await this.#seam.readLog({
|
|
576
|
+
// Default worker: the conversation; p.workerId pins another.
|
|
577
|
+
const readWorkerId = typeof p.workerId === "number" ? p.workerId : conversationWorkerId ?? await this.#seam.ensureModelWorker(env.workspaceId);
|
|
578
|
+
const entries = await this.#seam.readLog({
|
|
579
|
+
workspaceId: env.workspaceId,
|
|
580
|
+
workerId: readWorkerId,
|
|
581
|
+
...(Object.hasOwn(p, "limit") ? { limit: p.limit } : {}),
|
|
582
|
+
...(Object.hasOwn(p, "sinceId") ? { sinceId: p.sinceId } : {}),
|
|
583
|
+
...(Object.hasOwn(p, "loopId") ? { loopId: p.loopId } : {}),
|
|
584
|
+
...(Object.hasOwn(p, "turnId") ? { turnId: p.turnId } : {}),
|
|
585
|
+
...(Object.hasOwn(p, "loopSeq") ? { loopSeq: p.loopSeq } : {}),
|
|
586
|
+
...(Object.hasOwn(p, "turnSeq") ? { turnSeq: p.turnSeq } : {}),
|
|
587
|
+
...(Object.hasOwn(p, "sequence") ? { sequence: p.sequence } : {}),
|
|
588
|
+
});
|
|
387
589
|
return { ok: true, result: { entries } };
|
|
388
590
|
}
|
|
389
591
|
case "loop.inject": {
|
|
390
|
-
if (typeof p.prompt !== "string" || p.prompt.length === 0)
|
|
391
|
-
return {
|
|
392
|
-
|
|
393
|
-
|
|
592
|
+
if (typeof p.prompt !== "string" || p.prompt.length === 0) {
|
|
593
|
+
return actionFailure("invalid-action-parameters", "loop.inject requires a non-empty prompt.", 400, { field: "prompt", recovery: "Provide the prompt to inject." });
|
|
594
|
+
}
|
|
595
|
+
const ack = await this.#seam.runLoop({ workspaceId: env.workspaceId, workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(env.workspaceId), prompt: p.prompt });
|
|
596
|
+
return operationOutcome(ack);
|
|
394
597
|
}
|
|
395
598
|
// The stop button (TUI /stop + Ctrl-C, nvim :PlurnkStop): abort the model
|
|
396
599
|
// worker's active drain. Mirrors the SSE-hangup abort, addressable as a verb.
|
|
397
|
-
case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(
|
|
398
|
-
case "workspace.prompts": return {
|
|
600
|
+
case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(conversationWorkerId ?? await this.#seam.ensureModelWorker(env.workspaceId)) } };
|
|
601
|
+
case "workspace.prompts": return {
|
|
602
|
+
ok: true,
|
|
603
|
+
result: {
|
|
604
|
+
prompts: await this.#seam.listPrompts(env.workspaceId, Object.hasOwn(p, "limit") ? p.limit : undefined),
|
|
605
|
+
},
|
|
606
|
+
};
|
|
399
607
|
case "workspace.rename": {
|
|
400
|
-
if (typeof p.name !== "string" || p.name.length === 0)
|
|
401
|
-
return {
|
|
608
|
+
if (typeof p.name !== "string" || p.name.length === 0) {
|
|
609
|
+
return actionFailure("invalid-action-parameters", "workspace.rename requires a non-empty name.", 400, { field: "name", recovery: "Provide the new workspace name." });
|
|
610
|
+
}
|
|
402
611
|
return { ok: true, result: await this.#seam.renameWorkspace(env.workspaceId, p.name) };
|
|
403
612
|
}
|
|
404
613
|
case "workspace.constrain": {
|
|
405
|
-
if (typeof p.effect !== "string" || typeof p.glob !== "string")
|
|
406
|
-
return
|
|
614
|
+
if (typeof p.effect !== "string" || typeof p.glob !== "string") {
|
|
615
|
+
return actionFailure("invalid-action-parameters", "workspace.constrain requires string effect and glob parameters.", 400, { fields: ["effect", "glob"], recovery: "Provide both constraint parameters." });
|
|
616
|
+
}
|
|
407
617
|
return { ok: true, result: await this.#seam.constrain(env.workspaceId, p.effect, p.glob) };
|
|
408
618
|
}
|
|
409
619
|
case "workspace.unconstrain": {
|
|
410
|
-
if (typeof p.effect !== "string" || typeof p.glob !== "string")
|
|
411
|
-
return
|
|
620
|
+
if (typeof p.effect !== "string" || typeof p.glob !== "string") {
|
|
621
|
+
return actionFailure("invalid-action-parameters", "workspace.unconstrain requires string effect and glob parameters.", 400, { fields: ["effect", "glob"], recovery: "Provide both constraint parameters." });
|
|
622
|
+
}
|
|
412
623
|
return { ok: true, result: await this.#seam.unconstrain(env.workspaceId, p.effect, p.glob) };
|
|
413
624
|
}
|
|
414
625
|
case "workspace.constraints": return { ok: true, result: { constraints: await this.#seam.listConstraints(env.workspaceId) } };
|
|
415
626
|
case "workspace.derivation": return { ok: true, result: { status: this.#seam.workspaceDerivationStatus(env.workspaceId) } };
|
|
416
627
|
case "entry.read": {
|
|
417
|
-
if (typeof p.target !== "string")
|
|
418
|
-
return {
|
|
419
|
-
|
|
628
|
+
if (typeof p.target !== "string") {
|
|
629
|
+
return actionFailure("invalid-action-parameters", "entry.read requires a string target.", 400, { field: "target", recovery: "Provide an entry URI." });
|
|
630
|
+
}
|
|
631
|
+
const result = Validator.assertEntryReadResult(await this.#seam.readEntry({
|
|
632
|
+
workspaceId: env.workspaceId,
|
|
633
|
+
workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(env.workspaceId),
|
|
634
|
+
target: p.target,
|
|
635
|
+
...(Object.hasOwn(p, "channel") ? { channel: p.channel } : {}),
|
|
636
|
+
...(Object.hasOwn(p, "offset") ? { offset: p.offset } : {}),
|
|
637
|
+
}));
|
|
638
|
+
return operationOutcome(result);
|
|
420
639
|
}
|
|
421
640
|
case "op.exec": {
|
|
422
641
|
// EXEC constructed structurally (no DSL text): the model-facing shape,
|
|
423
642
|
// proposal-gated by the engine like any client op.
|
|
424
|
-
if (typeof p.command !== "string" || p.command.length === 0)
|
|
425
|
-
return {
|
|
426
|
-
|
|
427
|
-
|
|
643
|
+
if (typeof p.command !== "string" || p.command.length === 0) {
|
|
644
|
+
return actionFailure("invalid-action-parameters", "op.exec requires a non-empty command.", 400, { field: "command", recovery: "Provide the command to execute." });
|
|
645
|
+
}
|
|
646
|
+
const statement = {
|
|
647
|
+
op: "EXEC", suffix: "", signal: null, target: null,
|
|
648
|
+
lineMarker: null, body: p.command, position: UNKNOWN_POSITION,
|
|
649
|
+
};
|
|
650
|
+
// Client ops journal as client-origin turns in the client worker (worker split:
|
|
428
651
|
// only LOOPS live in the model worker).
|
|
429
652
|
const [result] = await this.#seam.dispatchClientAction({ workspaceId: env.workspaceId, workerId: env.workerId, statements: [statement] });
|
|
430
|
-
|
|
653
|
+
if (result === undefined)
|
|
654
|
+
throw new Error("op.exec dispatch returned no operation result");
|
|
655
|
+
return operationOutcome(result);
|
|
431
656
|
}
|
|
432
657
|
case "op.parse": {
|
|
433
|
-
// Raw DSL parsed at the module's edge
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
658
|
+
// Raw DSL is parsed at the module's edge; statements and parser facts project
|
|
659
|
+
// through one ordered result contract. {§agui-op-parse}
|
|
660
|
+
if (typeof p.text !== "string" || p.text.length === 0) {
|
|
661
|
+
return actionFailure("invalid-action-parameters", "op.parse requires non-empty PLURNK text.", 400, { field: "text", recovery: "Provide PLURNK statements to parse." });
|
|
662
|
+
}
|
|
438
663
|
const parsed = PlurnkParser.parseClient(p.text);
|
|
439
664
|
const results = [];
|
|
440
665
|
const statements = [];
|
|
441
666
|
for (const item of parsed.items) {
|
|
442
667
|
if (item.kind === "error") {
|
|
443
|
-
results.push({
|
|
668
|
+
results.push(parseFailureResult({
|
|
669
|
+
detail: item.error.message,
|
|
670
|
+
line: item.error.line,
|
|
671
|
+
column: item.error.column,
|
|
672
|
+
source: item.error.source,
|
|
673
|
+
severity: item.error.severity,
|
|
674
|
+
}));
|
|
444
675
|
continue;
|
|
445
676
|
}
|
|
446
677
|
if (item.kind !== "statement")
|
|
@@ -448,6 +679,15 @@ class Module {
|
|
|
448
679
|
statements.push(item.statement);
|
|
449
680
|
results.push(null);
|
|
450
681
|
}
|
|
682
|
+
if (parsed.unparsedTail !== undefined) {
|
|
683
|
+
results.push(parseFailureResult({
|
|
684
|
+
detail: parsed.unparsedTail.reason,
|
|
685
|
+
line: parsed.unparsedTail.from.line,
|
|
686
|
+
column: parsed.unparsedTail.from.column,
|
|
687
|
+
source: "grammar",
|
|
688
|
+
severity: "error",
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
451
691
|
const dispatched = statements.length === 0
|
|
452
692
|
? []
|
|
453
693
|
: await this.#seam.dispatchClientAction({ workspaceId: env.workspaceId, workerId: env.workerId, statements });
|
|
@@ -460,37 +700,64 @@ class Module {
|
|
|
460
700
|
}
|
|
461
701
|
case "workspace.members": return { ok: true, result: await this.#seam.listMembers(env.workspaceId) };
|
|
462
702
|
case "op.look": {
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
703
|
+
// {§agui-op-look}
|
|
704
|
+
if (typeof p.text !== "string" || p.text.length === 0) {
|
|
705
|
+
return actionFailure("invalid-action-parameters", "op.look requires non-empty PLURNK text.", 400, { field: "text", recovery: "Provide one PLURNK statement to observe." });
|
|
706
|
+
}
|
|
467
707
|
const parsed = PlurnkParser.parseClient(p.text);
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
return {
|
|
708
|
+
const diagnostic = parsed.items.find((item) => item.kind === "error");
|
|
709
|
+
if (diagnostic !== undefined && diagnostic.kind === "error") {
|
|
710
|
+
return operationOutcome(parseFailureResult({
|
|
711
|
+
detail: diagnostic.error.message,
|
|
712
|
+
line: diagnostic.error.line,
|
|
713
|
+
column: diagnostic.error.column,
|
|
714
|
+
source: diagnostic.error.source,
|
|
715
|
+
severity: diagnostic.error.severity,
|
|
716
|
+
}));
|
|
717
|
+
}
|
|
718
|
+
if (parsed.unparsedTail !== undefined) {
|
|
719
|
+
return operationOutcome(parseFailureResult({
|
|
720
|
+
detail: parsed.unparsedTail.reason,
|
|
721
|
+
line: parsed.unparsedTail.from.line,
|
|
722
|
+
column: parsed.unparsedTail.from.column,
|
|
723
|
+
source: "grammar",
|
|
724
|
+
severity: "error",
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
const textItem = parsed.items.find((item) => item.kind === "text");
|
|
728
|
+
if (textItem !== undefined && textItem.kind === "text") {
|
|
729
|
+
return actionFailure("invalid-action-parameters", "op.look parsed text outside the statement; only surrounding whitespace is allowed.", 400, {
|
|
730
|
+
field: "text",
|
|
731
|
+
line: textItem.position.line,
|
|
732
|
+
column: textItem.position.column,
|
|
733
|
+
recovery: "Remove text outside the LOOK statement.",
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
const statements = parsed.items.filter((item) => item.kind === "statement");
|
|
737
|
+
if (statements.length !== 1) {
|
|
738
|
+
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." });
|
|
739
|
+
}
|
|
740
|
+
const [item] = statements;
|
|
741
|
+
if (item.statement.op !== "LOOK") {
|
|
742
|
+
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." });
|
|
743
|
+
}
|
|
471
744
|
const statement = { ...item.statement, op: "READ" };
|
|
472
|
-
return
|
|
745
|
+
return operationOutcome(await this.#seam.look({ workspaceId: env.workspaceId, workerId: env.workerId, statement }));
|
|
473
746
|
}
|
|
474
|
-
case "run.fork": return { ok: true, result: await this.#seam.forkWorker({ workspaceId: env.workspaceId, workerId:
|
|
475
|
-
default: return
|
|
747
|
+
case "run.fork": return { ok: true, result: await this.#seam.forkWorker({ workspaceId: env.workspaceId, workerId: conversationWorkerId ?? await this.#seam.ensureModelWorker(env.workspaceId), ...(typeof p.name === "string" ? { name: p.name } : {}) }) };
|
|
748
|
+
default: return actionFailure("unknown-action", `Action '${a.kind}' is not registered.`, 404, {
|
|
749
|
+
requestedAction: a.kind,
|
|
750
|
+
recovery: "Use an action advertised by discover.",
|
|
751
|
+
});
|
|
476
752
|
}
|
|
477
753
|
}
|
|
478
754
|
catch (err) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const messages = [];
|
|
485
|
-
for (const e of entries) {
|
|
486
|
-
if (e.op === "SEND" && e.origin === "model") {
|
|
487
|
-
const tx = e.tx;
|
|
488
|
-
const body = tx !== null && typeof tx === "object" && typeof tx.body === "string" ? tx.body : "";
|
|
489
|
-
if (body.length > 0)
|
|
490
|
-
messages.push({ id: String(e.coordinate ?? e.id), role: "assistant", content: body });
|
|
491
|
-
}
|
|
755
|
+
const problem = problemFromError(err);
|
|
756
|
+
if (problem !== null)
|
|
757
|
+
return { ok: false, problem };
|
|
758
|
+
console.error(`AG-UI action '${a.kind}' failed:`, err);
|
|
759
|
+
return actionFailure("action-failed", "The action failed unexpectedly.", 500);
|
|
492
760
|
}
|
|
493
|
-
return [{ type: EventType.MESSAGES_SNAPSHOT, messages }];
|
|
494
761
|
}
|
|
495
762
|
static #body(req) {
|
|
496
763
|
return new Promise((resolve, reject) => {
|