@opengeni/api-router 0.5.4 → 0.5.6

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
@@ -9,7 +9,6 @@ import {
9
9
  UpdateScheduledTaskRequest,
10
10
  } from "@opengeni/contracts";
11
11
  import {
12
- addSessionSystemUpdate,
13
12
  correctWorkspaceMemory,
14
13
  countVariableSets,
15
14
  beginRigChangeVerificationAttempt,
@@ -18,6 +17,7 @@ import {
18
17
  encryptVariableSetValue,
19
18
  getSession,
20
19
  getSessionGoal,
20
+ getSessionQueueSnapshot,
21
21
  getSessionTurn,
22
22
  getVariableSet,
23
23
  getVariableSetByName,
@@ -36,10 +36,8 @@ import {
36
36
  requireFile,
37
37
  requireScheduledTask,
38
38
  requireSession,
39
- requestSessionControl,
40
39
  saveWorkspaceMemory,
41
40
  searchWorkspaceMemories,
42
- setSessionChildNotificationsMode,
43
41
  setSessionGoalStatus,
44
42
  setVariableSetVariable,
45
43
  updateScheduledTask,
@@ -87,9 +85,13 @@ import {
87
85
  } from "@opengeni/core";
88
86
  import {
89
87
  acceptSessionUserMessage,
88
+ controlAgentSessionWorkstream,
89
+ controlHumanSessionWorkstream,
90
90
  createSessionForRequest,
91
+ sendAgentSessionMessage,
92
+ steerAgentSession,
91
93
  updateSessionTitle,
92
- workflowIdForSession,
94
+ type AgentSessionCommandContext,
93
95
  } from "@opengeni/core";
94
96
  import {
95
97
  buildFleetContextForSession,
@@ -151,25 +153,6 @@ export function buildOpenGeniMcpServer(
151
153
  },
152
154
  );
153
155
  }
154
- if (sessionId !== null && !toolspaceMode && can("sessions:create")) {
155
- server.registerTool(
156
- "set_child_notifications_mode",
157
- {
158
- description:
159
- "Change how workers you spawn report back when they finish. This setting persists across turns and must not be re-applied as routine setup or recovery. 'digest' (default): completions arrive as a coalesced turn you process. 'passive': completions appear only as quiet cards and never queue a turn or model run. Call only when the desired mode differs from the mode already in effect.",
160
- inputSchema: { mode: z4.enum(["digest", "passive"]) },
161
- },
162
- async ({ mode }) => {
163
- const changed = await setSessionChildNotificationsMode(
164
- deps.db,
165
- grant.workspaceId,
166
- sessionId,
167
- mode,
168
- );
169
- return json({ ok: true, changed, mode });
170
- },
171
- );
172
- }
173
156
  // Goal tools require goals:manage (in the default first-party permission set).
174
157
  if (sessionId !== null && can("goals:manage")) {
175
158
  registerGoalTools(server, deps, grant, sessionId, json);
@@ -204,7 +187,7 @@ export function buildOpenGeniMcpServer(
204
187
  // and mint GitHub install links out of the box. A user DEMOTES a specific
205
188
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
206
189
  // the creator's own grant); operators still cap what any session can be given.
207
- registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, json);
190
+ registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
208
191
  registerVariableSetTools(server, deps, grant, can, json);
209
192
  if (can("github:use")) {
210
193
  registerGitHubConnectTool(server, deps, grant, options, json);
@@ -1235,12 +1218,39 @@ function registerRigTools(
1235
1218
  // Workspace orchestration for manager-style agents. Session-authenticated
1236
1219
  // workers communicate through the typed internal-update plane; only a
1237
1220
  // sessionless operator can append a visible prompt through this surface.
1221
+ function exactAgentCommandContext(
1222
+ grant: AccessGrant,
1223
+ callerSessionId: string,
1224
+ ): AgentSessionCommandContext {
1225
+ const turnId = grant.metadata?.["turnId"];
1226
+ const attemptId = grant.metadata?.["attemptId"];
1227
+ const executionGeneration = grant.metadata?.["executionGeneration"];
1228
+ if (
1229
+ typeof turnId !== "string" ||
1230
+ typeof attemptId !== "string" ||
1231
+ typeof executionGeneration !== "number" ||
1232
+ !Number.isSafeInteger(executionGeneration) ||
1233
+ executionGeneration < 1
1234
+ ) {
1235
+ throw new Error("caller_attempt_claims_missing");
1236
+ }
1237
+ return {
1238
+ accountId: grant.accountId,
1239
+ workspaceId: grant.workspaceId,
1240
+ callerSessionId,
1241
+ callerTurnId: turnId,
1242
+ callerAttemptId: attemptId,
1243
+ callerExecutionGeneration: executionGeneration,
1244
+ };
1245
+ }
1246
+
1238
1247
  function registerWorkspaceOrchestrationTools(
1239
1248
  server: McpServer,
1240
1249
  deps: ApiRouteDeps,
1241
1250
  grant: AccessGrant,
1242
1251
  can: (permission: Permission) => boolean,
1243
1252
  callerSessionId: string | null,
1253
+ toolspaceMode: boolean,
1244
1254
  json: JsonResult,
1245
1255
  ): void {
1246
1256
  if (can("sessions:read")) {
@@ -1266,7 +1276,11 @@ function registerWorkspaceOrchestrationTools(
1266
1276
  if (!session) {
1267
1277
  throw new Error("session not found");
1268
1278
  }
1269
- return json(capSessionDetail(session));
1279
+ const queue = await getSessionQueueSnapshot(deps.db, grant.workspaceId, sessionId);
1280
+ return json({
1281
+ ...capSessionDetail(session),
1282
+ effectiveControl: queue?.effectiveControl ?? null,
1283
+ });
1270
1284
  },
1271
1285
  );
1272
1286
 
@@ -1384,7 +1398,7 @@ function registerWorkspaceOrchestrationTools(
1384
1398
  );
1385
1399
  }
1386
1400
 
1387
- if (can("sessions:control")) {
1401
+ if (can("sessions:control") && !toolspaceMode) {
1388
1402
  server.registerTool(
1389
1403
  "session_send_message",
1390
1404
  {
@@ -1393,50 +1407,30 @@ function registerWorkspaceOrchestrationTools(
1393
1407
  inputSchema: {
1394
1408
  sessionId: z4.string().uuid(),
1395
1409
  text: z4.string().min(1),
1410
+ idempotencyKey: z4.string().uuid(),
1396
1411
  // Header-value rotation only. URL/name/tool settings are immutable
1397
1412
  // after create; core enforces mcp_servers:attach on this field.
1398
1413
  mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
1399
1414
  },
1400
1415
  },
1401
- async ({ sessionId: targetSessionId, text, mcpCredentialUpdates }) => {
1416
+ async ({ sessionId: targetSessionId, text, idempotencyKey, mcpCredentialUpdates }) => {
1402
1417
  if (callerSessionId !== null) {
1403
1418
  if ((mcpCredentialUpdates?.length ?? 0) > 0) {
1404
1419
  throw new Error("internal session updates cannot change MCP credentials");
1405
1420
  }
1406
- const result = await addSessionSystemUpdate(deps.db, {
1407
- accountId: grant.accountId,
1408
- workspaceId: grant.workspaceId,
1409
- sessionId: targetSessionId,
1410
- kind: "runtime_notice",
1411
- classification: "info",
1412
- sourceId: callerSessionId,
1413
- dedupeKey: `session-message:${callerSessionId}:${crypto.randomUUID()}`,
1414
- summary: text,
1415
- payload: { text },
1416
- lineage: { sourceSessionId: callerSessionId, targetSessionId },
1417
- });
1418
- if (result.reason === "session_cancelled") {
1419
- return json({ delivered: false, reason: result.reason });
1420
- }
1421
- if (result.added && result.events.length > 0) {
1422
- await deps.bus.publish(grant.workspaceId, targetSessionId, result.events);
1423
- }
1424
- if (result.shouldWake) {
1425
- if (result.workflowWakeRevision === null) {
1426
- throw new Error("Internal update has no workflow wake revision");
1427
- }
1428
- await deps.workflowClient.wakeSessionWorkflow({
1429
- accountId: grant.accountId,
1430
- workspaceId: grant.workspaceId,
1431
- sessionId: targetSessionId,
1432
- workflowId: result.temporalWorkflowId ?? workflowIdForSession(targetSessionId),
1433
- wakeRevision: result.workflowWakeRevision,
1434
- });
1435
- }
1421
+ const result = await sendAgentSessionMessage(
1422
+ deps,
1423
+ exactAgentCommandContext(grant, callerSessionId),
1424
+ { targetSessionId, text, idempotencyKey },
1425
+ );
1436
1426
  return json({
1437
1427
  delivered: true,
1438
- updateId: result.update.id,
1428
+ updateId: result.updateId,
1439
1429
  delivery: "coalesced_internal_update",
1430
+ effectiveState: result.effectiveState,
1431
+ wakeRequested: result.wakeRevision !== null,
1432
+ resumeRequired: result.effectiveState === "paused",
1433
+ replay: result.replay,
1440
1434
  });
1441
1435
  }
1442
1436
  const { accepted, turn } = await acceptSessionUserMessage(
@@ -1447,8 +1441,9 @@ function registerWorkspaceOrchestrationTools(
1447
1441
  {
1448
1442
  text,
1449
1443
  toolsProvided: false,
1450
- delivery: "queue",
1444
+ delivery: "send",
1451
1445
  origin: "operator",
1446
+ clientEventId: idempotencyKey,
1452
1447
  mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) =>
1453
1448
  SessionMcpCredentialUpdateInput.parse(update),
1454
1449
  ),
@@ -1464,39 +1459,126 @@ function registerWorkspaceOrchestrationTools(
1464
1459
  description: "Pause this session. Waiting prompts stay saved and inert until Resume.",
1465
1460
  inputSchema: {
1466
1461
  sessionId: z4.string().uuid(),
1462
+ idempotencyKey: z4.string().uuid(),
1463
+ reason: z4.string().min(1).max(500).optional(),
1467
1464
  },
1468
1465
  },
1469
- async ({ sessionId }) => {
1470
- const controlled = await requestSessionControl(deps.db, {
1471
- accountId: grant.accountId,
1472
- workspaceId: grant.workspaceId,
1473
- sessionId,
1474
- actor: grant.subjectId,
1475
- mode: "pause",
1476
- reason: "mcp_pause",
1477
- });
1478
- await deps.bus.publish(grant.workspaceId, sessionId, controlled.events);
1479
- if (controlled.shouldSignalControl) {
1480
- if (controlled.workflowWakeRevision === null) {
1481
- throw new Error("Session control has no workflow wake revision");
1482
- }
1483
- await deps.workflowClient.signalSessionControl({
1484
- accountId: grant.accountId,
1485
- workspaceId: grant.workspaceId,
1486
- sessionId,
1487
- eventId: controlled.event.id,
1488
- workflowId: workflowIdForSession(sessionId),
1489
- workflowWakeRevision: controlled.workflowWakeRevision,
1466
+ async ({ sessionId, idempotencyKey, reason }) => {
1467
+ if (callerSessionId !== null) {
1468
+ const controlled = await controlAgentSessionWorkstream(
1469
+ deps,
1470
+ exactAgentCommandContext(grant, callerSessionId),
1471
+ {
1472
+ targetSessionId: sessionId,
1473
+ action: "pause",
1474
+ idempotencyKey,
1475
+ reason: reason ?? "agent_mcp_pause",
1476
+ },
1477
+ );
1478
+ return json({
1479
+ receiptId: controlled.receipt.id,
1480
+ effectiveControl: controlled.control,
1481
+ interruptionCount: controlled.interruptionCount,
1482
+ replay: controlled.replay,
1490
1483
  });
1491
1484
  }
1492
- return json({
1493
- event: controlled.event,
1494
- controlState: controlled.controlState,
1495
- controlGeneration: controlled.controlGeneration,
1496
- });
1485
+ return json(
1486
+ await controlHumanSessionWorkstream(
1487
+ deps,
1488
+ {
1489
+ accountId: grant.accountId,
1490
+ workspaceId: grant.workspaceId,
1491
+ sessionId,
1492
+ subjectId: grant.subjectId,
1493
+ },
1494
+ {
1495
+ action: "pause",
1496
+ clientEventId: idempotencyKey,
1497
+ ...(reason ? { reason } : {}),
1498
+ },
1499
+ ),
1500
+ );
1497
1501
  },
1498
1502
  );
1499
1503
 
1504
+ server.registerTool(
1505
+ "session_resume",
1506
+ {
1507
+ description:
1508
+ "Resume the selected session workstream through older parent/workspace pauses. This creates no message.",
1509
+ inputSchema: {
1510
+ sessionId: z4.string().uuid(),
1511
+ idempotencyKey: z4.string().uuid(),
1512
+ reason: z4.string().min(1).max(500).optional(),
1513
+ },
1514
+ },
1515
+ async ({ sessionId, idempotencyKey, reason }) => {
1516
+ if (callerSessionId !== null) {
1517
+ const controlled = await controlAgentSessionWorkstream(
1518
+ deps,
1519
+ exactAgentCommandContext(grant, callerSessionId),
1520
+ {
1521
+ targetSessionId: sessionId,
1522
+ action: "resume",
1523
+ idempotencyKey,
1524
+ reason: reason ?? "agent_mcp_resume",
1525
+ },
1526
+ );
1527
+ return json({
1528
+ receiptId: controlled.receipt.id,
1529
+ effectiveControl: controlled.control,
1530
+ interruptionCount: controlled.interruptionCount,
1531
+ replay: controlled.replay,
1532
+ });
1533
+ }
1534
+ return json(
1535
+ await controlHumanSessionWorkstream(
1536
+ deps,
1537
+ {
1538
+ accountId: grant.accountId,
1539
+ workspaceId: grant.workspaceId,
1540
+ sessionId,
1541
+ subjectId: grant.subjectId,
1542
+ },
1543
+ {
1544
+ action: "resume",
1545
+ clientEventId: idempotencyKey,
1546
+ ...(reason ? { reason } : {}),
1547
+ },
1548
+ ),
1549
+ );
1550
+ },
1551
+ );
1552
+
1553
+ if (callerSessionId !== null) {
1554
+ server.registerTool(
1555
+ "session_steer",
1556
+ {
1557
+ description:
1558
+ "Atomically replace another session's current direction and resume it. The instruction is an internal update, never a human queue row.",
1559
+ inputSchema: {
1560
+ sessionId: z4.string().uuid(),
1561
+ instruction: z4.string().min(1),
1562
+ idempotencyKey: z4.string().uuid(),
1563
+ },
1564
+ },
1565
+ async ({ sessionId, instruction, idempotencyKey }) => {
1566
+ const result = await steerAgentSession(
1567
+ deps,
1568
+ exactAgentCommandContext(grant, callerSessionId),
1569
+ { targetSessionId: sessionId, instruction, idempotencyKey },
1570
+ );
1571
+ return json({
1572
+ updateId: result.updateId,
1573
+ interruptionCount: result.interruptionCount,
1574
+ stoppingPreviousAttempt: result.interruptionCount > 0,
1575
+ effectiveState: result.effectiveState,
1576
+ replay: result.replay,
1577
+ });
1578
+ },
1579
+ );
1580
+ }
1581
+
1500
1582
  server.registerTool(
1501
1583
  "set_other_session_title",
1502
1584
  {