@sema-agent/core 7.9.2 → 7.11.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/agents/child-model-seat.d.ts +71 -14
  3. package/dist/agents/child-model-seat.js +23 -7
  4. package/dist/agents/subagent.d.ts +2 -2
  5. package/dist/agents/subagent.js +8 -5
  6. package/dist/agents/teacher.js +2 -2
  7. package/dist/core/ask-unresolvable-notice.d.ts +52 -0
  8. package/dist/core/ask-unresolvable-notice.js +25 -0
  9. package/dist/core/auto-mode-defaults.d.ts +15 -3
  10. package/dist/core/auto-mode-defaults.js +1 -0
  11. package/dist/core/auto-mode.d.ts +86 -23
  12. package/dist/core/auto-mode.js +43 -12
  13. package/dist/core/checkpoint-store.d.ts +14 -0
  14. package/dist/core/checkpoint-store.js +2 -1
  15. package/dist/core/engine-notice.d.ts +28 -7
  16. package/dist/core/gate-fold.js +1 -0
  17. package/dist/core/gate-lanes.d.ts +6 -1
  18. package/dist/core/gate-lanes.js +60 -18
  19. package/dist/core/governance-codes.d.ts +1 -1
  20. package/dist/core/governance-codes.js +4 -0
  21. package/dist/core/hooks.d.ts +37 -1
  22. package/dist/core/hooks.js +2 -0
  23. package/dist/core/permission-rule-model.d.ts +53 -16
  24. package/dist/core/permission-rule-model.js +59 -21
  25. package/dist/core/permission-rules.d.ts +6 -4
  26. package/dist/core/permission-rules.js +14 -14
  27. package/dist/core/persisted-rule-arms.js +4 -3
  28. package/dist/core/read-only-shell-table.d.ts +87 -0
  29. package/dist/core/read-only-shell-table.js +485 -0
  30. package/dist/core/read-only-shell.d.ts +42 -0
  31. package/dist/core/read-only-shell.js +316 -0
  32. package/dist/core/roles.d.ts +3 -2
  33. package/dist/core/runner/contracts.d.ts +26 -2
  34. package/dist/core/runner/denial-limit-arms.d.ts +14 -3
  35. package/dist/core/runner/denial-limit-arms.js +15 -5
  36. package/dist/core/runner/gate-exit.d.ts +5 -0
  37. package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
  38. package/dist/core/runner/permission-rule-lanes.js +9 -3
  39. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  40. package/dist/core/runner/prepare-caps-and-workflow.js +35 -14
  41. package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
  42. package/dist/core/runner/prepare-gate-stations.js +12 -0
  43. package/dist/core/runner/prepare-policy-chain.js +7 -6
  44. package/dist/core/runner/prepare-task.js +1 -1
  45. package/dist/core/runner/prepare-turn-wiring.js +1 -1
  46. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  47. package/dist/core/runner/prepare-wiring-manifest.js +8 -1
  48. package/dist/core/runner/runtask.d.ts +33 -32
  49. package/dist/core/runner/runtask.js +63 -33
  50. package/dist/core/runner-deps.d.ts +9 -2
  51. package/dist/core/shell-lexer.d.ts +18 -0
  52. package/dist/core/shell-lexer.js +17 -10
  53. package/dist/core/shell-wrapper-table.js +8 -5
  54. package/dist/core/swappable-deps.d.ts +90 -0
  55. package/dist/core/swappable-deps.js +55 -0
  56. package/dist/core/tool-policy.d.ts +30 -1
  57. package/dist/core/tool-policy.js +6 -2
  58. package/dist/core/tools.d.ts +28 -7
  59. package/dist/core/tools.js +44 -4
  60. package/dist/core/trace.d.ts +15 -0
  61. package/dist/core/wiring-manifest.d.ts +15 -1
  62. package/dist/core/wiring-manifest.js +10 -2
  63. package/dist/core/workflow-journal-store.d.ts +21 -2
  64. package/dist/core/workflow-journal-store.js +1 -1
  65. package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
  66. package/dist/engine/execution-env/node-execution-env.js +2 -1
  67. package/dist/engine/harness/agent-harness.d.ts +3 -1
  68. package/dist/engine/harness/agent-harness.js +1 -1
  69. package/dist/engine/harness/types.d.ts +15 -2
  70. package/dist/index.d.ts +8 -4
  71. package/dist/index.js +7 -3
  72. package/dist/orchestration/run-workflow-tool.d.ts +22 -2
  73. package/dist/orchestration/run-workflow-tool.js +14 -1
  74. package/dist/orchestration/workflow-governance.d.ts +3 -2
  75. package/dist/orchestration/workflow-observe.d.ts +1 -1
  76. package/dist/orchestration/workflow-observe.js +2 -0
  77. package/dist/orchestration/workflow-primitives.d.ts +4 -1
  78. package/dist/orchestration/workflow-primitives.js +1 -6
  79. package/dist/orchestration/workflow-types.d.ts +37 -2
  80. package/dist/orchestration/workflow-types.js +16 -0
  81. package/dist/orchestration/workflow.d.ts +53 -6
  82. package/dist/orchestration/workflow.js +319 -51
  83. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -2
  84. package/dist/stores/file/workflow-journal-store.js +10 -3
  85. package/package.json +1 -1
  86. package/test/export-surface.snapshot.json +85 -5
@@ -1,13 +1,13 @@
1
1
  import { isSyntheticApiErrorMessage } from "../../internal/harness.js";
2
2
  import { createHash } from "node:crypto";
3
- import { deploymentSubagentTierModel, forkGovernanceDenial } from "../../agents/subagent.js";
3
+ import { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel, forkGovernanceDenial } from "../../agents/subagent.js";
4
4
  import { CROSS_SESSION_CLASSIFIER_RULE } from "../../agents/cross-session-envelope.js";
5
5
  import { createRunWorkflowTool } from "../../orchestration/run-workflow-tool.js";
6
6
  import { isSelfOrchestrationActive } from "../../orchestration/workflow-script-runner.js";
7
7
  import { resolveWorkflowSizeGuideline } from "../../orchestration/workflow-size-guideline.js";
8
8
  import { thinkingOffExpressible } from "../../brain/reasoning.js";
9
9
  import { autoModeArmingRecipeOf } from "../auto-mode-arming.js";
10
- import { AUTO_MODE_CLASSIFIER_MAX_TOKENS } from "../auto-mode-defaults.js";
10
+ import { AUTO_MODE_CLASSIFIER_MAX_TOKENS, AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS } from "../auto-mode-defaults.js";
11
11
  import { buildAutoModePrompt, renderAutoModeAction, renderAutoModeWindow } from "../auto-mode-prompt.js";
12
12
  import { createAutoModeDecider, createAutoModeDenialTracker } from "../auto-mode.js";
13
13
  import { resolveCheckpointStore } from "../checkpoint-store.js";
@@ -17,9 +17,20 @@ import { resolveTaskModel } from "../roles.js";
17
17
  import { brainToRuntime } from "../runtime.js";
18
18
  import { defaultTaskRegistry } from "../task-registry.js";
19
19
  import { emitTrace } from "../trace.js";
20
- import { defineTool, isDefineToolProduct } from "../tools.js";
20
+ import { defineTool, isDefineToolProduct, rebindDefineToolCtx } from "../tools.js";
21
21
  import { derivedRouteFallsBack } from "./derived-route-fallback.js";
22
22
  import { REPORT_FINDINGS_TOOL_NAME, createReportBlockedTool, createReportFindingsTool } from "./synthetic-tools.js";
23
+ function classifierReplyText(reply) {
24
+ return reply.content
25
+ .filter((c) => c.type === "text")
26
+ .map((c) => c.text)
27
+ .join("");
28
+ }
29
+ function classifierReplyEmptyAtCap(reply) {
30
+ if (classifierReplyText(reply) !== "")
31
+ return false;
32
+ return reply.stopReason === "length" || (reply.stopReason === "error" && reply.errorKind === "length_empty");
33
+ }
23
34
  import { RosterBuilder, callerMountSource } from "../tool-roster.js";
24
35
  function assembleParentCaptureState(o, i, ctl, ancestors) {
25
36
  const build = (optedOut, indeterminate) => ({
@@ -72,9 +83,8 @@ export async function prepareCapsAndWorkflow(input) {
72
83
  roster.mount(mounted, { source: callerMountSource(mounted), mountedBy: "scenario" });
73
84
  }
74
85
  function mountCallerTool(t) {
75
- if (isDefineToolProduct(t)) {
76
- return maybeOffload(t, t);
77
- }
86
+ if (isDefineToolProduct(t))
87
+ return maybeOffload(rebindDefineToolCtx(t, enrichSpecToolCtx), t);
78
88
  return maybeOffload(defineTool({
79
89
  ...t,
80
90
  execute: (args, ctx) => t.execute(args, enrichSpecToolCtx(ctx)),
@@ -170,7 +180,10 @@ export async function prepareCapsAndWorkflow(input) {
170
180
  autoModeDecider = createAutoModeDecider({
171
181
  ...(am.timeoutMs !== undefined ? { timeoutMs: am.timeoutMs } : {}),
172
182
  ...(am.failureThreshold !== undefined ? { failureThreshold: am.failureThreshold } : {}),
173
- ...(am.onBreakerOpen !== undefined ? { onBreakerOpen: am.onBreakerOpen } : {}),
183
+ onBreakerOpen: (info) => {
184
+ internals?.autoModeBreakerLedger?.record(sessionId, { openedAtMs: Date.now(), lastCause: info.lastCause, failures: info.consecutiveFailures, runId });
185
+ am.onBreakerOpen?.(info);
186
+ },
174
187
  onClassified: (info) => emitTrace(deps.tracer, () => ({
175
188
  kind: "auto_mode.classified",
176
189
  version: 1,
@@ -188,17 +201,18 @@ export async function prepareCapsAndWorkflow(input) {
188
201
  (m.role === "user" || m.role === "assistant" || m.role === "toolResult"));
189
202
  const userPrompt = renderAutoModeWindow(known, am.window) + renderAutoModeAction(input);
190
203
  const classifierAuth = await spec.getApiKeyAndHeaders?.(classifierModel);
191
- const response = await classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, {
204
+ const request = (cap) => classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, {
192
205
  signal,
193
206
  reasoning: "off",
194
- ...classifierCap,
207
+ ...cap,
195
208
  ...(classifierAuth?.apiKey !== undefined ? { apiKey: classifierAuth.apiKey } : {}),
196
209
  ...(classifierAuth?.headers !== undefined ? { headers: classifierAuth.headers } : {}),
197
210
  });
198
- return response.content
199
- .filter((c) => c.type === "text")
200
- .map((c) => c.text)
201
- .join("");
211
+ let response = await request(classifierCap);
212
+ if (classifierCap.maxTokens !== undefined && classifierReplyEmptyAtCap(response)) {
213
+ response = await request({ maxTokens: classifierCap.maxTokens + AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS });
214
+ }
215
+ return classifierReplyText(response);
202
216
  },
203
217
  });
204
218
  if (am.persistArming === true)
@@ -246,6 +260,13 @@ export async function prepareCapsAndWorkflow(input) {
246
260
  parentModel: () => deploymentSubagentTierModel(runnerSelf.agentCatalog) ?? harnessRef.current?.getModel(),
247
261
  ...(spec.getApiKeyAndHeaders !== undefined ? { parentGetApiKeyAndHeaders: spec.getApiKeyAndHeaders } : {}),
248
262
  ...(frozenOnAsk !== undefined ? { parentOnAsk: frozenOnAsk } : {}),
263
+ ...(spec.durableApproval !== undefined ? { parentDurableApproval: { ...spec.durableApproval } } : {}),
264
+ ...(internals?.workflowParkedResume !== undefined
265
+ ? { parkedResume: (resumeFromRunId) => {
266
+ const mine = internals.workflowParkedResume.filter((d) => d.runId === resumeFromRunId).map(({ runId: _r, ...rest }) => rest);
267
+ return mine.length > 0 ? mine : undefined;
268
+ } }
269
+ : {}),
249
270
  principal: spec.principal,
250
271
  oneShot: spec.oneShot,
251
272
  ...(resolvedInteractionPosture !== undefined ? { parentInteractionPosture: resolvedInteractionPosture } : {}),
@@ -258,7 +279,7 @@ export async function prepareCapsAndWorkflow(input) {
258
279
  autoModeReview: () => (autoModeDecider !== undefined ? { decider: autoModeDecider } : undefined),
259
280
  workflowDepth: internals?.workflowDepth,
260
281
  parentCwd: taskRootFinal,
261
- parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
282
+ parentThinking: () => childThinkingSeat(undefined, deploymentSubagentThinking(runnerSelf.agentCatalog?.roles), harnessRef.current?.getThinkingLevel() ?? thinking).thinking,
262
283
  parentReadFace: () => carrierReadFace(),
263
284
  parentReadDenyPatterns: () => {
264
285
  const readDenyAdditionsNormalized = input.readDenyAdditionsNormalized();
@@ -49,8 +49,9 @@ export interface PrepareGateStationsInput {
49
49
  * first-party tool's declared effect (never overrides a scanned one); the plan-mode deny reads it per call. */
50
50
  toolEffects: Map<string, ToolEffect>;
51
51
  /** borrowed-readonly — the deployment seats these stations read, as a Pick over the SAME `deps` object (receiver
52
- * preserved for `deps.onError?.()`): the write-protection table and its data root, the tracer, the error face. */
53
- deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "tracer" | "onError">;
52
+ * preserved for `deps.onError?.()`): the write-protection table and its data root, the tracer, the error face, and
53
+ * the notice sink (#648: the gate's unresolvable-ask exit delivers `delegation.ask_unresolvable` through it). */
54
+ deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "tracer" | "onError" | "onNotice">;
54
55
  /** borrowed-mutable — the protocol-tools phase's arming belt. Writer here: `armed` ← the registration predicate, the
55
56
  * one write; the refresh seam reads it. */
56
57
  toolCallGateArmedRef: {
@@ -5,6 +5,8 @@ import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
5
5
  import { screenGateOutcome } from "../gate-outcome.js";
6
6
  import { directDeny } from "./gate-exit.js";
7
7
  import { emitTrace } from "../trace.js";
8
+ import { deliverEngineNotice } from "../engine-notice.js";
9
+ import { askUnresolvableNotice } from "../ask-unresolvable-notice.js";
8
10
  import { createWriteProtectionCheck } from "../write-protect.js";
9
11
  import { PATH_CONFINABLE_WRITE_TOOLS } from "./session-rule-policy.js";
10
12
  import { toolCallFaceOf } from "../tool-roster.js";
@@ -120,6 +122,7 @@ export function prepareGateStations(input) {
120
122
  shellGated: (e.toolName === "Bash" && shellGatedBash) || (e.toolName === "Monitor" && shellGatedMonitor),
121
123
  ...(autoModeDecider ? { autoMode: { decider: autoModeDecider, ...(autoModeDenialTracking !== undefined ? { denialTracking: autoModeDenialTracking } : {}) } } : {}),
122
124
  onHeadlessDenialLimit: stopForDenialLimit,
125
+ onAskUnresolvable: (info) => deliverEngineNotice(deps.onNotice, askUnresolvableNotice({ sessionId, ...info })),
123
126
  ...(permissionRuleLane
124
127
  ? {
125
128
  persistedRules: {
@@ -136,6 +139,15 @@ export function prepareGateStations(input) {
136
139
  },
137
140
  }
138
141
  : {}),
142
+ onReadOnlyAllowed: (info) => emitTrace(deps.tracer, () => ({
143
+ kind: "permission.read_only_allowed",
144
+ version: 1,
145
+ taskId: hostTaskId,
146
+ toolName: info.toolName,
147
+ toolCallId: info.toolCallId,
148
+ command: info.command,
149
+ ts: Date.now(),
150
+ })),
139
151
  ...(permissionRuleOrgLane
140
152
  ? {
141
153
  orgRules: {
@@ -252,6 +252,7 @@ export async function preparePolicyChain(input) {
252
252
  sessionId,
253
253
  root: taskRootFinal,
254
254
  liveCwd: () => handsCwdRef?.current,
255
+ home: executionEnv.homeDir,
255
256
  questionToolMounted,
256
257
  questionToolName: ASK_USER_QUESTION_TOOL_NAME,
257
258
  onRevisionDefect: (message) => deps.onError?.(new Error(message), { phase: "config", sessionId }),
@@ -299,7 +300,7 @@ export async function preparePolicyChain(input) {
299
300
  return { action: "allow", updatedInput: editArgs };
300
301
  if (judged.kind === "deny")
301
302
  return judged.result;
302
- const { ask: editAsk, fallback: editFallback, mintedHere: editMintedHere, origin: editOrigin } = judged;
303
+ const { ask: editAsk, fallback: editFallback, mintedHere: editMintedHere } = judged;
303
304
  re = editAsk;
304
305
  const rr = await resolveAsk({
305
306
  toolName: creq.toolName,
@@ -311,7 +312,7 @@ export async function preparePolicyChain(input) {
311
312
  ...riskAxesOf(creq.toolName),
312
313
  ...(re.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
313
314
  ...(re.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: re.persistedRuleShadowed } : {}),
314
- ...inheritedAskCarry(editOrigin, editFallback, isLiveApproverSeat(onAskOf), ancestorTracker),
315
+ ...inheritedAskCarry(judged, isLiveApproverSeat(onAskOf), ancestorTracker),
315
316
  ruleEvidence: inheritedAskEvidence,
316
317
  }, onAskOf, csignal ?? abortController.signal, lateAskSettlementObserver({ toolName: creq.toolName, toolCallId: creq.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
317
318
  settleDenialLimitFallback({ fallback: editFallback, mintedHere: editMintedHere, tracker: ancestorTracker, resolved: rr, headless: headlessDenyAtRecheck, stop: stopForDenialLimit, toolName: creq.toolName, toolCallId: creq.toolCallId });
@@ -372,7 +373,7 @@ export async function preparePolicyChain(input) {
372
373
  return { action: "allow" };
373
374
  if (judged.kind === "deny")
374
375
  return judged.result;
375
- const { ask: inheritedAsk, fallback, mintedHere, origin: inheritedOrigin } = judged;
376
+ const { ask: inheritedAsk, fallback, mintedHere } = judged;
376
377
  if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq.toolName)) {
377
378
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
378
379
  return { action: "allow" };
@@ -389,7 +390,7 @@ export async function preparePolicyChain(input) {
389
390
  ...riskAxesOf(creq.toolName),
390
391
  ...(inheritedAsk.action === "ask" && inheritedAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
391
392
  ...(inheritedAsk.action === "ask" && inheritedAsk.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: inheritedAsk.persistedRuleShadowed } : {}),
392
- ...inheritedAskCarry(inheritedOrigin, fallback, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
393
+ ...inheritedAskCarry(judged, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
393
394
  ruleEvidence: inheritedAskEvidence,
394
395
  }, pc.onAsk, csignal ?? abortController.signal, lateAskSettlementObserver({ toolName: creq.toolName, toolCallId: creq.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
395
396
  const askWaitMs = Math.max(0, now() - askT0);
@@ -452,7 +453,7 @@ export async function preparePolicyChain(input) {
452
453
  return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput } : { action: "allow" };
453
454
  if (judged.kind === "deny")
454
455
  return judged.result;
455
- const { ask: inheritedAsk, fallback, mintedHere, origin: inheritedOrigin } = judged;
456
+ const { ask: inheritedAsk, fallback, mintedHere } = judged;
456
457
  if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq.toolName)) {
457
458
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
458
459
  return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput } : { action: "allow" };
@@ -468,7 +469,7 @@ export async function preparePolicyChain(input) {
468
469
  ...riskAxesOf(creq.toolName),
469
470
  ...(inheritedAsk.action === "ask" && inheritedAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
470
471
  ...(inheritedAsk.action === "ask" && inheritedAsk.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: inheritedAsk.persistedRuleShadowed } : {}),
471
- ...inheritedAskCarry(inheritedOrigin, fallback, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
472
+ ...inheritedAskCarry(judged, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
472
473
  ruleEvidence: inheritedAskEvidence,
473
474
  }, pc.onAsk, csignal ?? abortController.signal, lateAskSettlementObserver({ toolName: creq.toolName, toolCallId: creq.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
474
475
  const askWaitMs = Math.max(0, now() - askT0);
@@ -634,7 +634,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
634
634
  session,
635
635
  tools: [...harnessTools],
636
636
  model,
637
- thinkingLevel: thinking ?? "off",
637
+ thinkingLevel: thinking,
638
638
  systemPrompt: systemPromptSeat.current,
639
639
  ...(systemBlocks ? { systemBlocks } : {}),
640
640
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
@@ -26,7 +26,7 @@ export function prepareTurnWiring(input) {
26
26
  layoutGroups: (systemBlocks ?? [{ groupId: "body", cacheControlBoundary: true }]).map((b) => ({ groupId: b.groupId, cacheControlBoundary: b.cacheControlBoundary })),
27
27
  stableSystemText: systemPromptSeat.current,
28
28
  toolWire: toolsToFingerprintInputs(harnessTools),
29
- thinkingLevel: (thinking ?? "off"),
29
+ thinkingLevel: thinking ?? null,
30
30
  maxTokens: model.maxTokens,
31
31
  hasOutputSchema: spec.outputSchema !== undefined,
32
32
  sessionId,
@@ -46,7 +46,7 @@ export interface PrepareWiringManifestInput {
46
46
  deps: RunnerDeps;
47
47
  /** borrowed-readonly — the trusted spawn-side channel: `questionFaceStripped`, `isDelegatedChild`, `onTaskNotification`,
48
48
  * `peerSelfRef`, `explicitAgentName` (the drain), `insideFork` / `agentName` / `parentToolCallId` (the identity). */
49
- internals: Pick<RunInternals, "questionFaceStripped" | "isDelegatedChild" | "onTaskNotification" | "peerSelfRef" | "explicitAgentName" | "insideFork" | "agentName" | "parentToolCallId"> | undefined;
49
+ internals: Pick<RunInternals, "questionFaceStripped" | "isDelegatedChild" | "onTaskNotification" | "peerSelfRef" | "explicitAgentName" | "insideFork" | "agentName" | "parentToolCallId" | "autoModeBreakerLedger"> | undefined;
50
50
  /** borrowed-readonly — the resume leg, or undefined (the manifest's leg derivation reads presence only). */
51
51
  resume: Pick<PrepareResume, "seed"> | undefined;
52
52
  /** borrowed-readonly — the acquired session id. */
@@ -199,7 +199,14 @@ export function prepareWiringManifest(input) {
199
199
  memoryAdmissionWired: deps.memoryScopeAdmission !== undefined,
200
200
  retentionPolicyWired: deps.retentionPolicy !== undefined,
201
201
  ...(modelGateManifest !== undefined ? { modelGate: modelGateManifest } : {}),
202
- autoMode: { armed: autoModeArmReason === "armed", reason: autoModeArmReason },
202
+ autoMode: {
203
+ armed: autoModeArmReason === "armed",
204
+ reason: autoModeArmReason,
205
+ ...(() => {
206
+ const trip = internals?.autoModeBreakerLedger?.lastTrip(sessionId);
207
+ return trip !== undefined ? { breaker: trip } : {};
208
+ })(),
209
+ },
203
210
  mcp: mcpManifestEntries(lockedPreflight.mcp, mcp.statuses),
204
211
  tools: toolRoster,
205
212
  });
@@ -1,9 +1,10 @@
1
1
  import { type Model } from "../../internal/llm.js";
2
2
  import { type CheckpointToken, type ResumeOutcome } from "../checkpoint-store.js";
3
3
  import { type TaskOutcome } from "../task-outcome.js";
4
+ import { type SwappableDeps } from "../swappable-deps.js";
4
5
  import { type SideQuerySpec, type SideQueryResult } from "../side-query.js";
5
6
  import type { SessionStore } from "../session.js";
6
- import type { AgentDefinition, ModelRef, ModelRoles, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js";
7
+ import type { AgentDefinition, ModelRoles, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js";
7
8
  import type { ResumeRun, ResumeTaskConfig, RunInternals, RunnerSelfSeat } from "./contracts.js";
8
9
  export type { ResumeTaskConfig } from "./contracts.js";
9
10
  /**
@@ -20,6 +21,9 @@ export declare class Runner implements RunnerSelfSeat {
20
21
  * authority: a durable resume's checkpoint seed wins on its leg, and a miss (cross-process,
21
22
  * evicted) falls back to the transcript-replay rung. See {@link SessionReadFileStates}. */
22
23
  private readonly sessionReadStates;
24
+ /** #616 — the per-session breaker read face's record (see {@link RunInternals.autoModeBreakerLedger}). Runner-lived:
25
+ * a trip in one leg is what the session's next leg reports. */
26
+ private readonly autoModeBreakerLedger;
23
27
  /** task notifications born BETWEEN turns (run torn down / harness already idle), parked
24
28
  * per session and drained into the session's next run at its first boundary. Runner-lived (outlives any
25
29
  * single run, like the registry handles that produce into it); bounded + drop-disclosing, see
@@ -57,7 +61,7 @@ export declare class Runner implements RunnerSelfSeat {
57
61
  private readonly locallyClaimedTokens;
58
62
  private static readonly PARENT_CONSTRAINT_REGISTRY_CAP;
59
63
  /** The lanes' LIVE view of `deps` ({@link RunnerDepsSeat}): a getter, never a captured object, because
60
- * `swapModels` replaces the object and a rung reading after its own await must see the replacement. */
64
+ * `swapDeps` replaces the object and a rung reading after its own await must see the replacement. */
61
65
  private readonly depsSeat;
62
66
  constructor(deps: RunnerDeps);
63
67
  /** design/73 §1 v1 — fire `RunnerDeps.onTaskOutcome` through the single swallow-guarded chokepoint
@@ -88,38 +92,35 @@ export declare class Runner implements RunnerSelfSeat {
88
92
  /** Acquire the lock for a sessionId; returns a release fn. New (undefined) sessions need no lock. */
89
93
  private acquireSessionLock;
90
94
  /**
91
- * Hot-swap the model catalog (and optionally the tier bindings) without restarting the process or
92
- * rebuilding the Runner the deployment seat that makes "switching models" a zero-restart
93
- * operation, and the ONLY sanctioned generation change.
95
+ * Hot-swap the deployment seats a running Runner may change without a restart — the ONE door for every
96
+ * hot-swappable seat ({@link SWAPPABLE_DEP_SEATS}: the model catalog generation `models` + its `tiers`
97
+ * bindings, and the deployment's read-face declaration `readFace`), and the ONLY sanctioned way to
98
+ * change any of them after construction. It replaces the former `swapModels` verb (retired, no alias):
99
+ * one verb per seat restated the same laws with a list of verbs as the only record of what is swappable.
94
100
  *
95
- * (Precisely: the constructor runs {@link expandTiers} once and keeps a private expanded copy **only
96
- * when `RunnerDeps.tiers` is configured** that is the arm where mutating the shared table after
97
- * construction provably never took effect. A tiers-less deployment's Runner holds the caller's own
98
- * `models` object BY REFERENCE, so mutating it after construction does leak through; that is an
99
- * accident of the expansion being a no-op, not a contract, and this verb is still the supported way
100
- * to change a generation it is what validates, announces, and computes the pairing disclosure.)
101
- *
102
- * Semantics:
103
- * - **Atomic**: the candidate catalog is tier-expanded and validated FIRST (an illegal tier
104
- * binding throws exactly like the constructor's boot error) on any throw the current
105
- * generation stays in force, untouched.
106
- * - **In-flight tasks keep their generation** (natural snapshot): a running task resolved its
107
- * `Model` object at prepare time and holds that reference; the swap changes what FUTURE
108
- * prepares (and auto-sourced agents' string refs {@link agentCatalog} copies per read)
109
- * resolve. Same-name redirects therefore never re-route or re-price a task mid-run; the
110
- * divergence window is exactly the in-flight tasks' lifetime, by design.
111
- * - `tiers` omitted the current tier bindings are kept (and re-applied over the new models);
112
- * explicitly passed (including `undefined`) replaced.
113
- * - Success is announced via `config.models_swapped` (models/tiers counts key material only,
114
- * never the catalog itself). A swap may additionally emit the advisory
115
- * `route.base_url_changed_key_unchanged` notice (deliberately conservative see its entry in
116
- * the notice directory) for same-name entries whose URL moved while the Model-visible
117
- * credential fingerprint did not.
101
+ * The laws, stated once (see the vocabulary module for the seat-by-seat meaning):
102
+ * - **Screened first, written once atomic.** The candidate is screened whole
103
+ * ({@link screenSwappableDeps}: the key set is the closed one, at least one seat, `tiers` rides with
104
+ * `models`, each seat's own value gate `readFace` through the very `assertReadFaceValue` every
105
+ * prepare door applies) and the catalog is tier-expanded on the candidate (an illegal binding throws
106
+ * exactly like the constructor's boot error) BEFORE the deps object is replaced. On any throw the
107
+ * current deps stay in force, byte-identical.
108
+ * - **In-flight legs keep their snapshot.** A running task read `this.deps` at its prepare and holds
109
+ * that object: its `Model`, its resolved read face, everything. The swap changes what FUTURE prepares
110
+ * (and auto-sourced agents' string refs {@link agentCatalog} copies per read) resolve. Same-name
111
+ * redirects never re-route or re-price a task mid-run; a read-face change never re-fences a mounted
112
+ * band; the divergence window is exactly the in-flight legs' lifetime, by design.
113
+ * - **Presence is the instruction.** A key present on the candidate is replaced (an explicit
114
+ * `undefined` clears an optional seat: `tiers: undefined` no bindings, `readFace: undefined` ⇒ the
115
+ * engine default); a key absent is kept. `tiers` omitted beside `models` the current bindings are
116
+ * re-applied over the new catalog.
117
+ * - **Announced per seat.** `config.models_swapped` (models/tiers counts key material only, never the
118
+ * catalog itself; plus the advisory `route.base_url_changed_key_unchanged` for same-name entries
119
+ * whose URL moved while the Model-visible credential fingerprint did not) and
120
+ * `config.read_face_swapped` (`{ from, to, occurrenceId }`, the seat's previous and new value, `null`
121
+ * = no declaration)the table {@link SWAP_SEAT_NOTICE} binds every seat to its line.
118
122
  */
119
- swapModels(next: {
120
- models: Record<string, Model>;
121
- tiers?: Record<string, ModelRef>;
122
- }): void;
123
+ swapDeps(next: SwappableDeps): void;
123
124
  /** one-shot brain-routed utility query (see {@link runSideQuery} for the full contract):
124
125
  * preserves system/multi-turn messages/tool DEFINITIONS, routes through the deployment's brain with
125
126
  * the same model resolution as tasks, returns real usage/model. No session, no tool execution, no
@@ -23,6 +23,8 @@ import { resolveReasoning } from "../../brain/reasoning.js";
23
23
  import { adjudicateDerivedRoute, authCarrierFingerprint, fallbackToPrimaryNotice, normalizeBaseUrl, sameRouteIdentity } from "../../brain/route-adjudicator.js";
24
24
  import { runWithBrainTelemetry, runWithReasoningWireFacts, runWithStatusSink } from "../../brain/status-sink.js";
25
25
  import { expandTiers, resolveModel, resolveTaskModel } from "../roles.js";
26
+ import { screenSwappableDeps } from "../swappable-deps.js";
27
+ import { AutoModeBreakerLedger } from "../auto-mode.js";
26
28
  import { runSideQuery } from "../side-query.js";
27
29
  import { generatePromptSuggestions } from "./prompt-suggestions.js";
28
30
  import { PushQueue } from "../push-queue.js";
@@ -82,6 +84,7 @@ export class Runner {
82
84
  sessions;
83
85
  sessionLocks = new Map();
84
86
  sessionReadStates = new SessionReadFileStates();
87
+ autoModeBreakerLedger = new AutoModeBreakerLedger();
85
88
  pendingSessionNotifications = new PendingSessionNotifications();
86
89
  parentConstraintRegistry = new Map();
87
90
  suspendedEnvReaps = new Map();
@@ -213,41 +216,67 @@ export class Runner {
213
216
  });
214
217
  };
215
218
  }
216
- swapModels(next) {
217
- if (next.models === null || typeof next.models !== "object" || Array.isArray(next.models)) {
218
- throw new Error(`swapModels: models must be a plain Record<string, Model> (got ${next.models === null ? "null" : Array.isArray(next.models) ? "array" : typeof next.models}) the current generation stays in force`);
219
- }
220
- const tiers = Object.hasOwn(next, "tiers") ? next.tiers : this.deps.tiers;
221
- const expanded = tiers && Object.keys(tiers).length > 0 ? expandTiers({ ...next.models }, tiers) : { ...next.models };
219
+ swapDeps(next) {
220
+ const screened = screenSwappableDeps(next);
221
+ const { seats, candidate } = screened;
222
+ const catalogSwap = seats.includes("models");
223
+ let expanded;
224
+ let tiers;
222
225
  const movedEntries = [];
223
- for (const [name, nextModel] of Object.entries(expanded ?? {})) {
224
- const prior = this.deps.models?.[name];
225
- if (!prior)
226
- continue;
227
- const from = normalizeBaseUrl(prior.baseUrl);
228
- const to = normalizeBaseUrl(nextModel.baseUrl);
229
- if (from !== to && authCarrierFingerprint(prior.headers) === authCarrierFingerprint(nextModel.headers)) {
230
- movedEntries.push({ modelId: name, from, to });
231
- }
232
- }
233
- this.deps = { ...this.deps, models: expanded, ...(tiers !== undefined ? { tiers } : {}) };
234
- if (tiers === undefined)
235
- delete this.deps.tiers;
236
- deliverEngineNotice(this.deps.onNotice, {
237
- code: "config.models_swapped",
238
- message: `model catalog swapped: ${Object.keys(next.models).length} model(s), ${tiers ? Object.keys(tiers).length : 0} tier binding(s); in-flight tasks finish on their resolved models, new tasks resolve against the new catalog`,
239
- detail: { models: Object.keys(next.models).length, tiers: tiers ? Object.keys(tiers).length : 0, occurrenceId: uuidv7() },
240
- });
241
- if (movedEntries.length > 0) {
242
- const RENDER_CAP = 8;
243
- const rendered = movedEntries.slice(0, RENDER_CAP);
226
+ if (catalogSwap) {
227
+ const models = candidate.models;
228
+ tiers = Object.hasOwn(candidate, "tiers") ? candidate.tiers : this.deps.tiers;
229
+ expanded = tiers && Object.keys(tiers).length > 0 ? expandTiers({ ...models }, tiers) : { ...models };
230
+ for (const [name, nextModel] of Object.entries(expanded ?? {})) {
231
+ const prior = this.deps.models?.[name];
232
+ if (!prior)
233
+ continue;
234
+ const from = normalizeBaseUrl(prior.baseUrl);
235
+ const to = normalizeBaseUrl(nextModel.baseUrl);
236
+ if (from !== to && authCarrierFingerprint(prior.headers) === authCarrierFingerprint(nextModel.headers)) {
237
+ movedEntries.push({ modelId: name, from, to });
238
+ }
239
+ }
240
+ }
241
+ const faceSwap = seats.includes("readFace");
242
+ const priorFace = this.deps.readFace;
243
+ const written = {
244
+ ...this.deps,
245
+ ...(expanded !== undefined ? { models: expanded } : {}),
246
+ ...(catalogSwap && tiers !== undefined ? { tiers } : {}),
247
+ ...(faceSwap && candidate.readFace !== undefined ? { readFace: candidate.readFace } : {}),
248
+ };
249
+ if (catalogSwap && tiers === undefined)
250
+ delete written.tiers;
251
+ if (faceSwap && candidate.readFace === undefined)
252
+ delete written.readFace;
253
+ this.deps = written;
254
+ if (catalogSwap) {
255
+ const modelCount = Object.keys(candidate.models).length;
256
+ deliverEngineNotice(this.deps.onNotice, {
257
+ code: "config.models_swapped",
258
+ message: `model catalog swapped: ${modelCount} model(s), ${tiers ? Object.keys(tiers).length : 0} tier binding(s); in-flight tasks finish on their resolved models, new tasks resolve against the new catalog`,
259
+ detail: { models: modelCount, tiers: tiers ? Object.keys(tiers).length : 0, occurrenceId: uuidv7() },
260
+ });
261
+ if (movedEntries.length > 0) {
262
+ const RENDER_CAP = 8;
263
+ const rendered = movedEntries.slice(0, RENDER_CAP);
264
+ deliverEngineNotice(this.deps.onNotice, {
265
+ code: "route.base_url_changed_key_unchanged",
266
+ message: `model catalog swap moved ${movedEntries.length} entry/entries to a new baseUrl while the Model-visible credential half stayed unchanged: ` +
267
+ rendered.map((e) => `"${e.modelId}" ${e.from || "(config root)"} → ${e.to || "(config root)"}`).join("; ") +
268
+ (movedEntries.length > rendered.length ? `; +${movedEntries.length - rendered.length} more` : "") +
269
+ " — if the provider changed (not just its domain), update the credential reference in the same step",
270
+ detail: { entries: rendered, total: movedEntries.length, occurrenceId: uuidv7() },
271
+ });
272
+ }
273
+ }
274
+ if (faceSwap) {
275
+ const to = candidate.readFace ?? null;
244
276
  deliverEngineNotice(this.deps.onNotice, {
245
- code: "route.base_url_changed_key_unchanged",
246
- message: `model catalog swap moved ${movedEntries.length} entry/entries to a new baseUrl while the Model-visible credential half stayed unchanged: ` +
247
- rendered.map((e) => `"${e.modelId}" ${e.from || "(config root)"} → ${e.to || "(config root)"}`).join("; ") +
248
- (movedEntries.length > rendered.length ? `; +${movedEntries.length - rendered.length} more` : "") +
249
- " — if the provider changed (not just its domain), update the credential reference in the same step",
250
- detail: { entries: rendered, total: movedEntries.length, occurrenceId: uuidv7() },
277
+ code: "config.read_face_swapped",
278
+ message: `deployment read face swapped: ${priorFace ?? "(no declaration)"} ${to ?? "(no declaration)"}; in-flight legs keep the face they prepared under, new legs resolve against the new declaration`,
279
+ detail: { from: priorFace ?? null, to, occurrenceId: uuidv7() },
251
280
  });
252
281
  }
253
282
  }
@@ -962,6 +991,7 @@ export class Runner {
962
991
  peerInboundChainRef,
963
992
  detachHub,
964
993
  sessionReadStates: this.sessionReadStates,
994
+ autoModeBreakerLedger: this.autoModeBreakerLedger,
965
995
  onTaskNotification: (notification, opts) => {
966
996
  try {
967
997
  upstreamTaskNotification?.(notification, opts);
@@ -400,6 +400,11 @@ export interface RunnerDeps {
400
400
  * may not. Beside a read-only (verifier) mount this seat's "open" silently CLAMPS to roots (a
401
401
  * deployment default cannot override a load-bearing containment wall) — TaskSpec.readFace's own
402
402
  * "open" there is the genuine per-task contradiction, and that one still refuses loudly (#123).
403
+ *
404
+ * A LIVE seat: `Runner.swapDeps({ readFace })` replaces it without a restart (the same natural
405
+ * snapshot the model catalog has — a leg reads the seat at its prepare and keeps that reading; every
406
+ * later prepare reads the new declaration; announced as `config.read_face_swapped`). A host whose
407
+ * declaration arrives late (a governance source read after boot) swaps it in rather than restarting.
403
408
  */
404
409
  readFace?: import("../tools/fs/read-face.js").ReadFace;
405
410
  /**
@@ -686,10 +691,12 @@ export interface RunnerDeps {
686
691
  timeoutMs?: number;
687
692
  /** Consecutive-failure threshold opening the one-way session breaker (default 3). */
688
693
  failureThreshold?: number;
689
- /** Fired ONCE when the session breaker opens (the "退回非 auto" alarm — surface it to the operator). */
694
+ /** Fired ONCE when the session breaker opens (the "退回非 auto" alarm — surface it to the operator). The engine
695
+ * records the trip on the session's breaker read face (`WiringManifest.autoMode.breaker`, #616) BEFORE calling
696
+ * this hook, so a deployment that wires nothing here still gets the read face. */
690
697
  onBreakerOpen?: (info: {
691
698
  consecutiveFailures: number;
692
- lastCause: string;
699
+ lastCause: import("./auto-mode.js").AutoModeBreakerCause;
693
700
  }) => void;
694
701
  /**
695
702
  * The classifier DENIAL LIMIT (CC 2.1.250 `FO`/`AKe`): a run whose classifier keeps blocking falls
@@ -9,10 +9,20 @@ export interface ShellWord {
9
9
  * yield zero, one or several words. */
10
10
  readonly expands: false | "one" | "many";
11
11
  }
12
+ /** One redirection of a segment, as read: the operator and its operand word (a heredoc's operand is its
13
+ * delimiter; the IO number or `{name}` before the operator is folded into the operator's meaning and not
14
+ * kept). The tightening reader never reads these — a rule speaks about program runs; the read-only
15
+ * reader does (an output redirection is a write). */
16
+ export interface ShellRedirection {
17
+ readonly op: string;
18
+ readonly target: ShellWord;
19
+ }
12
20
  /** One program run the lexer found, after keyword stripping. */
13
21
  export interface ShellSegment {
14
22
  /** The run as SPELLED (a leading keyword removed): the first candidate a rule is compared against. */
15
23
  readonly argv: readonly ShellWord[];
24
+ /** The segment's redirections in source order (see {@link ShellRedirection}); empty when it has none. */
25
+ readonly redirections: readonly ShellRedirection[];
16
26
  /** The deeper candidates, one per peeled layer (leading assignments, then each wrapper of the table):
17
27
  * `sudo -u root rm -r x` ⇒ `[[rm, -r, x]]`. Empty when nothing peeled. */
18
28
  readonly peeled: readonly (readonly ShellWord[])[];
@@ -34,6 +44,14 @@ export interface ShellCommandShape {
34
44
  /** A subshell, group, control-structure keyword or dropped empty piece bounded the segments: the
35
45
  * connector list is not one flat chain a compound rule could spell. */
36
46
  readonly grouped: boolean;
47
+ /** A `&` connector anywhere — between two segments or after the last one (`ls &`): some segment runs in the
48
+ * background, past any approval-time reading of the line. The connector list alone cannot say so for a
49
+ * trailing `&` (a connector rides BETWEEN segments). */
50
+ readonly backgrounded: boolean;
51
+ /** A redirection on a piece that runs NO program (`> x` alone, `[ -f a ] > x`, a keyword's own redirection):
52
+ * such a piece is dropped from `segments` (the tightening reader asks about program runs, and it runs
53
+ * none) — but the shell still opens the target, so a reader that asks about EFFECTS must refuse the line. */
54
+ readonly strayRedirection: boolean;
37
55
  }
38
56
  export { SHELL_WRAPPER_TABLE, type ShellWrapperName } from "./shell-wrapper-table.js";
39
57
  /** Longer than this and the command is not read at all (one unreadable segment): every pass is linear,