@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.
Files changed (44) hide show
  1. package/.env.defaults +8 -6
  2. package/README.md +11 -3
  3. package/SPEC.md +285 -88
  4. package/dist/AguiPlus.d.ts +2 -1
  5. package/dist/AguiPlus.d.ts.map +1 -1
  6. package/dist/AguiPlus.js +5 -5
  7. package/dist/AguiPlus.js.map +1 -1
  8. package/dist/DaemonSeam.d.ts +13 -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 +425 -158
  18. package/dist/Module.js.map +1 -1
  19. package/dist/Portal.d.ts +1 -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 +90 -69
  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 +8 -20
  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,32 +1,105 @@
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
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
- // The boot-plug-point init: `daemon.registerModule(Module.init(opts))`.
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 async (seam) => {
47
- const m = new _a(seam, opts);
48
- await m.listen();
49
- return m;
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 (§governance): bearer check before any body read.
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.writeHead(401, { "content-type": "application/json" }).end(JSON.stringify({ error: "bearer token required" }));
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.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "POST / (AG-UI run) is the interface" }));
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 message = err instanceof Error ? err.message : String(err);
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.writeHead(500, { "content-type": "application/json" });
92
- res.end(JSON.stringify({ error: message }));
207
+ writeHttpProblem(res, problem);
93
208
  return;
94
209
  }
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`);
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 SESSION is the WORLD (service SPEC, machine-processes) — selected by name via
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
- // #conversationRun (svc#366 landed: the three doors are ensureModelWorker, forkWorker,
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 Error("forwardedProps.plurnk.workspace (a workspace name) is required");
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
- ...(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) } : {}),
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 #conversationRun(threadId, env) {
141
- const cached = this.#threadRuns.get(threadId);
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.#threadRuns.set(threadId, workerId);
275
+ this.#threadWorkers.set(threadId, workerId);
149
276
  return workerId;
150
277
  }
151
278
  async #run(req, res) {
152
- const input = RunAgentInputSchema.parse(JSON.parse(await _a.#body(req)));
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
- // THREAD ↔ RUN (svc#366): the threadId is the CONVERSATION — a worker over the
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.#conversationRun(input.threadId, env);
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
- // 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;
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
- // run (the loop stays paused in-engine awaiting the resume run).
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 ACTION run (forwardedProps.plurnk.action): execute via the
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
- // run and completes after the resume run rebinds the stream.
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 run rebinds.
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) => finishAction({ ok: false, error: err instanceof Error ? err.message : String(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 NEW run on the same thread. Bind it
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 runs
411
+ res.on("close", finish); // client hangup on a resume just detaches; the loop is already active
254
412
  return;
255
413
  }
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");
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([...this.#threadRouterReplay(workspaceId, history)]);
419
+ emit(this.#portal.replay(boundRun, history));
267
420
  }
268
421
  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
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
- ...(typeof forwarded?.alias === "string" && forwarded.alias.length > 0 ? { alias: forwarded.alias } : {}),
276
- ...(typeof forwarded?.model === "string" && forwarded.model.length > 0 ? { model: forwarded.model } : {}),
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 LIVE run cancels the loop (hangup is the abort). A worker we
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 run needs).
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
- // Post-headers throw INSIDE the run (svc#480, completed): the frame alone is
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 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 }]);
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 run: no world bound. Open the SSE, run the worldless verb, answer on
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).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) }));
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`) 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() {
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 ["ping", "discover", "providers.list", "workspace.list", "workspace.create", "workspace.attach", "auth.authorize", "auth.authorize.poll", ..._a.#WORLD_SCOPED])
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 ["log/entry", "loop/terminated", "loop/proposal", "telemetry/event", "stream/event", "stream/concluded"])
500
+ for (const n of _a.#NOTIFICATIONS)
319
501
  notifications[n] = true;
320
- return { methods, notifications };
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, convRun) {
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 === "string" && p.name.length > 0) {
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
- ...(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) } : {}),
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 { ok: false, error: "workspace.attach requires id" };
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, 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 }) };
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 { ok: false, error: `unknown action '${a.kind}'` };
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 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 } : {}) });
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 { 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 };
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(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) } };
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 { ok: false, error: "workspace.rename requires name" };
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 { ok: false, error: "workspace.constrain requires effect + glob" };
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 { ok: false, error: "workspace.unconstrain requires effect + glob" };
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 { 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 } : {}) }) };
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 { 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:
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
- return { ok: true, result };
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 (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" };
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({ status: 400, error: String(item.error.message ?? item.error) });
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
- // 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" };
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 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" };
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 { ok: true, result: await this.#seam.look({ workspaceId: env.workspaceId, workerId: env.workerId, statement }) };
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: convRun ?? await this.#seam.ensureModelWorker(env.workspaceId), ...(typeof p.name === "string" ? { name: p.name } : {}) }) };
475
- default: return { ok: false, error: `unknown action '${a.kind}'` };
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
- 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
- }
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) => {