@plurnk/plurnk-agui 1.3.12 → 1.5.0

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