odai-dsh-agent 0.2.0 → 0.2.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.
@@ -51,6 +51,29 @@ import {
51
51
  import { createSessionEvidence, resolveSessionEvidenceRoot } from "./session-evidence.mjs";
52
52
  import { classifyModelRouteFailure, probeModelRoute } from "./model-route.mjs";
53
53
  import { RESPONSIBILITY_GAP_PROMPT, createResponsibilityGapTool } from "./responsibility-gap.mjs";
54
+ import {
55
+ ODAI_CORE_TOOL_NAMES,
56
+ activeOdaiToolNames,
57
+ classifyContextActivation,
58
+ inactiveOdaiToolNames,
59
+ } from "./context-activation.mjs";
60
+ import {
61
+ activateRequestedCapabilities,
62
+ createContextCapabilityTool,
63
+ requestedContextCapabilities,
64
+ } from "./context-capability.mjs";
65
+ import {
66
+ claimResponsibilityScope,
67
+ createResponsibilityScope,
68
+ latestDanglingResponsibilityScope,
69
+ pendingResponsibilityScopeRestoration,
70
+ responsibilityScopeClaimedEvent,
71
+ responsibilityScopeOwnsRequest,
72
+ responsibilityScopeStartedEvent,
73
+ responsibilityScopeStopReason,
74
+ responsibilityScopeStoppedEvent,
75
+ } from "./responsibility-scope.mjs";
76
+ import { HUMAN_CARE_REFERENCE_PATH, createHumanCareTool } from "./human-care.mjs";
54
77
  import { HUMAN_SAFETY_REFERENCE_PATH, createHumanSafetyTool } from "./human-safety.mjs";
55
78
  import {
56
79
  HUMAN_SAFETY_CONTINUITY_PROMPT,
@@ -878,6 +901,10 @@ export function apply(ctx, rawConfig) {
878
901
  memorySettingsSnapshots.set(agent, { turn, settings });
879
902
  return settings;
880
903
  };
904
+ const contextActivationFor = (agent, text, turn = currentAgentTurn(agent)) => activateRequestedCapabilities(
905
+ classifyContextActivation(text),
906
+ requestedContextCapabilities(evidence.events(agent), turn),
907
+ );
881
908
  const routingSnapshots = new WeakMap();
882
909
  const routingSnapshotFor = (agent, turn = currentAgentTurn(agent)) => {
883
910
  const cached = routingSnapshots.get(agent);
@@ -933,11 +960,18 @@ export function apply(ctx, rawConfig) {
933
960
  }
934
961
  const outputPrompt = renderOutputPolicyPrompt(outputSelection.policy);
935
962
  const childSession = isSubagentSession(agent);
936
- const routingPrompt = childSession
937
- ? ROUTING_CONFIG_PROMPT
938
- : `${ROUTING_CONFIG_PROMPT}\n\n${renderEffectiveRoutingContext(routingSnapshotFor(agent, turn))}`;
963
+ const directMessage = latestDirectUserMessage(agent);
964
+ const activation = contextActivationFor(agent, directMessage ? extractLatestUserText([directMessage]) : "", turn);
965
+ const proposedStep = (currentAgentStep(agent) ?? 0) + 1;
966
+ const routeCardNeeded = !childSession && (Boolean(activeRouteCard(evidence.events(agent)))
967
+ || ["planner", "executor"].includes(pendingResponsibilityGap(agent, turn, proposedStep)?.responsibility));
968
+ const routingPrompt = !activation.routingConfig
969
+ ? ""
970
+ : childSession
971
+ ? ROUTING_CONFIG_PROMPT
972
+ : `${ROUTING_CONFIG_PROMPT}\n\n${renderEffectiveRoutingContext(routingSnapshotFor(agent, turn))}`;
939
973
  let continuityRecordPrompt;
940
- if (!childSession) {
974
+ if (!childSession && (activation.care || activation.safety || activation.continuity)) {
941
975
  try {
942
976
  continuityRecordPrompt = renderHumanSafetyContinuitySection(
943
977
  readHumanSafetyContinuityStore(humanSafetyContinuityStorePath),
@@ -946,7 +980,9 @@ export function apply(ctx, rawConfig) {
946
980
  logger.warn(`Odai human-safety continuity is unavailable for this turn: ${error instanceof Error ? error.message : String(error)}`);
947
981
  }
948
982
  }
949
- const continuityPrompt = [HUMAN_SAFETY_CONTINUITY_PROMPT, continuityRecordPrompt].filter(Boolean).join("\n\n");
983
+ const continuityPrompt = [activation.continuity ? HUMAN_SAFETY_CONTINUITY_PROMPT : undefined, continuityRecordPrompt]
984
+ .filter(Boolean)
985
+ .join("\n\n");
950
986
  if (outputPrompt && !hasSessionEvent(agent, "odai/output-policy-selected", (data) => data?.turn === turn)) {
951
987
  appendEvent(agent, "odai/output-policy-selected", {
952
988
  ...(turn === undefined ? {} : { turn }),
@@ -960,15 +996,51 @@ export function apply(ctx, rawConfig) {
960
996
  if (section.name === "odai:canonical-governance") return { ...section, text: canonicalPrompt(selection) };
961
997
  if (section.name === "odai:routing-configuration") return { ...section, text: routingPrompt };
962
998
  if (section.name === "odai:human-safety-continuity") return { ...section, text: continuityPrompt };
999
+ if (section.name === "odai:responsibility-gap") return { ...section, text: childSession ? "" : RESPONSIBILITY_GAP_PROMPT };
1000
+ if (section.name === "odai:route-card") return { ...section, text: routeCardNeeded ? ROUTE_CARD_PROMPT : "" };
1001
+ if (section.name === "odai:skill-source-configuration") return { ...section, text: activation.skillSource ? SKILL_SOURCE_CONFIG_PROMPT : "" };
963
1002
  if (section.name === "odai:controller-output-policy") return { ...section, text: outputPrompt };
1003
+ if (section.name === "odai:compaction-model-configuration") return { ...section, text: activation.compactionConfig ? COMPACTION_CONFIG_PROMPT : "" };
1004
+ if (section.name === "odai:semantic-memory") return { ...section, text: activation.memory ? MEMORY_PROMPT : "" };
964
1005
  return section;
965
1006
  }),
966
1007
  };
967
1008
  });
968
1009
 
969
1010
  const routeProtections = new WeakMap();
970
- const controllerUpgrades = new WeakMap();
1011
+ const responsibilityScopes = new WeakMap();
1012
+ const responsibilityScopeOwners = new WeakMap();
971
1013
  const pendingRouteReceipts = new WeakMap();
1014
+ const pendingScopeRestorations = new WeakMap();
1015
+ const stopResponsibilityScope = (agent, reason, position = {}) => {
1016
+ const scope = responsibilityScopes.get(agent);
1017
+ if (!scope || (position.scopeId && position.scopeId !== scope.id)) return undefined;
1018
+ responsibilityScopes.delete(agent);
1019
+ const protection = routeProtections.get(agent);
1020
+ if (protection?.scopeId === scope.id) routeProtections.delete(agent);
1021
+ appendEvent(agent, "odai/route-protection-released", {
1022
+ scopeId: scope.id,
1023
+ turn: scope.turn,
1024
+ reason,
1025
+ });
1026
+ appendEvent(agent, "odai/responsibility-scope-stopped", responsibilityScopeStoppedEvent(scope, reason, position));
1027
+ return scope;
1028
+ };
1029
+ const stopDanglingResponsibilityScope = (agent, reason) => {
1030
+ if (responsibilityScopes.has(agent)) return undefined;
1031
+ const dangling = latestDanglingResponsibilityScope(evidence.events(agent));
1032
+ if (!dangling) return undefined;
1033
+ appendEvent(agent, "odai/route-protection-released", {
1034
+ scopeId: dangling.scopeId,
1035
+ turn: dangling.turn,
1036
+ reason,
1037
+ });
1038
+ appendEvent(agent, "odai/responsibility-scope-stopped", {
1039
+ ...dangling,
1040
+ reason,
1041
+ });
1042
+ return dangling;
1043
+ };
972
1044
  const configuredRole = (agent, role, turn = currentAgentTurn(agent)) => {
973
1045
  const state = routingSnapshotFor(agent, turn);
974
1046
  if (state.error) return state;
@@ -1043,6 +1115,18 @@ export function apply(ctx, rawConfig) {
1043
1115
  return routeProtections.get(agent) ?? activeRouteProtection(agent, evidence.events(agent));
1044
1116
  },
1045
1117
  });
1118
+ ctx.tools.register(createContextCapabilityTool({
1119
+ isChild: isSubagent,
1120
+ onRequested(agent, capability) {
1121
+ const turn = currentAgentTurn(agent);
1122
+ const step = currentAgentStep(agent);
1123
+ appendEvent(agent, "odai/context-capability-requested", {
1124
+ ...(turn === undefined ? {} : { turn }),
1125
+ ...(step === undefined ? {} : { step }),
1126
+ capability,
1127
+ });
1128
+ },
1129
+ }));
1046
1130
  ctx.tools.register(createRoutingConfigTool(config.routing.configPath, {
1047
1131
  configuredRoles: config.routing.roles,
1048
1132
  resolveCallConfig(route, signal) {
@@ -1055,6 +1139,13 @@ export function apply(ctx, rawConfig) {
1055
1139
  appendEvent(agent, "odai/routing-configured", data);
1056
1140
  },
1057
1141
  }));
1142
+ ctx.tools.register(createHumanCareTool({
1143
+ isChild: isSubagent,
1144
+ contractFor(agent) {
1145
+ const bundle = sharedSkillSelection(agent)?.bundle ?? bundled;
1146
+ return readSkillBundleFile(bundle, HUMAN_CARE_REFERENCE_PATH).toString("utf8");
1147
+ },
1148
+ }));
1058
1149
  ctx.tools.register(createHumanSafetyTool({
1059
1150
  isChild: isSubagent,
1060
1151
  contractFor(agent) {
@@ -1149,6 +1240,41 @@ export function apply(ctx, rawConfig) {
1149
1240
  }));
1150
1241
  ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
1151
1242
 
1243
+ const toolExposureStates = new WeakMap();
1244
+ const syncToolExposure = (agent, activation, options = {}) => {
1245
+ const child = isSubagentSession(agent);
1246
+ const activeNames = activeOdaiToolNames(activation, { child, routeCard: options.routeCard === true });
1247
+ const deniedNames = [
1248
+ ...inactiveOdaiToolNames(activeNames),
1249
+ ...(child ? ODAI_CORE_TOOL_NAMES : []),
1250
+ ];
1251
+ const key = deniedNames.join("\u0000");
1252
+ const previous = toolExposureStates.get(agent);
1253
+ if (previous?.key === key || ["unsupported", "fallback"].includes(previous?.key)) return activeNames;
1254
+ previous?.dispose?.();
1255
+ const restrict = agent?.ctx?.tools?.restrict;
1256
+ if (typeof restrict !== "function") {
1257
+ toolExposureStates.set(agent, Object.freeze({ key: "unsupported", dispose: undefined }));
1258
+ return activeNames;
1259
+ }
1260
+ try {
1261
+ const dispose = deniedNames.length > 0
1262
+ ? restrict.call(agent.ctx.tools, { deny: deniedNames })
1263
+ : undefined;
1264
+ toolExposureStates.set(agent, Object.freeze({ key, dispose }));
1265
+ appendEvent(agent, "odai/tool-exposure-selected", {
1266
+ ...(options.turn === undefined ? {} : { turn: options.turn }),
1267
+ ...(options.step === undefined ? {} : { step: options.step }),
1268
+ mode: "adaptive",
1269
+ activeTools: activeNames,
1270
+ });
1271
+ } catch (error) {
1272
+ logger.warn(`Odai adaptive tool exposure is unavailable; retaining the complete tool catalog: ${error instanceof Error ? error.message : String(error)}`);
1273
+ toolExposureStates.set(agent, Object.freeze({ key: "fallback", dispose: undefined }));
1274
+ }
1275
+ return activeNames;
1276
+ };
1277
+
1152
1278
  ctx.on("tools/result", (execution, result) => {
1153
1279
  if (!execution.agent) return;
1154
1280
  const summary = summarizeToolResult(execution, result);
@@ -1157,7 +1283,7 @@ export function apply(ctx, rawConfig) {
1157
1283
  });
1158
1284
 
1159
1285
  ctx.on("agent/request", async ({ agent, turn, step, signal }, next) => {
1160
- const proposed = await next();
1286
+ let proposed = await next();
1161
1287
  const childRole = routedRoleOf(agent);
1162
1288
  const childRoleState = childRole ? configuredRole(agent, childRole, turn) : undefined;
1163
1289
  if (childRole && !childRoleState.route) {
@@ -1165,14 +1291,42 @@ export function apply(ctx, rawConfig) {
1165
1291
  ? `Odai ${childRole} child route is unavailable: ${childRoleState.detail}`
1166
1292
  : `Odai ${childRole} child route is not configured`);
1167
1293
  }
1168
- const upgrade = controllerUpgrades.get(agent);
1169
- const upgradeRole = upgrade && upgrade.turn === turn ? upgrade.role : undefined;
1294
+ let scope = responsibilityScopes.get(agent);
1295
+ if (scope && !responsibilityScopeOwnsRequest(scope, turn, step)) {
1296
+ stopResponsibilityScope(agent, "ownership-boundary", { step });
1297
+ scope = undefined;
1298
+ }
1299
+ if (!childRole && !scope) {
1300
+ const restoration = pendingResponsibilityScopeRestoration(evidence.events(agent));
1301
+ if (restoration && sameRequestModelRoute(proposed, restoration.temporaryRoute)) {
1302
+ const { reasoningEffort: _temporaryEffort, maxTokens: _temporaryMaxTokens, ...withoutTemporaryRoute } = proposed;
1303
+ proposed = Object.freeze({ ...withoutTemporaryRoute, ...restoration.baseRoute });
1304
+ appendEvent(agent, "odai/responsibility-scope-restoration-requested", {
1305
+ scopeId: restoration.scopeId,
1306
+ turn,
1307
+ step,
1308
+ role: restoration.role,
1309
+ requestedRoute: restoration.baseRoute,
1310
+ });
1311
+ if (agent?.session) {
1312
+ pendingScopeRestorations.set(agent.session, Object.freeze({
1313
+ agent,
1314
+ scopeId: restoration.scopeId,
1315
+ turn,
1316
+ step,
1317
+ role: restoration.role,
1318
+ expectedRoute: restoration.baseRoute,
1319
+ }));
1320
+ }
1321
+ }
1322
+ }
1323
+ const upgradeRole = scope?.role;
1170
1324
  let roleRoute = childRole
1171
1325
  ? childRoleState.route
1172
- : upgradeRole
1173
- ? upgrade.route
1326
+ : scope
1327
+ ? scope.route
1174
1328
  : undefined;
1175
- const routeSource = childRole ? childRoleState?.source : upgrade?.source;
1329
+ const routeSource = childRole ? childRoleState?.source : scope?.source;
1176
1330
  let routeMode = childRole ? "child" : sameRequestModelRoute(proposed, roleRoute) ? "inline" : "same-turn";
1177
1331
  let scopedFrontendMaxTokens = upgradeRole === "frontend" ? roleRoute?.maxTokens : undefined;
1178
1332
  const finalize = (finalRequest) => {
@@ -1191,7 +1345,8 @@ export function apply(ctx, rawConfig) {
1191
1345
  responsibility: childRole ?? upgradeRole,
1192
1346
  routeMode,
1193
1347
  routeSource,
1194
- ...(upgrade?.cardId ? { routeCardId: upgrade.cardId } : {}),
1348
+ ...(scope?.id ? { responsibilityScopeId: scope.id } : {}),
1349
+ ...(scope?.cardId ? { routeCardId: scope.cardId } : {}),
1195
1350
  requestedRoute: expectedRoute,
1196
1351
  expectedRoute,
1197
1352
  }));
@@ -1210,7 +1365,17 @@ export function apply(ctx, rawConfig) {
1210
1365
  ? { maxTokens: roleRoute.maxTokens }
1211
1366
  : {}),
1212
1367
  });
1213
- const validation = upgrade?.routeValidated
1368
+ if (!childRole && scope?.state === "pending") {
1369
+ scope = claimResponsibilityScope(scope, {
1370
+ step,
1371
+ baseRoute: proposed,
1372
+ temporaryRoute: request,
1373
+ routeMode,
1374
+ });
1375
+ responsibilityScopes.set(agent, scope);
1376
+ appendEvent(agent, "odai/responsibility-scope-claimed", responsibilityScopeClaimedEvent(scope));
1377
+ }
1378
+ const validation = scope?.routeValidated
1214
1379
  ? Object.freeze({ status: "verified" })
1215
1380
  : await probeModelRoute(
1216
1381
  (candidate, candidateSignal) => ctx.llm.resolveCallConfig(candidate, candidateSignal),
@@ -1241,9 +1406,9 @@ export function apply(ctx, rawConfig) {
1241
1406
  error: validation.failure.message,
1242
1407
  invalidated: invalidation.invalidated,
1243
1408
  });
1244
- if (upgrade?.cardId) {
1409
+ if (scope?.cardId) {
1245
1410
  appendEvent(agent, "odai/route-card-claim-released", {
1246
- cardId: upgrade.cardId,
1411
+ cardId: scope.cardId,
1247
1412
  turn,
1248
1413
  step,
1249
1414
  reason: "route-validation-failed",
@@ -1255,9 +1420,9 @@ export function apply(ctx, rawConfig) {
1255
1420
  error.routeFailureKind = validation.failure.kind;
1256
1421
  throw error;
1257
1422
  }
1258
- controllerUpgrades.delete(agent);
1259
- if (upgrade?.decision && requiresFailClosedProtection(upgrade.decision)) {
1260
- protectController(agent, turn, step, upgrade.decision, "route-validation", validation.failure.message);
1423
+ stopResponsibilityScope(agent, "route-validation-failed", { step });
1424
+ if (scope?.decision && requiresFailClosedProtection(scope.decision)) {
1425
+ protectController(agent, turn, step, scope.decision, "route-validation", validation.failure.message);
1261
1426
  }
1262
1427
  roleRoute = undefined;
1263
1428
  routeMode = "same-turn";
@@ -1303,13 +1468,18 @@ export function apply(ctx, rawConfig) {
1303
1468
 
1304
1469
  const routeFallbackAttempts = new WeakMap();
1305
1470
  ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure, signal }, next) => {
1306
- if (signal.aborted || failure?.code === "CONTEXT_WINDOW_EXCEEDED") return next();
1307
1471
  const childRole = routedRoleOf(agent);
1308
- const upgrade = controllerUpgrades.get(agent);
1472
+ const scope = responsibilityScopes.get(agent);
1473
+ if (!childRole && scope && (signal.aborted || failure?.code === "CONTEXT_WINDOW_EXCEEDED")) {
1474
+ if (agent?.session) pendingRouteReceipts.delete(agent.session);
1475
+ stopResponsibilityScope(agent, signal.aborted ? "request-aborted" : "context-window-exceeded", { step });
1476
+ return next();
1477
+ }
1478
+ if (signal.aborted || failure?.code === "CONTEXT_WINDOW_EXCEEDED") return next();
1309
1479
  const active = childRole
1310
1480
  ? { role: childRole, ...configuredRole(agent, childRole, turn) }
1311
- : upgrade && upgrade.turn === turn
1312
- ? upgrade
1481
+ : responsibilityScopeOwnsRequest(scope, turn, step)
1482
+ ? scope
1313
1483
  : undefined;
1314
1484
  if (!active?.route || provider !== active.route.provider) return next();
1315
1485
 
@@ -1330,7 +1500,7 @@ export function apply(ctx, rawConfig) {
1330
1500
  routeSource: active.source,
1331
1501
  requestedRoute: active.route,
1332
1502
  fallbackUsed: !childRole,
1333
- fallbackRoute: childRole ? undefined : routeFromConfig(agent?.options),
1503
+ fallbackRoute: childRole ? undefined : active.baseRoute ?? routeFromConfig(agent?.options),
1334
1504
  failureKind: classified.kind,
1335
1505
  errorCode: classified.code,
1336
1506
  error: classified.message,
@@ -1344,7 +1514,13 @@ export function apply(ctx, rawConfig) {
1344
1514
  reason: "route-request-failed",
1345
1515
  });
1346
1516
  }
1347
- if (childRole || classified.kind === "cancelled") return next();
1517
+ if (childRole) return next();
1518
+ if (agent?.session) pendingRouteReceipts.delete(agent.session);
1519
+ stopResponsibilityScope(agent, classified.kind === "cancelled" ? "request-cancelled" : "route-request-failed", { step });
1520
+ if (scope?.decision && requiresFailClosedProtection(scope.decision)) {
1521
+ protectController(agent, turn, step, scope.decision, "route-request-failure", classified.message);
1522
+ }
1523
+ if (classified.kind === "cancelled") return next();
1348
1524
 
1349
1525
  let attempts = routeFallbackAttempts.get(agent);
1350
1526
  if (!attempts) {
@@ -1354,14 +1530,10 @@ export function apply(ctx, rawConfig) {
1354
1530
  const key = `${turn}:${step}`;
1355
1531
  if (attempts.has(key)) return next();
1356
1532
  attempts.add(key);
1357
- controllerUpgrades.delete(agent);
1358
- if (upgrade.decision && requiresFailClosedProtection(upgrade.decision)) {
1359
- protectController(agent, turn, step, upgrade.decision, "route-request-failure", classified.message);
1360
- }
1361
1533
  return { kind: "retry" };
1362
1534
  });
1363
1535
 
1364
- const protectController = (agent, turn, step, decision, source, failure) => {
1536
+ const protectController = (agent, turn, step, decision, source, failure, scopeId) => {
1365
1537
  const protection = Object.freeze({
1366
1538
  turn,
1367
1539
  step,
@@ -1369,12 +1541,59 @@ export function apply(ctx, rawConfig) {
1369
1541
  reasonCode: decision.reasonCode,
1370
1542
  source,
1371
1543
  ...(failure ? { failure } : {}),
1544
+ ...(scopeId ? { scopeId } : {}),
1372
1545
  });
1373
1546
  routeProtections.set(agent, protection);
1374
1547
  appendEvent(agent, "odai/route-protection", protection, logger);
1375
1548
  };
1376
1549
 
1377
1550
  ctx.on("session/event", (session, event) => {
1551
+ const owner = responsibilityScopeOwners.get(session);
1552
+ const activeScope = owner ? responsibilityScopes.get(owner) : undefined;
1553
+ const scopeStopReason = responsibilityScopeStopReason(activeScope, event);
1554
+ if (scopeStopReason) {
1555
+ stopResponsibilityScope(owner, scopeStopReason, {
1556
+ scopeId: activeScope.id,
1557
+ ...(Number.isSafeInteger(event.data?.step) ? { step: event.data.step } : {}),
1558
+ });
1559
+ }
1560
+
1561
+ const pendingRestoration = pendingScopeRestorations.get(session);
1562
+ if (pendingRestoration && event?.type === "turn/end") {
1563
+ appendEvent(pendingRestoration.agent, "odai/responsibility-scope-restored", {
1564
+ scopeId: pendingRestoration.scopeId,
1565
+ turn: pendingRestoration.turn,
1566
+ step: pendingRestoration.step,
1567
+ role: pendingRestoration.role,
1568
+ status: "unverified",
1569
+ requestedRoute: pendingRestoration.expectedRoute,
1570
+ stopReason: "no-effective-request",
1571
+ });
1572
+ pendingScopeRestorations.delete(session);
1573
+ }
1574
+ if (pendingRestoration && ["request/header", "assistant/chunk"].includes(event?.type)) {
1575
+ let actualRoute;
1576
+ try {
1577
+ actualRoute = event.type === "request/header"
1578
+ ? routeFromConfig(event.data?.header?.config)
1579
+ : routeFromConfig(session.requestHeader?.()?.config);
1580
+ } catch {}
1581
+ if (actualRoute || event.type === "assistant/chunk") {
1582
+ const mismatch = routeMismatchFor(pendingRestoration.expectedRoute, actualRoute, "base-route restoration");
1583
+ appendEvent(pendingRestoration.agent, "odai/responsibility-scope-restored", {
1584
+ scopeId: pendingRestoration.scopeId,
1585
+ turn: pendingRestoration.turn,
1586
+ step: pendingRestoration.step,
1587
+ role: pendingRestoration.role,
1588
+ status: mismatch ? "mismatch" : "applied",
1589
+ requestedRoute: pendingRestoration.expectedRoute,
1590
+ ...(actualRoute ? { actualRoute } : {}),
1591
+ ...(mismatch ? { error: mismatch } : {}),
1592
+ });
1593
+ pendingScopeRestorations.delete(session);
1594
+ }
1595
+ }
1596
+
1378
1597
  const pending = pendingRouteReceipts.get(session);
1379
1598
  if (!pending) return;
1380
1599
  if (event?.type === "turn/end") {
@@ -1397,6 +1616,10 @@ export function apply(ctx, rawConfig) {
1397
1616
  reason: "no-effective-request",
1398
1617
  });
1399
1618
  }
1619
+ stopResponsibilityScope(pending.agent, "no-effective-request", {
1620
+ scopeId: pending.responsibilityScopeId,
1621
+ step: pending.step,
1622
+ });
1400
1623
  pendingRouteReceipts.delete(session);
1401
1624
  return;
1402
1625
  }
@@ -1421,6 +1644,12 @@ export function apply(ctx, rawConfig) {
1421
1644
  ...(actualRoute ? { actualRoute } : {}),
1422
1645
  ...(mismatch ? { stopReason: "route-mismatch", error: mismatch } : {}),
1423
1646
  });
1647
+ if (mismatch && pending.routeMode === "same-turn") {
1648
+ stopResponsibilityScope(pending.agent, "route-mismatch", {
1649
+ scopeId: pending.responsibilityScopeId,
1650
+ step: pending.step,
1651
+ });
1652
+ }
1424
1653
  if (pending.routeCardId) {
1425
1654
  appendEvent(pending.agent, mismatch ? "odai/route-card-claim-released" : "odai/route-card-consumed", {
1426
1655
  cardId: pending.routeCardId,
@@ -1443,6 +1672,7 @@ export function apply(ctx, rawConfig) {
1443
1672
  });
1444
1673
 
1445
1674
  ctx.on("agent/turn-stopping", ({ agent, turn }) => {
1675
+ stopResponsibilityScope(agent, "turn-stopping");
1446
1676
  const role = routedRoleOf(agent);
1447
1677
  if (!role) return;
1448
1678
  const receipts = evidence.events(agent)
@@ -1461,15 +1691,22 @@ export function apply(ctx, rawConfig) {
1461
1691
  const routedSteps = new WeakMap();
1462
1692
  ctx.on("agent/pre-step", async ({ agent, turn, step, signal }, next) => {
1463
1693
  const subagentSession = isSubagentSession(agent);
1464
- if (step === 1 && !subagentSession) {
1465
- routeProtections.delete(agent);
1466
- controllerUpgrades.delete(agent);
1694
+ if (!subagentSession) {
1695
+ if (step === 1) {
1696
+ stopResponsibilityScope(agent, "new-turn");
1697
+ routeProtections.delete(agent);
1698
+ }
1699
+ stopDanglingResponsibilityScope(agent, "runtime-resume");
1467
1700
  }
1468
1701
  let downstream = await next();
1469
- if (downstream.kind === "reject" || signal.aborted || subagentSession) {
1470
- return downstream;
1471
- }
1472
- const responsibilityGap = pendingResponsibilityGap(agent, turn, step);
1702
+ if (downstream.kind === "reject" || signal.aborted) return downstream;
1703
+ const responsibilityGap = subagentSession ? undefined : pendingResponsibilityGap(agent, turn, step);
1704
+ const directMessage = latestDirectUserMessage(agent, downstream.messages, { turn });
1705
+ const activation = contextActivationFor(agent, directMessage ? extractLatestUserText([directMessage]) : "", turn);
1706
+ const routeCardNeeded = !subagentSession && (Boolean(activeRouteCard(evidence.events(agent)))
1707
+ || ["planner", "executor"].includes(responsibilityGap?.responsibility));
1708
+ syncToolExposure(agent, activation, { turn, step, routeCard: routeCardNeeded });
1709
+ if (subagentSession) return downstream;
1473
1710
  if (step !== 1 && !responsibilityGap) return downstream;
1474
1711
 
1475
1712
  if (step === 1 && claimSemanticMemoryTurn(agent, turn, step)) {
@@ -1653,17 +1890,19 @@ export function apply(ctx, rawConfig) {
1653
1890
  let roleContext = decision.action === "direct"
1654
1891
  ? undefined
1655
1892
  : buildRoleContextPacket(agent, routeRole, roleTaskText);
1893
+ let localReviewerCoverage;
1656
1894
  if (config.routing.mode === "auto"
1657
1895
  && routeRole === "reviewer"
1658
1896
  && decision.action === "delegate"
1659
1897
  && !roleContext.sufficient) {
1898
+ localReviewerCoverage = roleContext.coverage;
1660
1899
  decision = Object.freeze({
1661
1900
  ...decision,
1662
1901
  role: "controller",
1663
- mode: "upgrade",
1664
- action: "upgrade",
1902
+ mode: "direct",
1903
+ action: "direct",
1665
1904
  targetRole: "reviewer",
1666
- signals: Object.freeze([...decision.signals, "review-evidence-packet-missing"]),
1905
+ signals: Object.freeze([...decision.signals, "review-evidence-packet-missing", "controller-local-review"]),
1667
1906
  });
1668
1907
  routeRole = "reviewer";
1669
1908
  }
@@ -1695,7 +1934,46 @@ export function apply(ctx, rawConfig) {
1695
1934
  }, logger);
1696
1935
  }
1697
1936
 
1698
- if (decision.action === "direct") return routedDownstream;
1937
+ if (decision.action === "direct") {
1938
+ if (!localReviewerCoverage) return routedDownstream;
1939
+ appendEvent(agent, "odai/route-context", {
1940
+ turn,
1941
+ step,
1942
+ role: "reviewer",
1943
+ mode: "controller-local",
1944
+ digest: roleContext.digest,
1945
+ evidenceCount: roleContext.evidenceCount,
1946
+ toolEvidenceCount: roleContext.toolEvidenceCount,
1947
+ acceptanceCount: localReviewerCoverage.acceptanceCount,
1948
+ diffCount: localReviewerCoverage.diffCount,
1949
+ testCount: localReviewerCoverage.testCount,
1950
+ truncated: roleContext.truncated,
1951
+ sufficient: false,
1952
+ }, logger);
1953
+ appendEvent(agent, "odai/route-result", {
1954
+ turn,
1955
+ step,
1956
+ role: "reviewer",
1957
+ action: "direct",
1958
+ status: "fallback",
1959
+ stopReason: "evidence-packet-missing",
1960
+ independent: false,
1961
+ }, logger);
1962
+ return {
1963
+ kind: "enter",
1964
+ messages: [
1965
+ ...routedDownstream.messages,
1966
+ pluginMessage(
1967
+ [
1968
+ `An independent reviewer was not started because the bounded packet is incomplete (${JSON.stringify(localReviewerCoverage)}).`,
1969
+ "Remain on the current controller route and continue the authorized task. Gather project-available requirements, acceptance conditions, diff, tests, and matching tool evidence before resubmitting a changed reviewer gap.",
1970
+ "A controller-local read-only check may guide the work, but it is not independent acceptance. Do not stop solely to ask the user for review artifacts the project can produce, and do not claim the reviewer approved release.",
1971
+ ].join("\n"),
1972
+ "odai reviewer evidence is incomplete; controller continues locally",
1973
+ ),
1974
+ ],
1975
+ };
1976
+ }
1699
1977
 
1700
1978
  if (config.routing.mode === "observe") {
1701
1979
  if (requiresFailClosedProtection(decision)) {
@@ -1842,27 +2120,35 @@ export function apply(ctx, rawConfig) {
1842
2120
  }
1843
2121
 
1844
2122
  if (inPlaceUpgrade) {
1845
- if (["planner", "reviewer"].includes(routeRole)) {
1846
- protectController(agent, turn, step, decision, `same-turn-${routeRole}`);
1847
- }
1848
- if (routeRole === "executor" && frozenCard) {
1849
- appendEvent(agent, "odai/route-card-claimed", { cardId: frozenCard.id, turn, step });
1850
- }
1851
- controllerUpgrades.set(agent, Object.freeze({
2123
+ stopResponsibilityScope(agent, "superseded", { step });
2124
+ const responsibilityScope = createResponsibilityScope({
1852
2125
  turn,
2126
+ startStep: step,
1853
2127
  role: routeRole,
1854
2128
  route: roleRoute,
1855
2129
  source: roleState.source,
1856
2130
  decision,
1857
- ...(rolePreflightVerified ? { routeValidated: true } : {}),
2131
+ routeValidated: rolePreflightVerified,
1858
2132
  ...(routeRole === "executor" && frozenCard ? { cardId: frozenCard.id } : {}),
1859
- }));
2133
+ });
2134
+ responsibilityScopes.set(agent, responsibilityScope);
2135
+ if (agent?.session) responsibilityScopeOwners.set(agent.session, agent);
2136
+ appendEvent(agent, "odai/responsibility-scope-started", responsibilityScopeStartedEvent(responsibilityScope));
2137
+ if (["planner", "reviewer"].includes(routeRole)) {
2138
+ protectController(agent, turn, step, decision, `responsibility-scope-${routeRole}`, undefined, responsibilityScope.id);
2139
+ }
2140
+ if (routeRole === "executor" && frozenCard) {
2141
+ appendEvent(agent, "odai/route-card-claimed", { cardId: frozenCard.id, turn, step });
2142
+ }
1860
2143
  appendEvent(agent, "odai/route-upgrade", {
1861
2144
  turn,
1862
2145
  step,
1863
2146
  role: decision.role,
1864
2147
  targetRole: routeRole,
1865
2148
  status: "requested",
2149
+ responsibilityScopeId: responsibilityScope.id,
2150
+ continuationPolicy: responsibilityScope.continuationPolicy,
2151
+ stopPolicy: responsibilityScope.stopPolicy,
1866
2152
  routeSource: roleState.source,
1867
2153
  requestedRoute: roleRoute,
1868
2154
  contextDigest: roleContext.digest,
@@ -3,12 +3,10 @@ import { createHash } from "node:crypto";
3
3
  import { CONFIGURABLE_ROLES } from "./routing-config.mjs";
4
4
 
5
5
  export const RESPONSIBILITY_GAP_PROMPT = [
6
- "## Odai automatic responsibility and user-alignment gaps",
7
- "Users describe goals, constraints, materials, and acceptance; they never need to request internal roles, routing modes, or handoffs.",
8
- "Keep work direct when the current controller can close it reliably. Keywords such as plan, review, research, frontend, or independent are only clues and never sufficient routing authority.",
9
- "When project evidence reveals a real capability gap that can change the result, call odai_responsibility_gap with the smallest grounded proposal before the next affected action. The runtime chooses inline, same-turn, or child execution from the configured mapping, context need, evidence, and net benefit.",
10
- "Use responsibility=user only when a missing user-owned choice, priority, or unacceptable outcome changes the route. Ask one concise question after the tool accepts that gap. Do not ask users for repository facts, technical investigation, implementation details the project already determines, or internal routing choices.",
11
- "Do not submit a gap merely because a task is complex, risky, multi-step, uses a role word, has a configured model, or could use a cheaper model. Do not resubmit an unchanged gap or state.",
6
+ "## Odai responsibility gaps",
7
+ "Users provide goals, constraints, materials, and acceptance; they never need to request internal roles or handoffs.",
8
+ "Keep work direct when the controller can close it reliably. Call odai_responsibility_gap only when current evidence shows an independent capability or user-decision gap that can change a concrete result; keywords, complexity, risk, configured models, and price are insufficient. The runtime decides direct, inline, same-turn, child, or user-question handling.",
9
+ "Use responsibility=user only for a missing user-owned choice, priority, or unacceptable outcome, then ask exactly the accepted concise question. Do not ask users for repository facts or implementation details the project can determine. Do not resubmit unchanged state.",
12
10
  ].join("\n");
13
11
 
14
12
  const RESPONSIBILITIES = Object.freeze([...CONFIGURABLE_ROLES, "user"]);
@@ -58,10 +56,10 @@ export function createResponsibilityGapTool(options = {}) {
58
56
  required: ["responsibility", "gap", "evidenceRefs", "expectedChange"],
59
57
  properties: {
60
58
  responsibility: { type: "string", enum: [...RESPONSIBILITIES] },
61
- gap: { type: "string", description: "The unresolved capability or user-owned decision gap." },
62
- evidenceRefs: { type: "array", items: { type: "string" }, description: "Small stable references to current task evidence." },
63
- expectedChange: { type: "string", description: "The concrete decision, artifact, or acceptance result this responsibility can change." },
64
- question: { type: "string", description: "One concise question, required only for responsibility=user." },
59
+ gap: { type: "string" },
60
+ evidenceRefs: { type: "array", items: { type: "string" } },
61
+ expectedChange: { type: "string" },
62
+ question: { type: "string" },
65
63
  },
66
64
  },
67
65
  output: {