@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.
- package/.env.defaults +8 -6
- package/README.md +11 -3
- package/SPEC.md +295 -88
- package/dist/AguiPlus.d.ts +2 -1
- package/dist/AguiPlus.d.ts.map +1 -1
- package/dist/AguiPlus.js +7 -7
- package/dist/AguiPlus.js.map +1 -1
- package/dist/DaemonSeam.d.ts +15 -26
- package/dist/DaemonSeam.d.ts.map +1 -1
- package/dist/DaemonSeam.js +1 -1
- package/dist/DaemonSeam.js.map +1 -1
- package/dist/EventRouter.d.ts.map +1 -1
- package/dist/EventRouter.js +16 -5
- package/dist/EventRouter.js.map +1 -1
- package/dist/Module.d.ts +5 -7
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +440 -163
- package/dist/Module.js.map +1 -1
- package/dist/Portal.d.ts +6 -0
- package/dist/Portal.d.ts.map +1 -1
- package/dist/Portal.js +12 -8
- package/dist/Portal.js.map +1 -1
- package/dist/ProposalHitl.d.ts.map +1 -1
- package/dist/ProposalHitl.js +48 -39
- package/dist/ProposalHitl.js.map +1 -1
- package/dist/Translator.d.ts +1 -1
- package/dist/Translator.d.ts.map +1 -1
- package/dist/Translator.js +114 -70
- package/dist/Translator.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +30 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/observe.d.ts +8 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/observe.js +56 -0
- package/dist/observe.js.map +1 -0
- package/dist/types.d.ts +11 -21
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +13 -8
package/dist/Module.js
CHANGED
|
@@ -1,52 +1,147 @@
|
|
|
1
|
-
// The in-process transport module
|
|
1
|
+
// {§agui-daemon-client} The in-process transport module is what the daemon's boot plug-point
|
|
2
2
|
// activates: registerModule(aguiModule(opts)) hands this the CoreSeam handle; it opens
|
|
3
3
|
// the AG-UI+ HTTP/SSE listener and owns the client interface from there.
|
|
4
4
|
//
|
|
5
|
-
// This is the
|
|
5
|
+
// This is the single external client interface:
|
|
6
6
|
// POST / — the only endpoint. A worker streams SSE. HITL is terminate-resume: a
|
|
7
7
|
// stopped-world emits a request_approval/request_user_input TOOL_CALL and finishes
|
|
8
|
-
// with an AG-UI interrupt outcome (the loop stays paused in-engine); the next
|
|
8
|
+
// with an AG-UI interrupt outcome (the loop stays paused in-engine); the next AG-UI Run's
|
|
9
9
|
// standard resume entries resolve the durable proposal and continue the exact loop.
|
|
10
|
-
// Reads ride STATE_SNAPSHOT on RUN_STARTED;
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Reads ride STATE_SNAPSHOT on RUN_STARTED; there are no side-channel action
|
|
11
|
+
// or proposal-resolution endpoints.
|
|
12
|
+
// An AG-UI threadId names one conversation worker inside the explicitly forwarded
|
|
13
|
+
// workspace ({§agui-thread-binding}); no prefix or inferred workspace is minted.
|
|
13
14
|
var _a;
|
|
14
15
|
import { createServer } from "node:http";
|
|
15
16
|
import Portal from "./Portal.js";
|
|
16
17
|
import { stateSnapshot, parseAction, actionResult } from "./AguiPlus.js";
|
|
17
|
-
import { PlurnkParser } from "@plurnk/plurnk-
|
|
18
|
-
import { authorize as mcpAuthorize, poll as mcpPoll } from "@plurnk/plurnk-execs-mcp";
|
|
18
|
+
import { PlurnkParser, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
|
|
19
19
|
import { EventType } from "./types.js";
|
|
20
|
+
import { aguiRouteTemplate, observed } from "./observe.js";
|
|
20
21
|
import { RunAgentInputSchema } from "@ag-ui/core";
|
|
21
22
|
import { logEntryIdFromToolCallId, proposalInterrupt } from "./AguiPlus.js";
|
|
23
|
+
import { Problems, Validator } from "@plurnk/plurnk-contracts";
|
|
24
|
+
import { resolveModuleOptions } from "./config.js";
|
|
25
|
+
const actionFailure = (code, detail, status = 400, extensions = {}) => ({
|
|
26
|
+
ok: false,
|
|
27
|
+
problem: Problems.create("agui:action", code, status, detail, {
|
|
28
|
+
stage: status < 500 ? "action-validation" : "action-execution",
|
|
29
|
+
retryable: false,
|
|
30
|
+
...extensions,
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const httpProblem = (code, status, detail, extensions = {}) => Problems.create("agui:http", code, status, detail, extensions);
|
|
34
|
+
class HttpProblemError extends Error {
|
|
35
|
+
problem;
|
|
36
|
+
constructor(problem) {
|
|
37
|
+
super(problem.detail);
|
|
38
|
+
this.name = "HttpProblemError";
|
|
39
|
+
this.problem = problem;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const problemFromError = (error) => {
|
|
43
|
+
if (error instanceof HttpProblemError)
|
|
44
|
+
return error.problem;
|
|
45
|
+
if (typeof error !== "object" || error === null)
|
|
46
|
+
return null;
|
|
47
|
+
const result = error.result;
|
|
48
|
+
if (result !== undefined) {
|
|
49
|
+
try {
|
|
50
|
+
return Validator.assertOperationResult(result).problem ?? null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const problem = error.problem;
|
|
57
|
+
if (problem !== undefined) {
|
|
58
|
+
try {
|
|
59
|
+
return Validator.assertProblemDetails(problem);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
const operationOutcome = (result) => {
|
|
68
|
+
const exact = Validator.assertOperationResult(result);
|
|
69
|
+
return exact.problem === undefined
|
|
70
|
+
? { ok: true, result: exact }
|
|
71
|
+
: { ok: false, problem: exact.problem };
|
|
72
|
+
};
|
|
73
|
+
const parseFailureResult = ({ detail, line, column, source, severity, }) => ({
|
|
74
|
+
status: 400,
|
|
75
|
+
problem: Problems.create("agui:action", "parse-failed", 400, detail, {
|
|
76
|
+
line,
|
|
77
|
+
column,
|
|
78
|
+
source,
|
|
79
|
+
severity,
|
|
80
|
+
stage: "parsing",
|
|
81
|
+
retryable: false,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
const writeHttpProblem = (res, problem) => {
|
|
85
|
+
res.writeHead(problem.status, { "content-type": "application/problem+json" });
|
|
86
|
+
res.end(JSON.stringify(problem));
|
|
87
|
+
};
|
|
88
|
+
const runErrorEvents = (problem) => [
|
|
89
|
+
{ type: EventType.CUSTOM, name: "plurnk.problem", value: problem },
|
|
90
|
+
{ type: EventType.RUN_ERROR, message: problem.detail, code: problem.type },
|
|
91
|
+
];
|
|
22
92
|
class Module {
|
|
23
93
|
#seam;
|
|
24
94
|
#opts;
|
|
25
95
|
#portal;
|
|
26
96
|
#http;
|
|
27
97
|
#threads = new Map(); // threadId → envelope
|
|
28
|
-
#
|
|
29
|
-
|
|
98
|
+
#threadWorkers = new Map(); // threadId → conversation workerId
|
|
99
|
+
static #CONTROL_ACTIONS = Object.freeze([
|
|
100
|
+
"ping", "discover", "providers.list", "workspace.list", "workspace.create", "workspace.attach",
|
|
101
|
+
]);
|
|
102
|
+
// The control plane vs the world. An AG-UI Run lives in a world (a conversation, or an action
|
|
30
103
|
// that reads/writes a workspace's log); a control-plane action (list/create/attach/discover/
|
|
31
|
-
// auth) does NOT — so it must not bind or forge a workspace
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (§
|
|
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
|
|
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
|
|
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
|
|
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
|
|
92
|
-
res.end(JSON.stringify({ error: message }));
|
|
206
|
+
writeHttpProblem(res, problem);
|
|
93
207
|
return;
|
|
94
208
|
}
|
|
95
|
-
//
|
|
96
|
-
// invisible to the event parser
|
|
97
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
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
|
-
// #
|
|
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
|
|
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
|
-
...(
|
|
131
|
-
|
|
132
|
-
|
|
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 #
|
|
141
|
-
const cached = this.#
|
|
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.#
|
|
274
|
+
this.#threadWorkers.set(threadId, workerId);
|
|
149
275
|
return workerId;
|
|
150
276
|
}
|
|
151
277
|
async #run(req, res) {
|
|
152
|
-
|
|
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
|
-
//
|
|
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.#
|
|
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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
410
|
+
res.on("close", finish); // client hangup on a resume just detaches; the loop is already active
|
|
254
411
|
return;
|
|
255
412
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
throw new Error("RunAgentInput.messages must carry a user message");
|
|
259
|
-
if (typeof lastUser.content !== "string")
|
|
260
|
-
throw new Error("PLURNK currently requires textual user message content");
|
|
261
|
-
if (lastUser.content.length === 0)
|
|
262
|
-
throw new Error("RunAgentInput.messages must carry a non-empty user message");
|
|
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(
|
|
418
|
+
emit(this.#portal.replay(boundRun, history));
|
|
267
419
|
}
|
|
268
420
|
await this.#portal.run(boundRun, {
|
|
269
|
-
workspaceId, workerId, prompt
|
|
270
|
-
...(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
...(
|
|
276
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
299
|
-
// our own stream. No Portal thread, no model worker
|
|
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)
|
|
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`)
|
|
311
|
-
//
|
|
312
|
-
|
|
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 [
|
|
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
|
|
504
|
+
for (const n of _a.#NOTIFICATIONS)
|
|
319
505
|
notifications[n] = true;
|
|
320
|
-
|
|
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,
|
|
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
|
|
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
|
-
...(
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
385
|
-
const
|
|
386
|
-
const entries = await this.#seam.readLog({
|
|
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 {
|
|
392
|
-
|
|
393
|
-
|
|
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(
|
|
398
|
-
case "workspace.prompts": return {
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
419
|
-
|
|
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 {
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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({
|
|
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
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
469
|
-
if (
|
|
470
|
-
return {
|
|
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
|
|
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:
|
|
475
|
-
default: return
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const messages = [];
|
|
485
|
-
for (const e of entries) {
|
|
486
|
-
if (e.op === "SEND" && e.origin === "model") {
|
|
487
|
-
const tx = e.tx;
|
|
488
|
-
const body = tx !== null && typeof tx === "object" && typeof tx.body === "string" ? tx.body : "";
|
|
489
|
-
if (body.length > 0)
|
|
490
|
-
messages.push({ id: String(e.coordinate ?? e.id), role: "assistant", content: body });
|
|
491
|
-
}
|
|
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) => {
|