@mono-agent/operator-adapter 0.19.0 → 0.20.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.
@@ -1,22 +1,30 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { isAbsolute } from "node:path";
4
- import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, BoundedHttpResponseWriter, agentAttachmentKindFromMimeType, closeServerBounded, createChannelUserCancelReason, decodeAgentAttachmentText, isAgentResponseCancelledError, serializeAgentStreamFrame, } from "@mono-agent/agent-contracts";
5
- import { assertSafeBind, bearerTokensEqual, hostForUrl, isLoopbackHost, listen, normalizeOptionalString, readAuthorizationBearer, } from "@mono-agent/agent-contracts";
4
+ import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, MCP_APP_RESOURCE_MIME_TYPE, MCP_APP_SUPPORTED_VERSIONS, MAX_AGENT_REPLY_PARTS, BoundedHttpResponseWriter, agentAttachmentKindFromMimeType, closeServerBounded, createChannelUserCancelReason, decodeAgentAttachmentText, isAgentResponseCancelledError, parseProcessJobProjection, parseProcessJobProjections, serializeAgentStreamFrame, } from "@mono-agent/agent-contracts";
5
+ import { assertSafeBind, bearerTokensEqual, hostForUrl, isLoopbackHost, listen, normalizeOptionalString, parseCronOperatorOverview, parseCronOperatorRunDetail, parseCronOperatorRunPage, readAuthorizationBearer, } from "@mono-agent/agent-contracts";
6
6
  import express, {} from "express";
7
7
  import { DEFAULT_BASE_PATH, DEFAULT_HOST, DEFAULT_PORT, MAX_FRAME_BYTES, TUI_WIRE_SCHEMA } from "./constants.js";
8
+ import { CronOperatorError, MAX_CRON_OPERATOR_RESPONSE_BYTES, MAX_CRON_OPERATOR_RUN_PAGE, } from "./cron.js";
8
9
  import { TuiAdapterError } from "./errors.js";
9
10
  const MAX_TURN_BODY_BYTES = 96 * 1024 * 1024;
10
11
  const MAX_VERBATIM_BODY_BYTES = 2 * 1024 * 1024;
11
12
  const MAX_VERBATIM_TEXT_CHARACTERS = 200_000;
12
13
  const MAX_VERBATIM_TEXT_BYTES = 1024 * 1024;
13
14
  const MAX_LIVE_INPUT_BODY_BYTES = 32 * 1024;
15
+ const MAX_PROCESS_JOBS_RESPONSE_BYTES = 16 * 1024 * 1024;
14
16
  const MAX_WEB_ATTACHMENTS = 10;
15
17
  const MAX_WEB_ATTACHMENT_BYTES = 64 * 1024 * 1024;
16
18
  const MAX_REQUEST_TOOL_ENVIRONMENT_KEYS = 32;
17
19
  const MAX_REQUEST_TOOL_ENVIRONMENT_VALUE_BYTES = 16 * 1024;
18
20
  const MAX_REQUEST_TOOL_ENVIRONMENT_TOTAL_BYTES = 64 * 1024;
19
21
  const MAX_REQUEST_TOOL_ENVIRONMENT_PATHS = 4;
22
+ const MAX_CRON_ACTION_BODY_BYTES = 32 * 1024;
23
+ const MAX_REPLY_ARTIFACT_ID_BYTES = 128;
24
+ const MAX_REPLY_ARTIFACT_CONVERSATION_BYTES = 4 * 1024;
25
+ const MAX_MCP_APP_IDENTITY_BYTES = 4 * 1024;
26
+ const MAX_MCP_APP_REQUEST_BYTES = 64 * 1024;
27
+ const REPLY_ARTIFACT_DRAIN_TIMEOUT_MS = 30_000;
20
28
  const ALLOWED_ATTACHMENT_MIME_TYPES = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST.map((mimeType) => mimeType.toLowerCase()));
21
29
  export async function startTuiAdapter(options) {
22
30
  if (typeof options.responder?.respond !== "function") {
@@ -26,6 +34,10 @@ export async function startTuiAdapter(options) {
26
34
  const port = options.port ?? DEFAULT_PORT;
27
35
  const basePath = normalizeBasePath(options.basePath ?? DEFAULT_BASE_PATH);
28
36
  const apiKey = normalizeOptionalString(options.apiKey);
37
+ const processJobsBearer = normalizeOptionalString(options.processJobsBearer);
38
+ if ((options.processJobs === undefined) !== (processJobsBearer === undefined)) {
39
+ throw new TuiAdapterError("invalid_config", "processJobs and processJobsBearer must be configured together.");
40
+ }
29
41
  if (options.requestToolEnvironment !== undefined && !isLoopbackHost(host)) {
30
42
  throw new TuiAdapterError("unsafe_host", "Request tool environment requires a loopback-only TUI adapter bind.", { host });
31
43
  }
@@ -40,21 +52,76 @@ export async function startTuiAdapter(options) {
40
52
  const cancelPath = `${basePath}/v1/conversations/:conversationId/cancel`;
41
53
  const verbatimPath = `${basePath}/v1/conversations/:conversationId/verbatim`;
42
54
  const liveInputPath = `${basePath}/v1/conversations/:conversationId/live-input`;
55
+ const replyArtifactPath = `${basePath}/v1/conversations/:conversationId/reply-artifacts/:artifactId`;
56
+ const mcpAppPath = `${basePath}/v1/conversations/:conversationId/mcp-apps/:invocationId`;
57
+ const mcpAppRequestPath = `${mcpAppPath}/requests`;
43
58
  const askPath = `${basePath}/v1/conversations/:conversationId/ask`;
59
+ const interactionPath = `${basePath}/v1/interactions/:interactionId`;
60
+ const cronOverviewPath = `${basePath}/v1/cron`;
61
+ const cronRunsPath = `${basePath}/v1/cron/jobs/:jobId/runs`;
62
+ const cronRunDetailPath = `${basePath}/v1/cron/jobs/:jobId/runs/:runId`;
63
+ const cronConfigViewPath = `${basePath}/v1/cron/config-view`;
64
+ const cronRunNowPath = `${basePath}/v1/cron/jobs/:jobId/run`;
65
+ const cronEnabledPath = `${basePath}/v1/cron/jobs/:jobId/effective-enabled`;
66
+ const jobsPath = `${basePath}/v1/jobs`;
67
+ const jobPath = `${basePath}/v1/jobs/:jobId`;
68
+ const jobCancelPath = `${basePath}/v1/jobs/:jobId/cancel`;
44
69
  app.get(infoPath, (req, res) => {
45
70
  if (!authorize(req, res, apiKey)) {
46
71
  return;
47
72
  }
48
- void resolveInfo(options.info)
49
- .then((info) => {
73
+ const cronInfo = options.cron === undefined
74
+ ? Promise.resolve({ kind: "absent" })
75
+ : Promise.resolve()
76
+ .then(async () => await options.cron.overview())
77
+ .then((overview) => ({ kind: "available", overview }))
78
+ .catch((error) => {
79
+ // Cron is an additive capability, never the agent-liveness probe. A
80
+ // stopped registry or failed control store must not turn /v1/info into
81
+ // a 500 that makes the whole agent appear unreachable.
82
+ options.logger?.error?.("Cron operator overview failed during TUI info.", {
83
+ error: errorToMessage(error),
84
+ });
85
+ return { kind: "degraded" };
86
+ });
87
+ void Promise.all([resolveInfo(options.info), cronInfo])
88
+ .then(([info, cronState]) => {
50
89
  res.status(200).json({
51
90
  schema: TUI_WIRE_SCHEMA,
52
91
  pid: process.pid,
53
92
  capabilities: {
54
93
  attachments: true,
94
+ ...(typeof options.responder.openReplyArtifact === "function"
95
+ ? { replyAttachments: { version: 1, maxBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES } }
96
+ : {}),
97
+ ...(typeof options.responder.loadMcpApp === "function"
98
+ && typeof options.responder.requestMcpApp === "function"
99
+ ? {
100
+ mcpApps: {
101
+ bridgeVersion: 1,
102
+ versions: MCP_APP_SUPPORTED_VERSIONS,
103
+ mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE],
104
+ },
105
+ }
106
+ : {}),
55
107
  ...(typeof options.responder.offerLiveInput === "function" ? { liveInput: true } : {}),
56
108
  ...(typeof options.responder.deliverVerbatim === "function" ? { historyAppend: true } : {}),
57
109
  ...(options.interaction === undefined ? {} : { askUser: true }),
110
+ ...(typeof options.interaction?.getAsk === "function" ? { askById: true } : {}),
111
+ ...(cronState.kind === "absent"
112
+ ? {}
113
+ : cronState.kind === "degraded"
114
+ ? { cron: { status: "degraded", read: false, actions: false } }
115
+ : {
116
+ cron: {
117
+ status: cronState.overview.degradedReason === undefined ? "ready" : "degraded",
118
+ read: true,
119
+ actions: cronState.overview.degradedReason === undefined
120
+ && apiKey !== undefined
121
+ && cronState.overview.actionsEnabled === true,
122
+ },
123
+ }),
124
+ ...(options.processJobs === undefined || processJobsBearer === undefined ? {} : { jobs: true }),
58
125
  ...(options.requestToolEnvironment === undefined ? {} : { toolEnvironment: true }),
59
126
  },
60
127
  ...(info?.label === undefined ? {} : { label: info.label }),
@@ -72,6 +139,69 @@ export async function startTuiAdapter(options) {
72
139
  sendJsonError(res, 500, error);
73
140
  });
74
141
  });
142
+ app.get(jobsPath, (req, res, next) => {
143
+ if (!authorize(req, res, processJobsBearer))
144
+ return;
145
+ if (options.processJobs === undefined || processJobsBearer === undefined) {
146
+ sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Process jobs are unavailable."));
147
+ return;
148
+ }
149
+ void options.processJobs.list()
150
+ .then((jobs) => sendBoundedJobs(res, jobs))
151
+ .catch(next);
152
+ });
153
+ app.get(jobPath, (req, res, next) => {
154
+ if (!authorize(req, res, processJobsBearer))
155
+ return;
156
+ if (options.processJobs === undefined || processJobsBearer === undefined) {
157
+ sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Process jobs are unavailable."));
158
+ return;
159
+ }
160
+ const jobId = normalizeOptionalString(typeof req.params.jobId === "string" ? req.params.jobId : undefined);
161
+ if (jobId === undefined || jobId.length > 256) {
162
+ sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A bounded jobId is required."));
163
+ return;
164
+ }
165
+ void options.processJobs.get(jobId)
166
+ .then((job) => {
167
+ if (job === undefined) {
168
+ res.status(404).json({ error: { code: "process_job_not_found", message: "Process job was not found." } });
169
+ }
170
+ else {
171
+ sendBoundedJob(res, job);
172
+ }
173
+ })
174
+ .catch(next);
175
+ });
176
+ app.post(jobCancelPath, (req, res, next) => {
177
+ if (!authorize(req, res, processJobsBearer))
178
+ return;
179
+ if (options.processJobs === undefined || processJobsBearer === undefined) {
180
+ sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Process jobs are unavailable."));
181
+ return;
182
+ }
183
+ const jobId = normalizeOptionalString(typeof req.params.jobId === "string" ? req.params.jobId : undefined);
184
+ if (jobId === undefined || jobId.length > 256) {
185
+ sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A bounded jobId is required."));
186
+ return;
187
+ }
188
+ void options.processJobs.cancel(jobId)
189
+ .then((job) => sendBoundedJob(res, job))
190
+ .catch((error) => {
191
+ const code = typeof error === "object" && error !== null
192
+ ? error.code
193
+ : undefined;
194
+ if (code === "process_job_not_found") {
195
+ res.status(404).json({ error: { code, message: errorToMessage(error) } });
196
+ }
197
+ else if (code === "process_job_conflict") {
198
+ res.status(409).json({ error: { code, message: errorToMessage(error) } });
199
+ }
200
+ else {
201
+ next(error);
202
+ }
203
+ });
204
+ });
75
205
  // Keep the enlarged parser scoped to turn submission. 64 MiB of decoded
76
206
  // files expands to about 85.4 MiB in base64, while info/cancel stay bodyless.
77
207
  app.post(turnsPath, express.json({ limit: MAX_TURN_BODY_BYTES }), (req, res) => {
@@ -103,6 +233,101 @@ export async function startTuiAdapter(options) {
103
233
  options.interaction?.cancelAsks(conversationId);
104
234
  res.status(202).json({ cancelled: conversationId });
105
235
  });
236
+ app.get(replyArtifactPath, (req, res, next) => {
237
+ if (!authorize(req, res, apiKey))
238
+ return;
239
+ if (typeof options.responder.openReplyArtifact !== "function") {
240
+ sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Reply artifacts are unavailable."));
241
+ return;
242
+ }
243
+ const conversationId = normalizeOptionalString(typeof req.params.conversationId === "string" ? req.params.conversationId : undefined);
244
+ const artifactId = normalizeOptionalString(typeof req.params.artifactId === "string" ? req.params.artifactId : undefined);
245
+ const expectedIntegrityId = normalizeOptionalString(req.header("x-mono-agent-integrity-id"));
246
+ if (conversationId === undefined
247
+ || artifactId === undefined
248
+ || Buffer.byteLength(conversationId, "utf8") > MAX_REPLY_ARTIFACT_CONVERSATION_BYTES
249
+ || Buffer.byteLength(artifactId, "utf8") > MAX_REPLY_ARTIFACT_ID_BYTES) {
250
+ sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A valid conversation and artifact id are required."));
251
+ return;
252
+ }
253
+ void options.responder.openReplyArtifact({
254
+ conversationId,
255
+ reference: { scheme: "mono-agent-artifact", id: artifactId },
256
+ ...(expectedIntegrityId === undefined ? {} : { expectedIntegrityId }),
257
+ }).then(async (opened) => {
258
+ setReplyArtifactHeaders(res, opened.attachment);
259
+ let streamed = 0;
260
+ for await (const chunk of opened.body) {
261
+ streamed += chunk.byteLength;
262
+ if (streamed > opened.attachment.sizeBytes) {
263
+ throw new TuiAdapterError("invalid_request", "Reply artifact exceeded its declared size.");
264
+ }
265
+ await writeBinaryChunk(res, chunk);
266
+ }
267
+ if (streamed !== opened.attachment.sizeBytes) {
268
+ throw new TuiAdapterError("invalid_request", "Reply artifact stream ended before its declared size.");
269
+ }
270
+ res.end();
271
+ }).catch((error) => {
272
+ if (res.headersSent) {
273
+ res.destroy(error instanceof Error ? error : new Error(String(error)));
274
+ return;
275
+ }
276
+ const code = codeOf(error);
277
+ const status = code === "artifact_forbidden" || code === "artifact_missing" || code === "artifact_expired"
278
+ ? 404
279
+ : code === "artifact_integrity_failed" ? 409 : 500;
280
+ sendJsonError(res, status, error);
281
+ }).catch(next);
282
+ });
283
+ app.get(mcpAppPath, (req, res) => {
284
+ if (!authorize(req, res, apiKey))
285
+ return;
286
+ if (typeof options.responder.loadMcpApp !== "function") {
287
+ sendJsonError(res, 404, new TuiAdapterError("invalid_request", "MCP Apps are unavailable."));
288
+ return;
289
+ }
290
+ const identity = normalizeMcpAppIdentity(req);
291
+ if (identity === undefined) {
292
+ sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A valid conversation, invocation, and connection id are required."));
293
+ return;
294
+ }
295
+ void options.responder.loadMcpApp(identity).then((resource) => {
296
+ setPrivateMcpAppHeaders(res);
297
+ res.status(200).json(resource);
298
+ }).catch((error) => sendMcpAppError(res, error));
299
+ });
300
+ app.post(mcpAppRequestPath, express.json({ limit: MAX_MCP_APP_REQUEST_BYTES, strict: true }), (req, res) => {
301
+ if (!authorize(req, res, apiKey))
302
+ return;
303
+ if (typeof options.responder.requestMcpApp !== "function") {
304
+ sendJsonError(res, 404, new TuiAdapterError("invalid_request", "MCP Apps are unavailable."));
305
+ return;
306
+ }
307
+ const identity = normalizeMcpAppIdentity(req);
308
+ const body = isRecord(req.body) ? req.body : undefined;
309
+ const method = body?.method;
310
+ const params = body?.params;
311
+ const confirmed = body?.confirmed;
312
+ if (identity === undefined
313
+ || (method !== "resources/read"
314
+ && method !== "tools/call"
315
+ && method !== "ui/open-link"
316
+ && method !== "ui/update-model-context")
317
+ || (confirmed !== undefined && typeof confirmed !== "boolean")) {
318
+ sendJsonError(res, 400, new TuiAdapterError("invalid_request", "The MCP App bridge request is invalid."));
319
+ return;
320
+ }
321
+ void options.responder.requestMcpApp({
322
+ ...identity,
323
+ method,
324
+ ...(params === undefined ? {} : { params }),
325
+ ...(confirmed === undefined ? {} : { confirmed }),
326
+ }).then((result) => {
327
+ setPrivateMcpAppHeaders(res);
328
+ res.status(200).json({ result });
329
+ }).catch((error) => sendMcpAppError(res, error));
330
+ });
106
331
  app.post(verbatimPath, express.json({ limit: MAX_VERBATIM_BODY_BYTES, strict: true }), (req, res, next) => {
107
332
  if (!authorize(req, res, apiKey)) {
108
333
  return;
@@ -200,6 +425,128 @@ export async function startTuiAdapter(options) {
200
425
  res.status(200).json(result);
201
426
  }).catch((error) => sendJsonError(res, 500, error));
202
427
  });
428
+ app.get(interactionPath, (req, res) => {
429
+ if (!authorize(req, res, apiKey))
430
+ return;
431
+ const interactionId = normalizeOptionalString(typeof req.params.interactionId === "string" ? req.params.interactionId : undefined);
432
+ if (interactionId === undefined) {
433
+ sendJsonError(res, 400, new TuiAdapterError("invalid_request", "interactionId is required."));
434
+ return;
435
+ }
436
+ if (typeof options.interaction?.getAsk !== "function") {
437
+ sendJsonError(res, 501, new TuiAdapterError("invalid_request", "Exact interaction lookup is unsupported."));
438
+ return;
439
+ }
440
+ void Promise.resolve(options.interaction.getAsk(interactionId))
441
+ .then((ask) => res.status(200).json({ ask: ask ?? null }))
442
+ .catch((error) => sendJsonError(res, 500, error));
443
+ });
444
+ app.get(cronOverviewPath, (req, res, next) => {
445
+ if (!authorize(req, res, apiKey))
446
+ return;
447
+ if (options.cron === undefined) {
448
+ sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
449
+ return;
450
+ }
451
+ void Promise.resolve(options.cron.overview())
452
+ .then((overview) => sendBoundedCronJson(res, 200, parseCronOperatorOverview(overview)))
453
+ .catch(next);
454
+ });
455
+ app.get(cronRunsPath, (req, res, next) => {
456
+ if (!authorize(req, res, apiKey))
457
+ return;
458
+ if (options.cron === undefined) {
459
+ sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
460
+ return;
461
+ }
462
+ try {
463
+ const jobId = cronJobId(req.params.jobId);
464
+ const rawLimit = typeof req.query.limit === "string" ? Number(req.query.limit) : 50;
465
+ if (!Number.isSafeInteger(rawLimit) || rawLimit < 1 || rawLimit > MAX_CRON_OPERATOR_RUN_PAGE) {
466
+ throw new CronOperatorError("invalid_request", `limit must be 1-${String(MAX_CRON_OPERATOR_RUN_PAGE)}.`, 400);
467
+ }
468
+ const before = typeof req.query.before === "string" && req.query.before.length > 0
469
+ ? req.query.before
470
+ : undefined;
471
+ if (before !== undefined && Buffer.byteLength(before, "utf8") > 4_096) {
472
+ throw new CronOperatorError("invalid_request", "before cursor is too large.", 400);
473
+ }
474
+ void Promise.resolve(options.cron.runs({ jobId, limit: rawLimit, ...(before === undefined ? {} : { before }) }))
475
+ .then((page) => sendBoundedCronJson(res, 200, parseCronOperatorRunPage(page)))
476
+ .catch(next);
477
+ }
478
+ catch (error) {
479
+ next(error);
480
+ }
481
+ });
482
+ app.get(cronRunDetailPath, (req, res, next) => {
483
+ if (!authorize(req, res, apiKey))
484
+ return;
485
+ if (options.cron === undefined) {
486
+ sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
487
+ return;
488
+ }
489
+ try {
490
+ const jobId = cronJobId(req.params.jobId);
491
+ const runId = cronRunId(req.params.runId);
492
+ void Promise.resolve(options.cron.run({ jobId, runId }))
493
+ .then((run) => sendBoundedCronJson(res, 200, { run: parseCronOperatorRunDetail(run) }))
494
+ .catch(next);
495
+ }
496
+ catch (error) {
497
+ next(error);
498
+ }
499
+ });
500
+ app.get(cronConfigViewPath, (req, res, next) => {
501
+ if (!authorize(req, res, apiKey))
502
+ return;
503
+ if (options.cron === undefined) {
504
+ sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
505
+ return;
506
+ }
507
+ void Promise.resolve(options.cron.configView())
508
+ .then((configView) => res.status(200).json({ configView }))
509
+ .catch(next);
510
+ });
511
+ app.post(cronRunNowPath, express.json({ limit: MAX_CRON_ACTION_BODY_BYTES, strict: true }), (req, res, next) => {
512
+ if (!authorize(req, res, apiKey) || !requireCronActionKey(res, apiKey))
513
+ return;
514
+ if (options.cron === undefined) {
515
+ sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
516
+ return;
517
+ }
518
+ try {
519
+ const jobId = cronJobId(req.params.jobId);
520
+ const action = cronActionInput(req.body);
521
+ void Promise.resolve(options.cron.runNow(jobId, action))
522
+ .then((result) => sendCronMutation(res, result))
523
+ .catch(next);
524
+ }
525
+ catch (error) {
526
+ next(error);
527
+ }
528
+ });
529
+ app.post(cronEnabledPath, express.json({ limit: MAX_CRON_ACTION_BODY_BYTES, strict: true }), (req, res, next) => {
530
+ if (!authorize(req, res, apiKey) || !requireCronActionKey(res, apiKey))
531
+ return;
532
+ if (options.cron === undefined) {
533
+ sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
534
+ return;
535
+ }
536
+ try {
537
+ const jobId = cronJobId(req.params.jobId);
538
+ if (!isRecord(req.body) || typeof req.body.enabled !== "boolean") {
539
+ throw new CronOperatorError("invalid_request", "enabled must be a boolean.", 400);
540
+ }
541
+ const action = cronActionInput(req.body);
542
+ void Promise.resolve(options.cron.setEffectiveEnabled(jobId, req.body.enabled, action))
543
+ .then((result) => sendCronMutation(res, result))
544
+ .catch(next);
545
+ }
546
+ catch (error) {
547
+ next(error);
548
+ }
549
+ });
203
550
  app.use((error, _req, res, next) => {
204
551
  if (res.headersSent) {
205
552
  next(error);
@@ -213,6 +560,10 @@ export async function startTuiAdapter(options) {
213
560
  }
214
561
  // 400 only for client mistakes (invalid_request, body-parse SyntaxError);
215
562
  // anything else is a server-side failure and must read as one.
563
+ if (error instanceof CronOperatorError) {
564
+ sendJsonError(res, error.status, error);
565
+ return;
566
+ }
216
567
  const isClientError = codeOf(error) === "invalid_request" ||
217
568
  (error instanceof SyntaxError && error.status === 400);
218
569
  sendJsonError(res, isClientError ? 400 : 500, error);
@@ -276,6 +627,7 @@ export async function startTuiAdapter(options) {
276
627
  kind: "finish",
277
628
  ...(response.text === undefined ? {} : { finalText: response.text }),
278
629
  ...(response.metadata === undefined ? {} : { metadata: response.metadata }),
630
+ ...(response.parts === undefined || response.parts.length === 0 ? {} : { parts: response.parts }),
279
631
  });
280
632
  }
281
633
  catch (error) {
@@ -317,7 +669,9 @@ export async function startTuiAdapter(options) {
317
669
  * the response's backpressure signal and carry a bounded pending-byte budget,
318
670
  * so a slow client cannot grow the process heap without limit. Oversized event
319
671
  * frames are reduced or replaced with a marker to meet the exported UTF-8 byte
320
- * cap; non-event frames retain their existing behavior.
672
+ * cap. Append text is split losslessly; other text fields are deterministically
673
+ * truncated, and terminal rich parts that cannot fit are replaced by an
674
+ * explicit failure marker.
321
675
  */
322
676
  class NdjsonMessageStream {
323
677
  res;
@@ -330,11 +684,30 @@ class NdjsonMessageStream {
330
684
  if (this.res.writableEnded) {
331
685
  return;
332
686
  }
333
- let line = serializeAgentStreamFrame(frame);
334
- if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES && frame.kind === "event") {
335
- line = serializeCappedEventFrame(frame.event, line);
687
+ const boundedFrame = frame.kind === "finish" ? capFinishReplyParts(frame) : frame;
688
+ const line = serializeAgentStreamFrame(boundedFrame);
689
+ if (Buffer.byteLength(line, "utf8") <= MAX_FRAME_BYTES) {
690
+ await this.writer.write(line);
691
+ return;
692
+ }
693
+ if (boundedFrame.kind === "append") {
694
+ for (const chunk of splitTextForWireFrame("append", boundedFrame.delta)) {
695
+ await this.writer.write(serializeAgentStreamFrame({ kind: "append", delta: chunk }));
696
+ }
697
+ return;
336
698
  }
337
- await this.writer.write(line);
699
+ const capped = boundedFrame.kind === "event"
700
+ ? serializeCappedEventFrame(boundedFrame.event, line)
701
+ : boundedFrame.kind === "finish"
702
+ ? serializeCappedFinishFrame(line)
703
+ : boundedFrame.kind === "status" || boundedFrame.kind === "replace" || boundedFrame.kind === "error"
704
+ ? serializeCappedTextFrame(boundedFrame)
705
+ : serializeAgentStreamFrame({
706
+ kind: "error",
707
+ code: "frame_too_large",
708
+ message: "A transport frame exceeded the maximum size.",
709
+ });
710
+ await this.writer.write(capped);
338
711
  }
339
712
  async status(text) {
340
713
  await this.writeFrame({ kind: "status", text });
@@ -353,6 +726,24 @@ class NdjsonMessageStream {
353
726
  // AgentResponse (which carries metadata); mid-stream finish() is a no-op.
354
727
  }
355
728
  }
729
+ function capFinishReplyParts(frame) {
730
+ const parts = frame.parts;
731
+ if (parts === undefined || parts.length <= MAX_AGENT_REPLY_PARTS)
732
+ return frame;
733
+ const omitted = parts.length - (MAX_AGENT_REPLY_PARTS - 1);
734
+ return {
735
+ ...frame,
736
+ parts: [
737
+ ...parts.slice(0, MAX_AGENT_REPLY_PARTS - 1),
738
+ {
739
+ type: "failure",
740
+ id: "wire-rich-parts-over-limit",
741
+ code: "reply_part_too_large",
742
+ message: `${omitted} rich reply part${omitted === 1 ? " was" : "s were"} omitted because the reply exceeded the 20-part transport limit.`,
743
+ },
744
+ ],
745
+ };
746
+ }
356
747
  /**
357
748
  * Prepare a stable reducer for the payload-bearing event variants whose shape
358
749
  * the operator adapter preserves under truncation. The input is the parsed
@@ -462,12 +853,217 @@ function serializeOversizedEventMarker(originalType) {
462
853
  },
463
854
  });
464
855
  }
856
+ function serializeCappedFinishFrame(serializedFrame) {
857
+ const snapshot = JSON.parse(serializedFrame);
858
+ const safeMetadata = compactFinishMetadata(snapshot.metadata);
859
+ const base = {
860
+ kind: "finish",
861
+ ...(safeMetadata === undefined ? {} : { metadata: safeMetadata }),
862
+ };
863
+ const parts = snapshot.parts ?? [];
864
+ const allPartsFrame = {
865
+ ...base,
866
+ ...(parts.length === 0 ? {} : { parts }),
867
+ };
868
+ const allPartsWithoutText = serializeAgentStreamFrame(allPartsFrame);
869
+ if (Buffer.byteLength(allPartsWithoutText, "utf8") <= MAX_FRAME_BYTES) {
870
+ const withText = serializeFinishWithCappedText(allPartsFrame, snapshot.finalText);
871
+ if (Buffer.byteLength(withText, "utf8") <= MAX_FRAME_BYTES)
872
+ return withText;
873
+ }
874
+ const failure = {
875
+ type: "failure",
876
+ id: "wire-rich-parts-truncated",
877
+ code: "reply_part_too_large",
878
+ message: "One or more rich reply parts were omitted because the terminal frame exceeded 256 KiB.",
879
+ };
880
+ let frame = {
881
+ ...base,
882
+ ...(snapshot.finalText === undefined ? {} : { finalText: snapshot.finalText }),
883
+ parts: [failure],
884
+ };
885
+ let line = serializeAgentStreamFrame(frame);
886
+ if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES && snapshot.finalText !== undefined) {
887
+ frame = {
888
+ ...frame,
889
+ finalText: `${largestTextThatFits(snapshot.finalText, (candidate) => serializeAgentStreamFrame({ ...frame, finalText: `${candidate}… [truncated]` }))}… [truncated]`,
890
+ };
891
+ }
892
+ const accepted = [];
893
+ for (const part of parts) {
894
+ const candidate = serializeAgentStreamFrame({ ...frame, parts: [...accepted, part, failure] });
895
+ if (Buffer.byteLength(candidate, "utf8") <= MAX_FRAME_BYTES) {
896
+ accepted.push(part);
897
+ }
898
+ }
899
+ line = serializeAgentStreamFrame({
900
+ ...frame,
901
+ parts: [...accepted, failure],
902
+ });
903
+ return Buffer.byteLength(line, "utf8") <= MAX_FRAME_BYTES
904
+ ? line
905
+ : serializeAgentStreamFrame({ kind: "finish", metadata: { truncated: true } });
906
+ }
907
+ function serializeFinishWithCappedText(frame, finalText) {
908
+ if (finalText === undefined)
909
+ return serializeAgentStreamFrame(frame);
910
+ const complete = serializeAgentStreamFrame({ ...frame, finalText });
911
+ if (Buffer.byteLength(complete, "utf8") <= MAX_FRAME_BYTES)
912
+ return complete;
913
+ const suffix = "… [truncated]";
914
+ const text = largestTextThatFits(finalText, (candidate) => serializeAgentStreamFrame({
915
+ ...frame,
916
+ finalText: `${candidate}${suffix}`,
917
+ }));
918
+ return serializeAgentStreamFrame({ ...frame, finalText: `${text}${suffix}` });
919
+ }
920
+ function compactFinishMetadata(metadata) {
921
+ if (metadata === undefined)
922
+ return undefined;
923
+ const compact = { truncated: true };
924
+ for (const key of ["runId", "conversationId", "requestId"]) {
925
+ const value = metadata[key];
926
+ if (typeof value === "string")
927
+ compact[key] = value.slice(0, 512);
928
+ }
929
+ return compact;
930
+ }
931
+ function serializeCappedTextFrame(frame) {
932
+ const suffix = "… [truncated]";
933
+ if (frame.kind === "error") {
934
+ const text = largestTextThatFits(frame.message, (candidate) => serializeAgentStreamFrame({
935
+ ...frame,
936
+ message: `${candidate}${suffix}`,
937
+ }));
938
+ return serializeAgentStreamFrame({ ...frame, message: `${text}${suffix}` });
939
+ }
940
+ const text = largestTextThatFits(frame.text, (candidate) => serializeAgentStreamFrame({
941
+ ...frame,
942
+ text: `${candidate}${suffix}`,
943
+ }));
944
+ return serializeAgentStreamFrame({ ...frame, text: `${text}${suffix}` });
945
+ }
946
+ function splitTextForWireFrame(kind, text) {
947
+ if (text.length === 0)
948
+ return [""];
949
+ const chunks = [];
950
+ let remaining = text;
951
+ while (remaining.length > 0) {
952
+ const chunk = largestTextThatFits(remaining, (candidate) => serializeAgentStreamFrame({ kind, delta: candidate }));
953
+ if (chunk.length === 0)
954
+ break;
955
+ chunks.push(chunk);
956
+ remaining = remaining.slice(chunk.length);
957
+ }
958
+ return chunks;
959
+ }
960
+ function largestTextThatFits(text, serialize) {
961
+ let lower = 0;
962
+ let upper = text.length;
963
+ let best = "";
964
+ while (lower <= upper) {
965
+ let length = Math.floor((lower + upper) / 2);
966
+ if (length > 0 && isHighSurrogate(text.charCodeAt(length - 1)))
967
+ length -= 1;
968
+ const candidate = text.slice(0, length);
969
+ if (Buffer.byteLength(serialize(candidate), "utf8") <= MAX_FRAME_BYTES) {
970
+ best = candidate;
971
+ lower = Math.max(lower + 1, length + 1);
972
+ }
973
+ else {
974
+ upper = length - 1;
975
+ }
976
+ }
977
+ return best;
978
+ }
979
+ function isHighSurrogate(code) {
980
+ return code >= 0xd800 && code <= 0xdbff;
981
+ }
465
982
  function serializeUnknown(value) {
466
983
  return typeof value === "string" ? value : JSON.stringify(value) ?? "";
467
984
  }
468
985
  function truncatePreparedText(text, cap) {
469
986
  return text.length > cap ? `${text.slice(0, cap)}… [truncated]` : text;
470
987
  }
988
+ function setReplyArtifactHeaders(res, attachment) {
989
+ const asciiName = attachment.name
990
+ .replace(/[^\x20-\x7e]/gu, "_")
991
+ .replace(/["\\]/gu, "_")
992
+ .slice(0, 180) || "attachment";
993
+ const encodedName = encodeURIComponent(attachment.name).replace(/['()*]/gu, (character) => `%${character.codePointAt(0).toString(16).toUpperCase()}`);
994
+ res.status(200);
995
+ const risky = /^(?:text\/(?:html|javascript|xml)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/iu
996
+ .test(attachment.mediaType);
997
+ res.setHeader("Content-Type", risky ? "application/octet-stream" : attachment.mediaType);
998
+ if (risky)
999
+ res.setHeader("X-Original-Content-Type", attachment.mediaType);
1000
+ res.setHeader("Content-Length", String(attachment.sizeBytes));
1001
+ res.setHeader("Accept-Ranges", "none");
1002
+ res.setHeader("Content-Disposition", `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
1003
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
1004
+ res.setHeader("X-Content-Type-Options", "nosniff");
1005
+ res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
1006
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1007
+ res.setHeader("X-Mono-Agent-Integrity-Id", attachment.integrityId);
1008
+ }
1009
+ async function writeBinaryChunk(res, chunk) {
1010
+ if (res.destroyed || res.writableEnded)
1011
+ throw new Error("Reply artifact client disconnected.");
1012
+ if (res.write(Buffer.from(chunk)))
1013
+ return;
1014
+ await new Promise((resolveDrain, rejectDrain) => {
1015
+ const cleanup = () => {
1016
+ clearTimeout(timer);
1017
+ res.off("drain", onDrain);
1018
+ res.off("close", onClose);
1019
+ res.off("error", onError);
1020
+ };
1021
+ const onDrain = () => { cleanup(); resolveDrain(); };
1022
+ const onClose = () => { cleanup(); rejectDrain(new Error("Reply artifact client disconnected.")); };
1023
+ const onError = (error) => { cleanup(); rejectDrain(error); };
1024
+ const timer = setTimeout(() => {
1025
+ cleanup();
1026
+ rejectDrain(new Error("Reply artifact response did not drain in time."));
1027
+ }, REPLY_ARTIFACT_DRAIN_TIMEOUT_MS);
1028
+ res.once("drain", onDrain);
1029
+ res.once("close", onClose);
1030
+ res.once("error", onError);
1031
+ });
1032
+ }
1033
+ function normalizeMcpAppIdentity(req) {
1034
+ const conversationId = normalizeOptionalString(typeof req.params.conversationId === "string" ? req.params.conversationId : undefined);
1035
+ const invocationId = normalizeOptionalString(typeof req.params.invocationId === "string" ? req.params.invocationId : undefined);
1036
+ const connectionId = normalizeOptionalString(req.header("x-mono-agent-mcp-connection-id"));
1037
+ if (conversationId === undefined
1038
+ || invocationId === undefined
1039
+ || connectionId === undefined
1040
+ || Buffer.byteLength(conversationId, "utf8") > MAX_MCP_APP_IDENTITY_BYTES
1041
+ || Buffer.byteLength(invocationId, "utf8") > MAX_MCP_APP_IDENTITY_BYTES
1042
+ || Buffer.byteLength(connectionId, "utf8") > MAX_MCP_APP_IDENTITY_BYTES)
1043
+ return undefined;
1044
+ return { conversationId, invocationId, connectionId };
1045
+ }
1046
+ function setPrivateMcpAppHeaders(res) {
1047
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
1048
+ res.setHeader("X-Content-Type-Options", "nosniff");
1049
+ res.setHeader("Content-Security-Policy", "default-src 'none'; base-uri 'none'; frame-ancestors 'none'");
1050
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1051
+ }
1052
+ function sendMcpAppError(res, error) {
1053
+ const code = codeOf(error);
1054
+ const status = code === "app_forbidden" || code === "app_missing" || code === "app_expired"
1055
+ ? 404
1056
+ : code === "app_request_too_large" ? 413
1057
+ : code === "app_tool_forbidden" || code === "app_resource_forbidden" || code === "app_open_link_forbidden" ? 403
1058
+ : code === "app_confirmation_required" ? 409
1059
+ : code === "app_audit_incomplete" ? 409
1060
+ : code === "app_rate_limited" ? 429
1061
+ : code === "app_connection_closed" ? 410
1062
+ : code === "app_audit_failed" ? 507
1063
+ : 500;
1064
+ setPrivateMcpAppHeaders(res);
1065
+ sendJsonError(res, status, error);
1066
+ }
471
1067
  function normalizeVerbatimBody(rawConversationId, body) {
472
1068
  const conversationId = normalizeOptionalString(typeof rawConversationId === "string" ? rawConversationId : undefined);
473
1069
  if (conversationId === undefined) {
@@ -753,6 +1349,56 @@ async function resolveInfo(info) {
753
1349
  }
754
1350
  return info;
755
1351
  }
1352
+ function cronJobId(value) {
1353
+ const raw = typeof value === "string" ? value : undefined;
1354
+ const jobId = normalizeOptionalString(raw);
1355
+ if (jobId === undefined || Buffer.byteLength(jobId, "utf8") > 512) {
1356
+ throw new CronOperatorError("invalid_request", "A valid cron job id is required.", 400);
1357
+ }
1358
+ return jobId;
1359
+ }
1360
+ function cronRunId(value) {
1361
+ const raw = typeof value === "string" ? value : undefined;
1362
+ const runId = normalizeOptionalString(raw);
1363
+ if (runId === undefined || Buffer.byteLength(runId, "utf8") > 4_096) {
1364
+ throw new CronOperatorError("invalid_request", "A valid cron run id is required.", 400);
1365
+ }
1366
+ return runId;
1367
+ }
1368
+ function cronActionInput(value) {
1369
+ if (!isRecord(value)) {
1370
+ throw new CronOperatorError("invalid_request", "A JSON action body is required.", 400);
1371
+ }
1372
+ const idempotencyKey = normalizeOptionalString(typeof value.idempotencyKey === "string" ? value.idempotencyKey : undefined);
1373
+ if (idempotencyKey === undefined || Buffer.byteLength(idempotencyKey, "utf8") > 256) {
1374
+ throw new CronOperatorError("invalid_request", "A valid idempotencyKey is required.", 400);
1375
+ }
1376
+ const confirmationToken = normalizeOptionalString(typeof value.confirmationToken === "string" ? value.confirmationToken : undefined);
1377
+ if (confirmationToken !== undefined && Buffer.byteLength(confirmationToken, "utf8") > 1_024) {
1378
+ throw new CronOperatorError("invalid_request", "confirmationToken is too large.", 400);
1379
+ }
1380
+ return { idempotencyKey, ...(confirmationToken === undefined ? {} : { confirmationToken }) };
1381
+ }
1382
+ function requireCronActionKey(res, apiKey) {
1383
+ if (apiKey !== undefined)
1384
+ return true;
1385
+ sendJsonError(res, 403, new CronOperatorError("actions_disabled", "Cron actions require an operator API key.", 403));
1386
+ return false;
1387
+ }
1388
+ function sendCronMutation(res, result) {
1389
+ if (result.kind === "confirmation_required") {
1390
+ sendBoundedCronJson(res, 428, result);
1391
+ return;
1392
+ }
1393
+ sendBoundedCronJson(res, 200, result);
1394
+ }
1395
+ function sendBoundedCronJson(res, status, value) {
1396
+ const serialized = JSON.stringify(value);
1397
+ if (Buffer.byteLength(serialized, "utf8") > MAX_CRON_OPERATOR_RESPONSE_BYTES) {
1398
+ throw new CronOperatorError("unavailable", "Cron operator response exceeded its bounded wire contract.", 503);
1399
+ }
1400
+ res.status(status).type("application/json").send(serialized);
1401
+ }
756
1402
  function authorize(req, res, apiKey) {
757
1403
  if (apiKey === undefined) {
758
1404
  return true;
@@ -772,6 +1418,64 @@ function sendJsonError(res, status, error) {
772
1418
  },
773
1419
  });
774
1420
  }
1421
+ function sendBoundedJobs(res, jobs) {
1422
+ const body = serializeBoundedJobs(jobs);
1423
+ if (body === undefined) {
1424
+ sendJsonError(res, 413, new TuiAdapterError("process_job_response_too_large", `Process-job list exceeds the ${String(MAX_PROCESS_JOBS_RESPONSE_BYTES)}-byte operator response bound.`));
1425
+ return;
1426
+ }
1427
+ res.status(200).type("application/json").send(body);
1428
+ }
1429
+ function serializeBoundedJobs(jobs) {
1430
+ const parsed = parseProcessJobProjections(jobs);
1431
+ const selected = new Map();
1432
+ let selectedCount = 0;
1433
+ let bodyBytes = Buffer.byteLength('{"jobs":[]}', "utf8");
1434
+ const add = (index, projection) => {
1435
+ const serialized = JSON.stringify(projection);
1436
+ const nextBytes = bodyBytes
1437
+ + Buffer.byteLength(serialized, "utf8")
1438
+ + (selectedCount === 0 ? 0 : 1);
1439
+ if (nextBytes > MAX_PROCESS_JOBS_RESPONSE_BYTES)
1440
+ return false;
1441
+ selected.set(index, serialized);
1442
+ selectedCount += 1;
1443
+ bodyBytes = nextBytes;
1444
+ return true;
1445
+ };
1446
+ // The app can retain up to 32 starting/running and 64 queued records
1447
+ // alongside its terminal ceiling. Keep that complete control-plane view
1448
+ // even when large terminal projections require a smaller HTTP representation.
1449
+ for (const [index, projection] of parsed.entries()) {
1450
+ if (isActiveProcessJobProjection(projection) && !add(index, projection))
1451
+ return undefined;
1452
+ }
1453
+ // Service lists are already newest-first. Select one deterministic terminal
1454
+ // prefix so a larger byte budget can only extend, never reshuffle, history.
1455
+ for (const [index, projection] of parsed.entries()) {
1456
+ if (isActiveProcessJobProjection(projection))
1457
+ continue;
1458
+ if (!add(index, projection))
1459
+ break;
1460
+ }
1461
+ const serialized = [...selected.entries()]
1462
+ .sort(([left], [right]) => left - right)
1463
+ .map(([, projection]) => projection);
1464
+ return `{"jobs":[${serialized.join(",")}]}`;
1465
+ }
1466
+ function isActiveProcessJobProjection(projection) {
1467
+ return projection.state === "queued"
1468
+ || projection.state === "starting"
1469
+ || projection.state === "running";
1470
+ }
1471
+ function sendBoundedJob(res, job) {
1472
+ const body = JSON.stringify(parseProcessJobProjection(job));
1473
+ if (Buffer.byteLength(body, "utf8") > MAX_PROCESS_JOBS_RESPONSE_BYTES) {
1474
+ sendJsonError(res, 413, new TuiAdapterError("process_job_response_too_large", `Process-job projection exceeds the ${String(MAX_PROCESS_JOBS_RESPONSE_BYTES)}-byte operator response bound.`));
1475
+ return;
1476
+ }
1477
+ res.status(200).type("application/json").send(body);
1478
+ }
775
1479
  function codeOf(error) {
776
1480
  const candidate = error?.code;
777
1481
  return typeof candidate === "string" ? candidate : undefined;