@opengeni/api-router 0.7.3 → 0.11.1

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/src/mcp/server.ts CHANGED
@@ -2,11 +2,15 @@ import {
2
2
  CreateScheduledTaskRequest,
3
3
  defaultRepositoryMountPath,
4
4
  SESSION_EVENT_RAW_DELTA_TYPES,
5
+ SessionEventLatestClass,
5
6
  SessionEventPayloadMode,
6
7
  SessionEventReadDirection,
7
8
  SessionEventReadMode,
9
+ SessionEventResultMode,
8
10
  SessionEventSemanticClass,
9
11
  SessionEventType,
12
+ compactSessionEventResult,
13
+ sessionEventLatestClassToSemanticClass,
10
14
  SessionMcpCredentialUpdateInput,
11
15
  VariableSetVariableName,
12
16
  type AccessGrant,
@@ -14,6 +18,7 @@ import {
14
18
  type Permission,
15
19
  type ResourceRef,
16
20
  type SessionAuthorizationOperation,
21
+ type Session,
17
22
  UpdateScheduledTaskRequest,
18
23
  } from "@opengeni/contracts";
19
24
  import {
@@ -53,15 +58,15 @@ import {
53
58
  saveWorkspaceMemory,
54
59
  searchWorkspaceMemories,
55
60
  serializeEffectiveSessionControl,
56
- setSessionGoalStatus,
61
+ setSessionGoalStatusWithEvent,
57
62
  setVariableSetVariable,
58
63
  updateScheduledTask,
59
- updateSessionGoal,
60
- upsertSessionGoal,
64
+ updateSessionGoalWithEvent,
65
+ upsertSessionGoalWithEvent,
61
66
  RigChangeAlreadyVerifyingError,
62
67
  RigChangeTransitionError,
63
68
  } from "@opengeni/db";
64
- import { appendAndPublishEvents } from "@opengeni/events";
69
+ import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
65
70
  import {
66
71
  createGitHubAppInstallationToken,
67
72
  GitHubAppConfigurationError,
@@ -104,9 +109,14 @@ import {
104
109
  controlAgentSessionWorkstream,
105
110
  controlHumanSessionWorkstream,
106
111
  createSessionForRequest,
112
+ SessionSpawnDeniedError,
113
+ sessionSpawnDenialEnvelope,
107
114
  sendAgentSessionMessage,
108
115
  steerAgentSession,
109
116
  updateSessionTitle,
117
+ sessionWithEffectiveToolPolicy,
118
+ workspaceSessionToolPolicyDefaultServerIds,
119
+ workspaceSessionToolPolicyServerIds,
110
120
  type AgentSessionCommandContext,
111
121
  } from "@opengeni/core";
112
122
  import {
@@ -120,12 +130,14 @@ import {
120
130
  type RunOnOp,
121
131
  } from "@opengeni/core";
122
132
  import {
133
+ boundSessionEventCompactResult,
123
134
  boundSessionEventMcpPage,
124
135
  boundSessionDetailMcp,
125
136
  boundRigDetailMcp,
126
137
  SESSION_EVENT_MCP_MAX_BYTES,
127
138
  } from "./session-view";
128
139
  import type { ToolspaceMcpSurface } from "./toolspace";
140
+ import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
129
141
 
130
142
  export type McpServerOptions = {
131
143
  // Origin of the HTTP request that reached the MCP route. Retained in the
@@ -730,7 +742,7 @@ function registerGoalTools(
730
742
  ? (grant.metadata["turnId"] as string)
731
743
  : null;
732
744
  await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
733
- const { goal, replaced } = await upsertSessionGoal(deps.db, {
745
+ const { goal, events } = await upsertSessionGoalWithEvent(deps.db, {
734
746
  accountId: grant.accountId,
735
747
  workspaceId: grant.workspaceId,
736
748
  sessionId,
@@ -738,20 +750,11 @@ function registerGoalTools(
738
750
  successCriteria: successCriteria ?? null,
739
751
  maxAutoContinuations: maxAutoContinuations ?? null,
740
752
  createdBy: "agent",
753
+ actor: "agent",
741
754
  });
742
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
743
- {
744
- type: "goal.set",
745
- payload: {
746
- goalId: goal.id,
747
- text: goal.text,
748
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
749
- version: goal.version,
750
- actor: "agent",
751
- replaced,
752
- },
753
- },
754
- ]);
755
+ if (events.length > 0) {
756
+ await deps.bus.publish(grant.workspaceId, sessionId, events);
757
+ }
755
758
  return json(goal);
756
759
  },
757
760
  );
@@ -765,36 +768,36 @@ function registerGoalTools(
765
768
  text: z4.string().min(1).optional(),
766
769
  successCriteria: z4.string().min(1).optional(),
767
770
  progressNote: z4.string().min(1).optional(),
771
+ idempotencyKey: z4.string().uuid(),
768
772
  },
769
773
  },
770
- async ({ text, successCriteria, progressNote }) => {
774
+ async ({ text, successCriteria, progressNote, idempotencyKey }) => {
771
775
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
772
- await requireSession(deps.db, grant.workspaceId, sessionId);
773
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
774
- if (!existing) {
775
- throw new Error("this session has no goal; use goal_set first");
776
- }
777
- if (existing.status === "completed") {
778
- throw new Error("session goal is completed; use goal_set to start a new goal");
779
- }
780
- const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
781
- ...(text !== undefined ? { text } : {}),
782
- ...(successCriteria !== undefined ? { successCriteria } : {}),
783
- });
784
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
776
+ const context = exactAgentCommandContext(grant, sessionId);
777
+ const { goal, events, operationId, replay } = await updateSessionGoalWithEvent(
778
+ deps.db,
779
+ grant.workspaceId,
780
+ sessionId,
785
781
  {
786
- type: "goal.updated",
787
- payload: {
788
- goalId: goal.id,
789
- text: goal.text,
790
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
791
- ...(progressNote ? { progressNote } : {}),
792
- version: goal.version,
793
- actor: "agent",
782
+ ...(text !== undefined ? { text } : {}),
783
+ ...(successCriteria !== undefined ? { successCriteria } : {}),
784
+ ...(progressNote !== undefined ? { progressNote } : {}),
785
+ actor: "agent",
786
+ command: {
787
+ accountId: grant.accountId,
788
+ actor: {
789
+ type: "agent_attempt",
790
+ attemptId: context.callerAttemptId,
791
+ sessionId: context.callerSessionId,
792
+ turnId: context.callerTurnId,
793
+ executionGeneration: context.callerExecutionGeneration,
794
+ },
795
+ operationKey: idempotencyKey,
794
796
  },
795
797
  },
796
- ]);
797
- return json(goal);
798
+ );
799
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
800
+ return json({ ...goal, operationId, replay });
798
801
  },
799
802
  );
800
803
 
@@ -812,17 +815,18 @@ function registerGoalTools(
812
815
  if (!existing) {
813
816
  throw new Error("this session has no goal; use goal_set first");
814
817
  }
815
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
816
- status: "completed",
817
- evidence,
818
- });
819
- if (changed) {
820
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
821
- {
822
- type: "goal.completed",
823
- payload: { goalId: goal.id, evidence, version: goal.version },
824
- },
825
- ]);
818
+ const { goal, events } = await setSessionGoalStatusWithEvent(
819
+ deps.db,
820
+ grant.workspaceId,
821
+ sessionId,
822
+ {
823
+ status: "completed",
824
+ evidence,
825
+ event: { type: "goal.completed", evidence },
826
+ },
827
+ );
828
+ if (events.length > 0) {
829
+ await deps.bus.publish(grant.workspaceId, sessionId, events);
826
830
  }
827
831
  return json(goal);
828
832
  },
@@ -842,25 +846,24 @@ function registerGoalTools(
842
846
  if (!existing) {
843
847
  throw new Error("this session has no goal; use goal_set first");
844
848
  }
845
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
846
- status: "paused",
847
- rationale,
848
- pausedReason: "agent",
849
- });
850
- if (changed) {
851
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
852
- {
849
+ const { goal, events } = await setSessionGoalStatusWithEvent(
850
+ deps.db,
851
+ grant.workspaceId,
852
+ sessionId,
853
+ {
854
+ status: "paused",
855
+ rationale,
856
+ pausedReason: "agent",
857
+ event: {
853
858
  type: "goal.paused",
854
- payload: {
855
- goalId: goal.id,
856
- actor: "agent",
857
- reason: "agent",
858
- rationale,
859
- autoContinuations: goal.autoContinuations,
860
- noProgressStreak: goal.noProgressStreak,
861
- },
859
+ actor: "agent",
860
+ reason: "agent",
861
+ rationale,
862
862
  },
863
- ]);
863
+ },
864
+ );
865
+ if (events.length > 0) {
866
+ await deps.bus.publish(grant.workspaceId, sessionId, events);
864
867
  }
865
868
  return json(goal);
866
869
  },
@@ -1029,6 +1032,13 @@ function registerFleetTools(
1029
1032
  db: deps.db,
1030
1033
  settings: deps.settings,
1031
1034
  bus: deps.bus,
1035
+ ensureSessionGroupReady: async (ctx) => {
1036
+ const session = await requireSession(deps.db, ctx.workspaceId, ctx.sessionId);
1037
+ return await ensureViewerSessionGroupReady(
1038
+ { db: deps.db, settings: deps.settings, bus: deps.bus },
1039
+ { accountId: ctx.accountId, workspaceId: ctx.workspaceId, session },
1040
+ );
1041
+ },
1032
1042
  };
1033
1043
 
1034
1044
  // Resolve the session's group sandbox (the default/home fleet member) at
@@ -1046,7 +1056,7 @@ function registerFleetTools(
1046
1056
  "sandboxes_list",
1047
1057
  {
1048
1058
  description:
1049
- "List the sandboxes this session can run on: its own session sandbox (the Modal box) PLUS the workspace's enrolled selfhosted machines, each with liveness (online/reconnecting/offline) and an `active` marker for the currently-routed one. Use before sandbox_attach/sandbox_swap to pick a target. The `id` of any entry is the `target` for attach/swap/run_on.",
1059
+ "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
1050
1060
  inputSchema: {},
1051
1061
  },
1052
1062
  async () => json(await listFleet(services, await fleetContext())),
@@ -1056,7 +1066,7 @@ function registerFleetTools(
1056
1066
  "sandbox_attach",
1057
1067
  {
1058
1068
  description:
1059
- 'Attach this session to a sandbox (make it the active sandbox the agent\'s next tool calls run on). Heterogeneous: a Modal box or an enrolled selfhosted machine. Validates the target is owned by this workspace and online, then repoints under an epoch fence. Identical mechanic to sandbox_swap; use `target` = a sandboxes_list `id`, or "session"/"default" for this session\'s own box.',
1069
+ 'Attach this session to a sandbox for the next tool call. The target must be owned and verified ready. A same-target attach is a repair request: it revalidates readiness and advances the route epoch rather than returning unchanged success. Recovery-in-progress/degraded/unrecoverable outcomes are typed. Use a sandboxes_list `id`, or "session"/"default" for home.',
1060
1070
  inputSchema: { target: z4.string().min(1) },
1061
1071
  },
1062
1072
  async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
@@ -1066,7 +1076,7 @@ function registerFleetTools(
1066
1076
  "sandbox_swap",
1067
1077
  {
1068
1078
  description:
1069
- 'Swap the active sandbox for this session mid-conversation (the next tool call runs on the new box). Heterogeneous Modal<->selfhosted<->selfhosted, single active at a time, flippable as many times as you like. Validates ownership + liveness, then bumps the active epoch (fencing any in-flight op, which retries against the new box). `target` = a sandboxes_list `id`, or "session"/"default" to swap back to this session\'s own box.',
1079
+ 'Swap the active sandbox for this session mid-conversation. Validates ownership and verified readiness, then advances the route epoch. Same-target swaps also revalidate and fence stale route caches. An operation that encountered provider disappearance is not replayed; retry only after a typed recovery-ready result. Use a sandboxes_list `id`, or "session"/"default" for home.',
1070
1080
  inputSchema: { target: z4.string().min(1) },
1071
1081
  },
1072
1082
  async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
@@ -1401,7 +1411,14 @@ function registerWorkspaceOrchestrationTools(
1401
1411
  },
1402
1412
  authorization?.relatedSessionAccess ?? "root",
1403
1413
  );
1404
- return json(boundSessionDetailMcp(projected));
1414
+ return json(
1415
+ boundSessionDetailMcp(
1416
+ await withMcpEffectivePolicy(deps, grant.workspaceId, {
1417
+ ...projected,
1418
+ effectiveControl: queue?.effectiveControl ?? projected.effectiveControl,
1419
+ }),
1420
+ ),
1421
+ );
1405
1422
  },
1406
1423
  );
1407
1424
 
@@ -1409,7 +1426,7 @@ function registerWorkspaceOrchestrationTools(
1409
1426
  "session_events",
1410
1427
  {
1411
1428
  description:
1412
- "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the newest event in exactly one semantic class; it cannot be combined with type or class filters. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
1429
+ "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the authoritative newest durable sequence in exactly one semantic class; `receipt` is the concise alias for `tool_receipt`, and latest cannot be combined with type or class filters. Add `resultMode=compact` to latest for one bounded result-bearing completion/checkpoint/receipt without another inference. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
1413
1430
  inputSchema: {
1414
1431
  sessionId: z4.string().uuid(),
1415
1432
  after: z4.number().int().nonnegative().optional(),
@@ -1418,6 +1435,7 @@ function registerWorkspaceOrchestrationTools(
1418
1435
  direction: z4.enum(SessionEventReadDirection.options).optional(),
1419
1436
  mode: z4.enum(SessionEventReadMode.options).optional(),
1420
1437
  payloadMode: z4.enum(SessionEventPayloadMode.options).optional(),
1438
+ resultMode: z4.enum(SessionEventResultMode.options).optional(),
1421
1439
  includeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1422
1440
  excludeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1423
1441
  includeClasses: z4
@@ -1428,7 +1446,7 @@ function registerWorkspaceOrchestrationTools(
1428
1446
  .array(z4.enum(SessionEventSemanticClass.options))
1429
1447
  .max(SessionEventSemanticClass.options.length)
1430
1448
  .optional(),
1431
- latest: z4.enum(SessionEventSemanticClass.options).optional(),
1449
+ latest: z4.enum(SessionEventLatestClass.options).optional(),
1432
1450
  },
1433
1451
  },
1434
1452
  async ({
@@ -1439,6 +1457,7 @@ function registerWorkspaceOrchestrationTools(
1439
1457
  direction: requestedDirection,
1440
1458
  mode: requestedMode,
1441
1459
  payloadMode: requestedPayloadMode,
1460
+ resultMode: requestedResultMode,
1442
1461
  includeTypes,
1443
1462
  excludeTypes,
1444
1463
  includeClasses,
@@ -1446,6 +1465,11 @@ function registerWorkspaceOrchestrationTools(
1446
1465
  latest,
1447
1466
  }) => {
1448
1467
  await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
1468
+ const latestClass =
1469
+ latest === undefined ? undefined : sessionEventLatestClassToSemanticClass(latest);
1470
+ if (requestedResultMode === "compact" && latestClass === undefined) {
1471
+ throw new Error("resultMode=compact requires latest");
1472
+ }
1449
1473
  await requireSession(deps.db, grant.workspaceId, sessionId);
1450
1474
  if (
1451
1475
  latest &&
@@ -1456,24 +1480,42 @@ function registerWorkspaceOrchestrationTools(
1456
1480
  throw new Error("latest cannot be combined with event filters");
1457
1481
  }
1458
1482
  const mode = requestedMode ?? (after !== undefined ? "forensic" : "monitoring");
1459
- const direction = latest
1483
+ const direction = latestClass
1460
1484
  ? "before"
1461
1485
  : (requestedDirection ??
1462
1486
  (before !== undefined ? "before" : after !== undefined ? "after" : "before"));
1463
- const payloadMode = requestedPayloadMode ?? (mode === "monitoring" ? "summary" : "full");
1487
+ const payloadMode =
1488
+ requestedResultMode === "compact"
1489
+ ? "full"
1490
+ : (requestedPayloadMode ?? (mode === "monitoring" ? "summary" : "full"));
1464
1491
  const dbPage = await listSessionEventPage(deps.db, grant.workspaceId, sessionId, {
1465
1492
  after: after ?? 0,
1466
1493
  ...(before !== undefined ? { before } : {}),
1467
1494
  direction,
1468
- limit: latest ? 1 : boundedSessionEventMcpLimit(limit),
1495
+ limit: latestClass ? 1 : boundedSessionEventMcpLimit(limit),
1469
1496
  payloadMode,
1470
1497
  includeTypes: includeTypes ?? [],
1471
1498
  excludeTypes: excludeTypes ?? [],
1472
- includeClasses: latest ? [latest] : (includeClasses ?? []),
1499
+ includeClasses: latestClass ? [latestClass] : (includeClasses ?? []),
1473
1500
  excludeClasses: excludeClasses ?? [],
1474
1501
  ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
1502
+ ...(latestClass ? { authoritativeLatest: true } : {}),
1475
1503
  maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
1476
1504
  });
1505
+ if (requestedResultMode === "compact") {
1506
+ const event = dbPage.events[0];
1507
+ return json(
1508
+ event
1509
+ ? boundSessionEventCompactResult(
1510
+ compactSessionEventResult(
1511
+ event,
1512
+ latestClass!,
1513
+ dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
1514
+ ),
1515
+ )
1516
+ : null,
1517
+ );
1518
+ }
1477
1519
  return json(
1478
1520
  boundSessionEventMcpPage({
1479
1521
  events: dbPage.events,
@@ -1534,6 +1576,9 @@ function registerWorkspaceOrchestrationTools(
1534
1576
  // Workspace-scoped CREATE idempotency key: a retried session_create with
1535
1577
  // the same key returns the already-spawned worker instead of a duplicate.
1536
1578
  idempotencyKey: z4.string().min(1).max(200).optional(),
1579
+ // Per-session/agent descendant policy. Reductions need only create;
1580
+ // increases are authorized server-side with workspace:admin.
1581
+ maxNestedAgentDepth: z4.number().int().nonnegative().optional(),
1537
1582
  // First-party MCP token permissions for the spawned session; every
1538
1583
  // permission must be held by this grant (validated in the domain).
1539
1584
  // A goal requires goals:manage in the resulting set; it is never
@@ -1578,10 +1623,21 @@ function registerWorkspaceOrchestrationTools(
1578
1623
  },
1579
1624
  },
1580
1625
  async (args) => {
1581
- if (callerSessionId !== null) {
1582
- await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1626
+ try {
1627
+ if (callerSessionId !== null) {
1628
+ await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1629
+ }
1630
+ const created = await createSessionForRequest(deps, grant, grant.workspaceId, args);
1631
+ return json(await withMcpEffectivePolicy(deps, grant.workspaceId, created));
1632
+ } catch (error) {
1633
+ if (error instanceof SessionSpawnDeniedError) {
1634
+ return {
1635
+ ...json(sessionSpawnDenialEnvelope(error)),
1636
+ isError: true,
1637
+ };
1638
+ }
1639
+ throw error;
1583
1640
  }
1584
- return json(await createSessionForRequest(deps, grant, grant.workspaceId, args));
1585
1641
  },
1586
1642
  );
1587
1643
  }
@@ -2542,3 +2598,15 @@ function parseMcpDate(raw: string, label: string): Date {
2542
2598
  }
2543
2599
  return date;
2544
2600
  }
2601
+
2602
+ async function withMcpEffectivePolicy(
2603
+ deps: ApiRouteDeps,
2604
+ workspaceId: string,
2605
+ session: Session,
2606
+ ): Promise<Session> {
2607
+ const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
2608
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
2609
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
2610
+ ]);
2611
+ return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
2612
+ }
@@ -13,11 +13,16 @@ import type {
13
13
  Rig,
14
14
  Session,
15
15
  SessionEvent,
16
+ SessionEventCompactResult,
16
17
  SessionEventPayloadMode,
17
18
  SessionEventReadDirection,
18
19
  SessionEventReadMode,
19
20
  } from "@opengeni/contracts";
20
- import { measureSessionEventJson } from "@opengeni/contracts";
21
+ import {
22
+ boundSessionEventPayload,
23
+ measureSessionEventJson,
24
+ sessionEventJsonBytes,
25
+ } from "@opengeni/contracts";
21
26
 
22
27
  export const SESSION_EVENT_MCP_MAX_BYTES = 64 * 1024;
23
28
  export const SESSION_EVENT_MCP_FIELD_MAX_CHARS = 4_000;
@@ -25,6 +30,102 @@ export const DEFAULT_SESSION_DETAIL_CHARS = 6_000;
25
30
  export const SESSION_DETAIL_MCP_MAX_BYTES = 64 * 1024;
26
31
  export const RIG_DETAIL_MCP_MAX_BYTES = 64 * 1024;
27
32
 
33
+ /**
34
+ * Keep the single-result MCP response below the same pretty-JSON envelope as
35
+ * event pages. The contracts projection bounds each value independently for
36
+ * HTTP/SDK use; this second boundary accounts for the result identity,
37
+ * failure/truncation metadata, and MCP's pretty-printing overhead.
38
+ */
39
+ export function boundSessionEventCompactResult(
40
+ result: SessionEventCompactResult,
41
+ maxBytes = SESSION_EVENT_MCP_MAX_BYTES,
42
+ ): SessionEventCompactResult {
43
+ const envelopeMaxBytes = Math.max(8 * 1024, maxBytes);
44
+
45
+ const project = (budget: number): SessionEventCompactResult => {
46
+ const noValues = budget <= 0;
47
+ const text =
48
+ noValues || result.text === null ? null : clampString(result.text, Math.max(128, budget));
49
+ const boundValue = (value: unknown): unknown =>
50
+ noValues || value === null
51
+ ? null
52
+ : boundSessionEventPayload(value, {
53
+ surface: "http_projection",
54
+ maxBytes: Math.max(1_024, budget),
55
+ });
56
+ const output = boundValue(result.output);
57
+ const resultValue = boundValue(result.result);
58
+ const checkpoint = boundValue(result.checkpoint);
59
+ const receipt = boundValue(result.receipt);
60
+ const failure =
61
+ noValues || result.failure === null
62
+ ? null
63
+ : {
64
+ error:
65
+ clampString(result.failure.error ?? "", Math.max(128, Math.floor(budget / 3))) ||
66
+ null,
67
+ code:
68
+ clampString(result.failure.code ?? "", Math.max(128, Math.floor(budget / 6))) || null,
69
+ retryable: result.failure.retryable,
70
+ recovery:
71
+ clampString(result.failure.recovery ?? "", Math.max(128, Math.floor(budget / 3))) ||
72
+ null,
73
+ };
74
+ const changed =
75
+ text !== result.text ||
76
+ output !== result.output ||
77
+ resultValue !== result.result ||
78
+ checkpoint !== result.checkpoint ||
79
+ receipt !== result.receipt ||
80
+ JSON.stringify(failure) !== JSON.stringify(result.failure);
81
+ // A compact result can inherit a source-payload boundary without any of
82
+ // its already-bounded slots changing at the MCP boundary. Keep that loss
83
+ // visible on the model-facing result, but do not manufacture a new byte
84
+ // count: the source projection owns the original/delivered accounting.
85
+ const inheritedSourceBoundary = result.truncation.fields.includes("payload");
86
+ const mcpBoundaryRecorded = changed || inheritedSourceBoundary;
87
+ const deliveredBytes = sessionEventJsonBytes({
88
+ text,
89
+ output,
90
+ result: resultValue,
91
+ failure,
92
+ checkpoint,
93
+ receipt,
94
+ });
95
+ return {
96
+ ...result,
97
+ text,
98
+ output,
99
+ result: resultValue,
100
+ failure,
101
+ checkpoint,
102
+ receipt,
103
+ truncation: {
104
+ ...result.truncation,
105
+ truncated: result.truncation.truncated || changed,
106
+ fields: mcpBoundaryRecorded
107
+ ? [...new Set([...result.truncation.fields, "mcp_envelope"])]
108
+ : result.truncation.fields,
109
+ originalBytes: changed
110
+ ? (result.truncation.originalBytes ?? result.truncation.deliveredBytes)
111
+ : result.truncation.originalBytes,
112
+ deliveredBytes,
113
+ },
114
+ };
115
+ };
116
+
117
+ // Start with a generous budget and tighten only if the full compact result
118
+ // would exceed MCP's envelope. This preserves as much result-bearing data
119
+ // as possible while guaranteeing a truthful bounded response.
120
+ for (const budget of [12_000, 8_000, 4_000, 2_000, 1_000, 0]) {
121
+ const candidate = project(budget);
122
+ if (prettyJsonBytes(candidate) <= envelopeMaxBytes) return candidate;
123
+ }
124
+ throw new RangeError(
125
+ `Session-event compact result exceeds its ${envelopeMaxBytes}-byte envelope`,
126
+ );
127
+ }
128
+
28
129
  function safeStringify(value: unknown): string {
29
130
  if (typeof value === "string") return value;
30
131
  try {