@sensigo/realm-cli 0.39.0 → 0.41.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 (109) hide show
  1. package/dist/agent/drive-failure.d.ts +62 -0
  2. package/dist/agent/drive-failure.d.ts.map +1 -0
  3. package/dist/agent/drive-failure.js +241 -0
  4. package/dist/agent/drive-failure.js.map +1 -0
  5. package/dist/agent/providers/agent-utils.d.ts +156 -0
  6. package/dist/agent/providers/agent-utils.d.ts.map +1 -1
  7. package/dist/agent/providers/agent-utils.js +423 -1
  8. package/dist/agent/providers/agent-utils.js.map +1 -1
  9. package/dist/agent/providers/anthropic-provider.d.ts +51 -1
  10. package/dist/agent/providers/anthropic-provider.d.ts.map +1 -1
  11. package/dist/agent/providers/anthropic-provider.js +109 -30
  12. package/dist/agent/providers/anthropic-provider.js.map +1 -1
  13. package/dist/agent/providers/llm-provider.d.ts +13 -1
  14. package/dist/agent/providers/llm-provider.d.ts.map +1 -1
  15. package/dist/agent/providers/llm-provider.js +8 -4
  16. package/dist/agent/providers/llm-provider.js.map +1 -1
  17. package/dist/agent/providers/openai-provider.d.ts +6 -1
  18. package/dist/agent/providers/openai-provider.d.ts.map +1 -1
  19. package/dist/agent/providers/openai-provider.js +75 -20
  20. package/dist/agent/providers/openai-provider.js.map +1 -1
  21. package/dist/agent/providers/openai-reasoning-provider.d.ts +4 -1
  22. package/dist/agent/providers/openai-reasoning-provider.d.ts.map +1 -1
  23. package/dist/agent/providers/openai-reasoning-provider.js +21 -6
  24. package/dist/agent/providers/openai-reasoning-provider.js.map +1 -1
  25. package/dist/agent/run-agent.d.ts +9 -0
  26. package/dist/agent/run-agent.d.ts.map +1 -1
  27. package/dist/agent/run-agent.js +775 -680
  28. package/dist/agent/run-agent.js.map +1 -1
  29. package/dist/agent/run-attach.d.ts.map +1 -1
  30. package/dist/agent/run-attach.js +26 -2
  31. package/dist/agent/run-attach.js.map +1 -1
  32. package/dist/agent/test-support/anthropic-stub.d.ts +45 -0
  33. package/dist/agent/test-support/anthropic-stub.d.ts.map +1 -0
  34. package/dist/agent/test-support/anthropic-stub.js +146 -0
  35. package/dist/agent/test-support/anthropic-stub.js.map +1 -0
  36. package/dist/agent/test-support/composed-journey.d.ts +40 -0
  37. package/dist/agent/test-support/composed-journey.d.ts.map +1 -0
  38. package/dist/agent/test-support/composed-journey.js +104 -0
  39. package/dist/agent/test-support/composed-journey.js.map +1 -0
  40. package/dist/agent/test-support/mcp-server-entry.d.ts +20 -0
  41. package/dist/agent/test-support/mcp-server-entry.d.ts.map +1 -0
  42. package/dist/agent/test-support/mcp-server-entry.js +39 -0
  43. package/dist/agent/test-support/mcp-server-entry.js.map +1 -0
  44. package/dist/agent/test-support/openai-stub.d.ts +47 -0
  45. package/dist/agent/test-support/openai-stub.d.ts.map +1 -0
  46. package/dist/agent/test-support/openai-stub.js +115 -0
  47. package/dist/agent/test-support/openai-stub.js.map +1 -0
  48. package/dist/commands/agent.d.ts +6 -0
  49. package/dist/commands/agent.d.ts.map +1 -1
  50. package/dist/commands/agent.js +44 -3
  51. package/dist/commands/agent.js.map +1 -1
  52. package/dist/commands/drain.d.ts.map +1 -1
  53. package/dist/commands/drain.js +37 -3
  54. package/dist/commands/drain.js.map +1 -1
  55. package/dist/commands/gc.d.ts.map +1 -1
  56. package/dist/commands/gc.js.map +1 -1
  57. package/dist/commands/inspect.d.ts.map +1 -1
  58. package/dist/commands/inspect.js +61 -2
  59. package/dist/commands/inspect.js.map +1 -1
  60. package/dist/commands/list.d.ts.map +1 -1
  61. package/dist/commands/list.js +86 -0
  62. package/dist/commands/list.js.map +1 -1
  63. package/dist/commands/listen.d.ts +13 -0
  64. package/dist/commands/listen.d.ts.map +1 -1
  65. package/dist/commands/listen.js +36 -4
  66. package/dist/commands/listen.js.map +1 -1
  67. package/dist/commands/purge.d.ts +22 -1
  68. package/dist/commands/purge.d.ts.map +1 -1
  69. package/dist/commands/purge.js +70 -42
  70. package/dist/commands/purge.js.map +1 -1
  71. package/dist/commands/register.d.ts +25 -0
  72. package/dist/commands/register.d.ts.map +1 -1
  73. package/dist/commands/register.js +91 -21
  74. package/dist/commands/register.js.map +1 -1
  75. package/dist/commands/respond.d.ts.map +1 -1
  76. package/dist/commands/respond.js +23 -1
  77. package/dist/commands/respond.js.map +1 -1
  78. package/dist/commands/run.d.ts +34 -0
  79. package/dist/commands/run.d.ts.map +1 -1
  80. package/dist/commands/run.js +225 -11
  81. package/dist/commands/run.js.map +1 -1
  82. package/dist/commands/test.d.ts.map +1 -1
  83. package/dist/commands/test.js +48 -8
  84. package/dist/commands/test.js.map +1 -1
  85. package/dist/commands/validate.d.ts +19 -0
  86. package/dist/commands/validate.d.ts.map +1 -1
  87. package/dist/commands/validate.js +358 -84
  88. package/dist/commands/validate.js.map +1 -1
  89. package/dist/commands/watch.d.ts +30 -0
  90. package/dist/commands/watch.d.ts.map +1 -1
  91. package/dist/commands/watch.js +159 -23
  92. package/dist/commands/watch.js.map +1 -1
  93. package/dist/commands/workflow-list.d.ts +16 -0
  94. package/dist/commands/workflow-list.d.ts.map +1 -0
  95. package/dist/commands/workflow-list.js +96 -0
  96. package/dist/commands/workflow-list.js.map +1 -0
  97. package/dist/commands-registry.d.ts.map +1 -1
  98. package/dist/commands-registry.js +2 -0
  99. package/dist/commands-registry.js.map +1 -1
  100. package/dist/extensions/load-project-extensions.d.ts.map +1 -1
  101. package/dist/extensions/load-project-extensions.js +9 -3
  102. package/dist/extensions/load-project-extensions.js.map +1 -1
  103. package/dist/index.js +22 -1
  104. package/dist/index.js.map +1 -1
  105. package/dist/lib/loader-warnings.d.ts +45 -3
  106. package/dist/lib/loader-warnings.d.ts.map +1 -1
  107. package/dist/lib/loader-warnings.js +106 -5
  108. package/dist/lib/loader-warnings.js.map +1 -1
  109. package/package.json +5 -4
@@ -3,10 +3,11 @@
3
3
  // All Slack-specific gate notification logic lives in slack-gate-notifier.ts.
4
4
  import { join } from 'node:path';
5
5
  import { loadWorkflowFromFile, findEligibleSteps, classifyInProgressClaims, executeChain, buildNextActions, findCapabilityBlockedSteps, unmetCapabilities, capabilityWarning, buildFailedAttemptRecord, WorkflowError, DEFAULT_VALIDATION_EXHAUSTION_THRESHOLD, assessStructuredOutputEligibility, renderIneligibleMessage, } from '@sensigo/realm';
6
- import { setAdditionalRedactionValues, renderValidationSummaryEntry, } from './providers/agent-utils.js';
6
+ import { sanitizeError, setAdditionalRedactionValues, renderValidationSummaryEntry, deriveLlmClock, safeErrorText, } from './providers/agent-utils.js';
7
7
  import { isToolCapable } from './providers/llm-provider.js';
8
8
  import { McpClient as McpClientImpl } from './mcp/mcp-client.js';
9
9
  import { scheduleGateExpiryTimer } from './gate/gate-expiry-timer.js';
10
+ import { recordDriveFailure, buildEntry, MESSAGE_CAP } from './drive-failure.js';
10
11
  /**
11
12
  * Dormant strict posture (issue #197 PR-2, design §6 — the #169→#170 template): read PER CALL,
12
13
  * never cached at module load (a test flips the env var mid-process). "on" = set to any
@@ -96,9 +97,16 @@ export async function runAgent(deps, options) {
96
97
  // issue #217: resolved once per run (not per call, unlike shouldMintWriterNonce — there is no
97
98
  // env-var strict-flip counterpart here). `0` disables the repair loop entirely.
98
99
  const schemaRetries = deps.schemaRetries ?? 2;
100
+ // issue #401 — the per-ATTEMPT ceiling in seconds, before the per-step key overrides it.
101
+ // 600s is the SDKs' own default request timeout, so an unconfigured drive keeps today's
102
+ // per-attempt patience; what changes is that the bound is realm's, it also covers the SDK's
103
+ // internal retries, and a fired bound says which lever to raise.
104
+ const fallbackLlmTimeoutSeconds = deps.llmTimeoutSeconds ?? 600;
99
105
  // issue #236 — sticky downgrade (design §4 [Rv8]): per step, homed HERE (run-agent scope,
100
- // beside the verdict) — survives ALL THREE loops (the provider ladder ⊂ the 2-attempt callStep
101
- // wrapper the #217 repair loop). Once armed for a step, every LATER attempt for that SAME
106
+ // beside the verdict) — survives BOTH remaining loops (the provider ladder ⊂ the #217 repair
107
+ // loop; the 2-attempt callStep wrapper that used to sit between them was retired by issue #401,
108
+ // because silently retrying is what made a failing drive invisible). Once armed for a step,
109
+ // every LATER attempt for that SAME
102
110
  // step name (across repair iterations, across a re-attach) never re-sends strict — it goes
103
111
  // straight to the ORIGINAL downgrade_reason/api_message. Never cleared for the run's lifetime
104
112
  // (R-Q: a non-grammar 503 disables prevention for the whole drive session — accepted, the
@@ -148,6 +156,9 @@ export async function runAgent(deps, options) {
148
156
  await deps.workflowStore.register(definition);
149
157
  }
150
158
  let runId;
159
+ // Declared without an initialiser and narrowed by `currentRun === undefined` below: the attach
160
+ // path always assigns it, and keying the moved re-read on the value rather than on the option
161
+ // is what keeps control-flow analysis satisfied.
151
162
  let currentRun;
152
163
  if (options.existingRunId !== undefined) {
153
164
  // --run-id path: attach to existing run
@@ -172,573 +183,625 @@ export async function runAgent(deps, options) {
172
183
  params: options.params,
173
184
  });
174
185
  runId = initialRecord.id;
175
- currentRun = await deps.store.get(runId);
176
- // #134 pre-flight (WARN-only, never refuse): warn at CREATE only. The attach path (--run-id) above
177
- // is N-A — it re-drives an EXISTING run, where recoverable-settle + the A5 surfaces already handle a
178
- // capability block. `deps.registry` is always a real registry, so the `?? createDefaultRegistry()`
179
- // invariant holds structurally.
180
- for (const req of unmetCapabilities(definition, deps.registry)) {
181
- console.warn(`⚠ ${capabilityWarning(req)}`);
182
- }
183
186
  }
184
- console.log(`\nRealm Agent ${definition.name} v${definition.version}`);
185
- console.log(`Run ID: ${runId}\n`);
186
- // Initialise MCP client if any steps declare tools.
187
- let mcpClient;
188
- if (definition.mcp_servers !== undefined && definition.mcp_servers.length > 0) {
189
- const serverIds = new Set(definition.mcp_servers.map((s) => s.id));
190
- for (const step of Object.values(definition.steps)) {
191
- for (const toolEntry of step.tools ?? []) {
192
- const serverId = toolEntry.split(':')[0] ?? '';
193
- if (!serverIds.has(serverId)) {
194
- throw new Error(`Step tool '${toolEntry}' references unknown MCP server '${serverId}'`);
195
- }
187
+ // ═══ issue #401, CHOKEPOINT (3)the last-resort catch opens HERE ═══
188
+ //
189
+ // It opens the moment `runId` exists and not before: everything above is pre-run, so a throw
190
+ // there has no record to attach itself to and the console is the only honest floor. Everything
191
+ // BELOW is a failed drive attempt on a real run, and every one of them used to vanish.
192
+ //
193
+ // The MCP-init block moved inside deliberately — its unknown-server and tool-incapable throws
194
+ // are exactly the "run created, then died before any step ran" case that read healthy for 24
195
+ // hours.
196
+ let currentStepName;
197
+ let attemptStartedAt = Date.now();
198
+ try {
199
+ if (currentRun === undefined) {
200
+ currentRun = await deps.store.get(runId);
201
+ // #134 pre-flight (WARN-only, never refuse): warn at CREATE only. The attach path (--run-id)
202
+ // above is N-A — it re-drives an EXISTING run, where recoverable-settle + the A5 surfaces
203
+ // already handle a capability block. `deps.registry` is always a real registry, so the
204
+ // `?? createDefaultRegistry()` invariant holds structurally.
205
+ for (const req of unmetCapabilities(definition, deps.registry)) {
206
+ console.warn(`⚠ ${capabilityWarning(req)}`);
196
207
  }
197
208
  }
198
- mcpClient = (deps.mcpClientFactory ?? ((s, sig) => new McpClientImpl(s, sig)))(definition.mcp_servers, undefined);
199
- if (!isToolCapable(deps.provider)) {
200
- throw new Error('This workflow uses MCP tool-enabled steps, but the configured LLM provider does not support tool calling. ' +
201
- 'Reasoning models (o1-series) and custom non-tool providers cannot run tool-enabled steps. ' +
202
- 'Use --provider openai with a standard chat model (e.g. gpt-4o), or --provider anthropic.');
203
- }
204
- }
205
- try {
206
- while (!currentRun.terminal_state) {
207
- // --- Gate handling ---
208
- if (currentRun.pending_gate !== undefined) {
209
- const gate = currentRun.pending_gate;
210
- console.log(`\n⏸ Gate: ${gate.step_name} | ID: ${gate.gate_id}`);
211
- const gateStepDef = definition.steps[gate.step_name];
212
- const gateText = gate.resolved_message ??
213
- (gateStepDef?.display !== undefined
214
- ? renderDisplay(gateStepDef.display, gate.preview)
215
- : formatOutputForTerminal(gate.preview));
216
- const indented = gateText
217
- .trimEnd()
218
- .split('\n')
219
- .map((l) => ` ${l}`)
220
- .join('\n');
221
- console.log('\n' + indented + '\n');
222
- if (deps.gateHandler !== undefined) {
223
- await deps.gateHandler(runId, gate);
224
- }
225
- else {
226
- // Terminal fallback: print each choice as a command and poll.
227
- for (const choice of gate.choices) {
228
- const label = choice.charAt(0).toUpperCase() + choice.slice(1);
229
- console.log(` ${label}: realm run respond ${runId} --gate ${gate.gate_id} --choice ${choice}`);
230
- }
231
- // issue #291 (Deliverable 4e, Amendment 4): the ATTENDING-PROCESS enactment timer —
232
- // this IS "the non-Slack agent poll loop" the design names as its own timer host. A
233
- // no-op for a finding-only/non-expiring gate.
234
- const clearExpiryTimer = scheduleGateExpiryTimer(runId, gate, {
235
- store: deps.store,
236
- definition,
237
- registry: deps.registry,
238
- });
239
- try {
240
- await pollUntilGateResolved(deps.store, runId, gate.gate_id, options.pollIntervalMs ?? 3000);
241
- }
242
- finally {
243
- clearExpiryTimer();
209
+ console.log(`\nRealm Agent ${definition.name} v${definition.version}`);
210
+ console.log(`Run ID: ${runId}\n`);
211
+ // Initialise MCP client if any steps declare tools.
212
+ let mcpClient;
213
+ if (definition.mcp_servers !== undefined && definition.mcp_servers.length > 0) {
214
+ const serverIds = new Set(definition.mcp_servers.map((s) => s.id));
215
+ for (const step of Object.values(definition.steps)) {
216
+ for (const toolEntry of step.tools ?? []) {
217
+ const serverId = toolEntry.split(':')[0] ?? '';
218
+ if (!serverIds.has(serverId)) {
219
+ throw new Error(`Step tool '${toolEntry}' references unknown MCP server '${serverId}'`);
244
220
  }
245
221
  }
246
- currentRun = await deps.store.get(runId);
247
- continue;
248
222
  }
249
- // --- Step execution ---
250
- const eligible = findEligibleSteps(definition, currentRun);
251
- if (eligible.length === 0) {
252
- // Detect-only wedge surfacing (issue #101): before exiting on "nothing eligible", check
253
- // whether the run is an after-claim wedge (an in-progress claim that is stale or
254
- // unknown-age). If so, print the claim state(s) + the exact reclaim remediation so the
255
- // operator is never silently parked. Phase 1 attach does NOT auto-reclaim.
256
- if (currentRun.in_progress_steps.length > 0) {
257
- const wedged = classifyInProgressClaims(currentRun).filter((c) => c.state !== 'healthy');
258
- if (wedged.length > 0) {
259
- console.log(`\n ⚠ Run '${runId}' is wedged — a claimed step never settled (its runner likely died):`);
260
- for (const c of wedged) {
261
- console.log(` • ${c.step}: ${c.state}`);
262
- console.log(` recover with: realm run reclaim ${runId} --step ${c.step} --force`);
223
+ mcpClient = (deps.mcpClientFactory ?? ((s, sig) => new McpClientImpl(s, sig)))(definition.mcp_servers, undefined);
224
+ if (!isToolCapable(deps.provider)) {
225
+ throw new Error('This workflow uses MCP tool-enabled steps, but the configured LLM provider does not support tool calling. ' +
226
+ 'Reasoning models (o1-series) and custom non-tool providers cannot run tool-enabled steps. ' +
227
+ 'Use --provider openai with a standard chat model (e.g. gpt-4o), or --provider anthropic.');
228
+ }
229
+ }
230
+ try {
231
+ while (!currentRun.terminal_state) {
232
+ // --- Gate handling ---
233
+ if (currentRun.pending_gate !== undefined) {
234
+ const gate = currentRun.pending_gate;
235
+ console.log(`\n⏸ Gate: ${gate.step_name} | ID: ${gate.gate_id}`);
236
+ const gateStepDef = definition.steps[gate.step_name];
237
+ const gateText = gate.resolved_message ??
238
+ (gateStepDef?.display !== undefined
239
+ ? renderDisplay(gateStepDef.display, gate.preview)
240
+ : formatOutputForTerminal(gate.preview));
241
+ const indented = gateText
242
+ .trimEnd()
243
+ .split('\n')
244
+ .map((l) => ` ${l}`)
245
+ .join('\n');
246
+ console.log('\n' + indented + '\n');
247
+ if (deps.gateHandler !== undefined) {
248
+ await deps.gateHandler(runId, gate);
249
+ }
250
+ else {
251
+ // Terminal fallback: print each choice as a command and poll.
252
+ for (const choice of gate.choices) {
253
+ const label = choice.charAt(0).toUpperCase() + choice.slice(1);
254
+ console.log(` ${label}: realm run respond ${runId} --gate ${gate.gate_id} --choice ${choice}`);
255
+ }
256
+ // issue #291 (Deliverable 4e, Amendment 4): the ATTENDING-PROCESS enactment timer —
257
+ // this IS "the non-Slack agent poll loop" the design names as its own timer host. A
258
+ // no-op for a finding-only/non-expiring gate.
259
+ const clearExpiryTimer = scheduleGateExpiryTimer(runId, gate, {
260
+ store: deps.store,
261
+ definition,
262
+ registry: deps.registry,
263
+ });
264
+ try {
265
+ await pollUntilGateResolved(deps.store, runId, gate.gate_id, options.pollIntervalMs ?? 3000);
266
+ }
267
+ finally {
268
+ clearExpiryTimer();
263
269
  }
264
- console.log(` (reclaim re-drives the step; its side effects may repeat — see 'realm run reclaim ${runId}' for a dry-run.)`);
265
270
  }
271
+ currentRun = await deps.store.get(runId);
272
+ continue;
266
273
  }
267
- break;
268
- }
269
- const stepName = eligible[0];
270
- const stepDef = definition.steps[stepName];
271
- // issue #217: the in-drive schema-feedback repair loop. `stepInput`/`toolCallsForMeta`/
272
- // `result` are re-assigned on every attempt inside the `for` loop below; `repairsUsed`/
273
- // `lastRejection` persist ACROSS attempts within this one step, and are fresh (0/undefined)
274
- // for every new step. The loop body is exactly the former single-pass step-execution region
275
- // (the agent/auto branch bodies + the executeChain call) an auto step's
276
- // `execution !== 'agent'` means the repair gate's conjunct (iii) can never hold for it, so
277
- // it structurally can never iterate more than once: the loop is a no-op wrapper for every
278
- // pre-existing (non-repair) case.
279
- let stepInput = {};
280
- let toolCallsForMeta;
281
- // issue #236: the resolved structuredOutput meta for THIS attempt's callStep call — reset
282
- // every iteration alongside toolCallsForMeta, threaded into stepMeta below.
283
- let structuredOutputMetaForStep;
284
- let result;
285
- let repairsUsed = 0;
286
- let lastRejection;
287
- for (;;) {
288
- toolCallsForMeta = undefined;
289
- structuredOutputMetaForStep = undefined;
290
- // issue #217 conjunct (vi) ground truth — captured FRESH at the top of EVERY attempt
291
- // (including the first), never once per step: a per-step capture is stale across the
292
- // whole provider LLM call, so any concurrent writer (a second drive, a gate `respond`, a
293
- // parallel-step settle) landing during that call would silently forfeit a legitimate
294
- // repair. See the repair-gate comment below for the full discriminator rationale. Cost:
295
- // one extra store read per attempt — accepted.
296
- const versionBeforeAttempt = (await deps.store.get(runId)).version;
297
- if (stepDef.execution === 'agent') {
298
- // Resolve template-expanded prompt via buildNextActions so {{ context.resources.* }}
299
- // references are substituted before the LLM call. Pure w.r.t. `definition`/`currentRun`,
300
- // both unchanged across repair attempts — a rejected attempt no longer leaves
301
- // `currentRun` itself stale relative to what's persisted (issue #220: countRejection DOES
302
- // persist a bounded rejection counter on a counted rejection — "nothing is ever
303
- // persisted on a rejected attempt" is FALSE as of #220), but `currentRun`/`definition`
304
- // are still safe to recompute per iteration here regardless, since neither is read from
305
- // again until the NEXT step (this step's own next_actions/prompt derivation never
306
- // consults `validation_rejections`).
307
- const nextActions = buildNextActions(definition, currentRun);
308
- const nextAction = nextActions.find((a) => a.instruction !== null &&
309
- a.instruction.call_with['command'] === stepName) ?? nextActions[0];
310
- // PRISTINE original prompt — never mutated across repair attempts. The prompt actually
311
- // sent to the provider (`promptForAttempt` below) is always derived FRESH from this,
312
- // plus at most the LATEST rejection's feedback — never accumulated, never stale.
313
- const prompt = nextAction?.prompt ?? stepDef.description;
314
- const promptForAttempt = lastRejection !== undefined
315
- ? `${prompt}\n\nYour previous output was rejected by the ${lastRejection.kind} schema validator:\n${lastRejection.summary}\nEmit corrected JSON only, matching the schema exactly.`
316
- : prompt;
317
- // #robust-anthropic-provider Part 1: route the schema the ENGINE validates output against
318
- // (output_schema, execution-loop.ts validateOutputSchema) ahead of the execute_step-param
319
- // schema (input_schema / nextAction.input_schema) the provider was fed until now. Both-
320
- // declared-and-divergent degrades to a clean recoverable VALIDATION_*_SCHEMA error downstream,
321
- // not a parse-strand — see the Part 6 loader warning for the authoring-time signal.
322
- const inputSchema = stepDef.output_schema ??
323
- nextAction?.input_schema ??
324
- stepDef.input_schema;
325
- const agentProfileInstructions = stepDef.agent_profile !== undefined
326
- ? definition.resolved_profiles?.[stepDef.agent_profile]?.content
327
- : undefined;
328
- // issue #236: compute the Phase-B verdict ONCE per attempt-cycle, on the EXACT resolved
329
- // `inputSchema` local above — never re-derived from `stepDef` (design §2, Rv11). Only
330
- // steps that DECLARED structured_output ever get a plan at all — an undeclared step's
331
- // call site below is completely untouched (byte-identical for the non-opted majority).
332
- let structuredOutputPlan;
333
- if (stepDef.structured_output === 'strict') {
334
- const sticky = structuredOutputSticky.get(stepName);
335
- if (sticky !== undefined) {
336
- // A prior attempt for this step already downgraded — never re-attempt strict.
337
- structuredOutputPlan = {
338
- send: false,
339
- ineligibleMeta: { requested: true, sent: false, ...sticky },
340
- };
341
- }
342
- else if (caps.strictGate !== undefined) {
343
- // issue #313: an ENDPOINT-scoped refusal, checked AFTER sticky and BEFORE the
344
- // verdict. Two consequences, both deliberate: the schema is never assessed (its
345
- // eligibility is irrelevant when strict cannot be sent at all), and this arm sits
346
- // structurally outside the sticky-arming path, so a compat endpoint can never arm
347
- // sticky — nothing was attempted, so there is nothing to remember.
348
- structuredOutputPlan = {
349
- send: false,
350
- ineligibleMeta: {
351
- requested: true,
352
- sent: false,
353
- downgrade_reason: caps.strictGate,
354
- },
355
- };
274
+ // --- Step execution ---
275
+ const eligible = findEligibleSteps(definition, currentRun);
276
+ if (eligible.length === 0) {
277
+ // Detect-only wedge surfacing (issue #101): before exiting on "nothing eligible", check
278
+ // whether the run is an after-claim wedge (an in-progress claim that is stale or
279
+ // unknown-age). If so, print the claim state(s) + the exact reclaim remediation so the
280
+ // operator is never silently parked. Phase 1 attach does NOT auto-reclaim.
281
+ if (currentRun.in_progress_steps.length > 0) {
282
+ const wedged = classifyInProgressClaims(currentRun).filter((c) => c.state !== 'healthy');
283
+ if (wedged.length > 0) {
284
+ console.log(`\n ⚠ Run '${runId}' is wedged a claimed step never settled (its runner likely died):`);
285
+ for (const c of wedged) {
286
+ console.log(` • ${c.step}: ${c.state}`);
287
+ console.log(` recover with: realm run reclaim ${runId} --step ${c.step} --force`);
288
+ }
289
+ console.log(` (reclaim re-drives the step; its side effects may repeat — see 'realm run reclaim ${runId}' for a dry-run.)`);
356
290
  }
357
- else {
358
- const verdict = assessStructuredOutputEligibility({
359
- schema: inputSchema,
360
- tools: false,
361
- profile,
362
- });
363
- // issue #313 the remediation nudge. The plan below discards `reasons`, so this
364
- // is the only place they still exist. Printed once per step (`repairsUsed === 0`),
365
- // for ineligible AND caveated verdicts, on stderr: an author who opted into strict
366
- // and silently did not get it is exactly who needs to know why.
367
- if (repairsUsed === 0 && verdict.verdict !== 'eligible') {
368
- const findings = verdict.verdict === 'ineligible' ? verdict.reasons : verdict.caveats;
369
- console.error(` ⚠ Step '${stepName}': structured_output: strict${renderIneligibleMessage(findings)}`);
291
+ }
292
+ break;
293
+ }
294
+ const stepName = eligible[0];
295
+ // issue #401: never cleared. A throw AFTER this step settles mints with this step's name,
296
+ // and conjunct (iv) of the drive_failing predicate then suppresses the finding — because
297
+ // progress DID happen. Chosen, not incidental.
298
+ currentStepName = stepName;
299
+ const stepDef = definition.steps[stepName];
300
+ // issue #401 the clock for THIS step's model requests. The step's own authored key
301
+ // WINS; the CLI flag fills in for a step that never authored one; 600s if neither
302
+ // (the --schema-retries precedent, exactly). `deriveLlmClock` turns the per-ATTEMPT
303
+ // seconds into the whole-create ceiling per-attempt × (retries + 1), plus the SDK's
304
+ // own worst-case backoff sleeping, plus a download allowance for a slow response body.
305
+ //
306
+ // The SOURCE is computed here, once, and travels with the clock. Two disclosures depend
307
+ // on it and nothing else does: whether the recorded `declared_per_attempt_ms` exists at
308
+ // all — a fallback nobody chose is not a declaration, so it is omitted rather than
309
+ // reported as one — and which lever a fired ceiling names, since telling someone to raise
310
+ // a flag their own step key overrides sends them to change something inert.
311
+ const perAttemptSource = stepDef.llm_timeout_seconds !== undefined
312
+ ? 'step'
313
+ : deps.llmTimeoutSeconds !== undefined
314
+ ? 'flag'
315
+ : 'default';
316
+ const derivedClock = deriveLlmClock((stepDef.llm_timeout_seconds ?? fallbackLlmTimeoutSeconds) * 1000);
317
+ const llmClock = {
318
+ ceilingMs: derivedClock.ceilingMs,
319
+ perAttemptSource,
320
+ ...(perAttemptSource !== 'default' && derivedClock.declaredPerAttemptMs !== undefined
321
+ ? { declaredPerAttemptMs: derivedClock.declaredPerAttemptMs }
322
+ : {}),
323
+ };
324
+ // issue #217: the in-drive schema-feedback repair loop. `stepInput`/`toolCallsForMeta`/
325
+ // `result` are re-assigned on every attempt inside the `for` loop below; `repairsUsed`/
326
+ // `lastRejection` persist ACROSS attempts within this one step, and are fresh (0/undefined)
327
+ // for every new step. The loop body is exactly the former single-pass step-execution region
328
+ // (the agent/auto branch bodies + the executeChain call) — an auto step's
329
+ // `execution !== 'agent'` means the repair gate's conjunct (iii) can never hold for it, so
330
+ // it structurally can never iterate more than once: the loop is a no-op wrapper for every
331
+ // pre-existing (non-repair) case.
332
+ let stepInput = {};
333
+ let toolCallsForMeta;
334
+ // issue #236: the resolved structuredOutput meta for THIS attempt's callStep call — reset
335
+ // every iteration alongside toolCallsForMeta, threaded into stepMeta below.
336
+ let structuredOutputMetaForStep;
337
+ let result;
338
+ let repairsUsed = 0;
339
+ let lastRejection;
340
+ for (;;) {
341
+ // issue #401: re-stamped per repair attempt, so `elapsed_ms` measures THIS attempt
342
+ // rather than the whole step.
343
+ attemptStartedAt = Date.now();
344
+ toolCallsForMeta = undefined;
345
+ structuredOutputMetaForStep = undefined;
346
+ // issue #217 conjunct (vi) ground truth — captured FRESH at the top of EVERY attempt
347
+ // (including the first), never once per step: a per-step capture is stale across the
348
+ // whole provider LLM call, so any concurrent writer (a second drive, a gate `respond`, a
349
+ // parallel-step settle) landing during that call would silently forfeit a legitimate
350
+ // repair. See the repair-gate comment below for the full discriminator rationale. Cost:
351
+ // one extra store read per attempt — accepted.
352
+ const versionBeforeAttempt = (await deps.store.get(runId)).version;
353
+ if (stepDef.execution === 'agent') {
354
+ // Resolve template-expanded prompt via buildNextActions so {{ context.resources.* }}
355
+ // references are substituted before the LLM call. Pure w.r.t. `definition`/`currentRun`,
356
+ // both unchanged across repair attempts — a rejected attempt no longer leaves
357
+ // `currentRun` itself stale relative to what's persisted (issue #220: countRejection DOES
358
+ // persist a bounded rejection counter on a counted rejection — "nothing is ever
359
+ // persisted on a rejected attempt" is FALSE as of #220), but `currentRun`/`definition`
360
+ // are still safe to recompute per iteration here regardless, since neither is read from
361
+ // again until the NEXT step (this step's own next_actions/prompt derivation never
362
+ // consults `validation_rejections`).
363
+ const nextActions = buildNextActions(definition, currentRun);
364
+ const nextAction = nextActions.find((a) => a.instruction !== null &&
365
+ a.instruction.call_with['command'] === stepName) ?? nextActions[0];
366
+ // PRISTINE original prompt — never mutated across repair attempts. The prompt actually
367
+ // sent to the provider (`promptForAttempt` below) is always derived FRESH from this,
368
+ // plus at most the LATEST rejection's feedback — never accumulated, never stale.
369
+ const prompt = nextAction?.prompt ?? stepDef.description;
370
+ const promptForAttempt = lastRejection !== undefined
371
+ ? `${prompt}\n\nYour previous output was rejected by the ${lastRejection.kind} schema validator:\n${lastRejection.summary}\nEmit corrected JSON only, matching the schema exactly.`
372
+ : prompt;
373
+ // #robust-anthropic-provider Part 1: route the schema the ENGINE validates output against
374
+ // (output_schema, execution-loop.ts validateOutputSchema) ahead of the execute_step-param
375
+ // schema (input_schema / nextAction.input_schema) the provider was fed until now. Both-
376
+ // declared-and-divergent degrades to a clean recoverable VALIDATION_*_SCHEMA error downstream,
377
+ // not a parse-strand — see the Part 6 loader warning for the authoring-time signal.
378
+ const inputSchema = stepDef.output_schema ??
379
+ nextAction?.input_schema ??
380
+ stepDef.input_schema;
381
+ const agentProfileInstructions = stepDef.agent_profile !== undefined
382
+ ? definition.resolved_profiles?.[stepDef.agent_profile]?.content
383
+ : undefined;
384
+ // issue #236: compute the Phase-B verdict ONCE per attempt-cycle, on the EXACT resolved
385
+ // `inputSchema` local above — never re-derived from `stepDef` (design §2, Rv11). Only
386
+ // steps that DECLARED structured_output ever get a plan at all — an undeclared step's
387
+ // call site below is completely untouched (byte-identical for the non-opted majority).
388
+ let structuredOutputPlan;
389
+ if (stepDef.structured_output === 'strict') {
390
+ const sticky = structuredOutputSticky.get(stepName);
391
+ if (sticky !== undefined) {
392
+ // A prior attempt for this step already downgraded — never re-attempt strict.
393
+ structuredOutputPlan = {
394
+ send: false,
395
+ ineligibleMeta: { requested: true, sent: false, ...sticky },
396
+ };
370
397
  }
371
- if (verdict.verdict === 'ineligible') {
398
+ else if (caps.strictGate !== undefined) {
399
+ // issue #313: an ENDPOINT-scoped refusal, checked AFTER sticky and BEFORE the
400
+ // verdict. Two consequences, both deliberate: the schema is never assessed (its
401
+ // eligibility is irrelevant when strict cannot be sent at all), and this arm sits
402
+ // structurally outside the sticky-arming path, so a compat endpoint can never arm
403
+ // sticky — nothing was attempted, so there is nothing to remember.
372
404
  structuredOutputPlan = {
373
405
  send: false,
374
406
  ineligibleMeta: {
375
407
  requested: true,
376
408
  sent: false,
377
- downgrade_reason: 'gate_ineligible',
409
+ downgrade_reason: caps.strictGate,
378
410
  },
379
411
  };
380
412
  }
381
- else if (verdict.verdict === 'eligible_with_caveats') {
382
- structuredOutputPlan = {
383
- send: true,
384
- caveats: verdict.caveats.map((c) => c.code),
385
- };
386
- }
387
413
  else {
388
- structuredOutputPlan = { send: true };
414
+ const verdict = assessStructuredOutputEligibility({
415
+ schema: inputSchema,
416
+ tools: false,
417
+ profile,
418
+ });
419
+ // issue #313 — the remediation nudge. The plan below discards `reasons`, so this
420
+ // is the only place they still exist. Printed once per step (`repairsUsed === 0`),
421
+ // for ineligible AND caveated verdicts, on stderr: an author who opted into strict
422
+ // and silently did not get it is exactly who needs to know why.
423
+ if (repairsUsed === 0 && verdict.verdict !== 'eligible') {
424
+ const findings = verdict.verdict === 'ineligible' ? verdict.reasons : verdict.caveats;
425
+ console.error(` ⚠ Step '${stepName}': structured_output: strict — ${renderIneligibleMessage(findings)}`);
426
+ }
427
+ if (verdict.verdict === 'ineligible') {
428
+ structuredOutputPlan = {
429
+ send: false,
430
+ ineligibleMeta: {
431
+ requested: true,
432
+ sent: false,
433
+ downgrade_reason: 'gate_ineligible',
434
+ },
435
+ };
436
+ }
437
+ else if (verdict.verdict === 'eligible_with_caveats') {
438
+ structuredOutputPlan = {
439
+ send: true,
440
+ caveats: verdict.caveats.map((c) => c.code),
441
+ };
442
+ }
443
+ else {
444
+ structuredOutputPlan = { send: true };
445
+ }
389
446
  }
390
447
  }
391
- }
392
- if (repairsUsed === 0) {
393
- const descPreview = stepDef.description.slice(0, 80);
394
- console.log(`\n→ [agent] ${stepName}`);
395
- console.log(` ${descPreview}${stepDef.description.length > 80 ? '…' : ''}`);
396
- // issue #220 deliverable 7 drive-time coherence warn: once per step (gated on the
397
- // same `repairsUsed === 0` this banner uses), warn when the repair budget itself
398
- // (schemaRetries + 1 attempts) exceeds the engine's own exhaustion threshold for this
399
- // step operator intent would be silently truncated mid-loop (the drive keeps
400
- // repairing past the point the engine terminalizes the step with VALIDATION_EXHAUSTED).
401
- const exhaustionThreshold = stepDef.validation_exhaustion?.threshold ?? DEFAULT_VALIDATION_EXHAUSTION_THRESHOLD;
402
- if (schemaRetries + 1 > exhaustionThreshold) {
403
- console.error(` --schema-retries ${schemaRetries} (repair budget ${schemaRetries + 1} attempts) ` +
404
- `exceeds step '${stepName}''s validation-exhaustion threshold ` +
405
- `(${exhaustionThreshold}) the engine will terminalize this step before the ` +
406
- `repair loop's own budget is exhausted.`);
407
- }
408
- }
409
- if (stepDef.tools && stepDef.tools.length > 0 && mcpClient) {
410
- // Tools path: build tool definitions, call callStepWithTools. Rebuilt every attempt
411
- // (issue #217) — safe: repair only ever follows a ZERO-toolCall attempt, so no budget
412
- // was spent and nothing can duplicate.
413
- const byServer = new Map();
414
- for (const entry of stepDef.tools) {
415
- const [serverId, toolName] = entry.split(':');
416
- if (!byServer.has(serverId))
417
- byServer.set(serverId, []);
418
- byServer.get(serverId).push(toolName);
448
+ if (repairsUsed === 0) {
449
+ const descPreview = stepDef.description.slice(0, 80);
450
+ console.log(`\n→ [agent] ${stepName}`);
451
+ console.log(` ${descPreview}${stepDef.description.length > 80 ? '…' : ''}`);
452
+ // issue #220 deliverable 7 — drive-time coherence warn: once per step (gated on the
453
+ // same `repairsUsed === 0` this banner uses), warn when the repair budget itself
454
+ // (schemaRetries + 1 attempts) exceeds the engine's own exhaustion threshold for this
455
+ // step operator intent would be silently truncated mid-loop (the drive keeps
456
+ // repairing past the point the engine terminalizes the step with VALIDATION_EXHAUSTED).
457
+ const exhaustionThreshold = stepDef.validation_exhaustion?.threshold ?? DEFAULT_VALIDATION_EXHAUSTION_THRESHOLD;
458
+ if (schemaRetries + 1 > exhaustionThreshold) {
459
+ console.error(` ⚠ --schema-retries ${schemaRetries} (repair budget ${schemaRetries + 1} attempts) ` +
460
+ `exceeds step '${stepName}''s validation-exhaustion threshold ` +
461
+ `(${exhaustionThreshold}) the engine will terminalize this step before the ` +
462
+ `repair loop's own budget is exhausted.`);
463
+ }
419
464
  }
420
- let toolsResult;
421
- // issue #311: hoisted so the evidence assembly below the try/catch can read them.
422
- const toolArgsEntries = [];
423
- try {
424
- const toolDefs = [];
425
- const barenameOwner = new Map(); // bareName → serverId of first registration
426
- for (const [serverId, allowList] of byServer) {
427
- const mcpTools = await mcpClient.getTools(serverId, allowList);
428
- const returnedNames = new Set(mcpTools.map((t) => t.name));
429
- for (const name of allowList) {
430
- if (!returnedNames.has(name)) {
431
- throw new WorkflowError(`Step '${stepName}' declares tool '${serverId}:${name}' which is not exposed by MCP server '${serverId}'. ` +
432
- `Check the tool name against the server's published tool list.`, {
433
- code: 'MCP_TOOL_NOT_FOUND',
434
- category: 'ENGINE',
435
- agentAction: 'stop',
436
- retryable: false,
465
+ if (stepDef.tools && stepDef.tools.length > 0 && mcpClient) {
466
+ // Tools path: build tool definitions, call callStepWithTools. Rebuilt every attempt
467
+ // (issue #217) — safe: repair only ever follows a ZERO-toolCall attempt, so no budget
468
+ // was spent and nothing can duplicate.
469
+ const byServer = new Map();
470
+ for (const entry of stepDef.tools) {
471
+ const [serverId, toolName] = entry.split(':');
472
+ if (!byServer.has(serverId))
473
+ byServer.set(serverId, []);
474
+ byServer.get(serverId).push(toolName);
475
+ }
476
+ let toolsResult;
477
+ // issue #311: hoisted so the evidence assembly below the try/catch can read them.
478
+ const toolArgsEntries = [];
479
+ try {
480
+ const toolDefs = [];
481
+ const barenameOwner = new Map(); // bareName → serverId of first registration
482
+ for (const [serverId, allowList] of byServer) {
483
+ const mcpTools = await mcpClient.getTools(serverId, allowList);
484
+ const returnedNames = new Set(mcpTools.map((t) => t.name));
485
+ for (const name of allowList) {
486
+ if (!returnedNames.has(name)) {
487
+ throw new WorkflowError(`Step '${stepName}' declares tool '${serverId}:${name}' which is not exposed by MCP server '${serverId}'. ` +
488
+ `Check the tool name against the server's published tool list.`, {
489
+ code: 'MCP_TOOL_NOT_FOUND',
490
+ category: 'ENGINE',
491
+ agentAction: 'stop',
492
+ retryable: false,
493
+ });
494
+ }
495
+ }
496
+ for (const mcpTool of mcpTools) {
497
+ const firstOwner = barenameOwner.get(mcpTool.name);
498
+ if (firstOwner !== undefined) {
499
+ throw new WorkflowError(`Tool name collision in step '${stepName}': '${mcpTool.name}' is exposed by both '${firstOwner}' and '${serverId}'. ` +
500
+ `Tool names must be unique across all connected servers within a step.`, {
501
+ code: 'MCP_TOOL_NAME_COLLISION',
502
+ category: 'ENGINE',
503
+ agentAction: 'stop',
504
+ retryable: false,
505
+ });
506
+ }
507
+ barenameOwner.set(mcpTool.name, serverId);
508
+ toolDefs.push({
509
+ id: `${serverId}:${mcpTool.name}`,
510
+ serverId,
511
+ name: mcpTool.name,
512
+ description: mcpTool.description,
513
+ inputSchema: mcpTool.inputSchema,
437
514
  });
438
515
  }
439
516
  }
440
- for (const mcpTool of mcpTools) {
441
- const firstOwner = barenameOwner.get(mcpTool.name);
442
- if (firstOwner !== undefined) {
443
- throw new WorkflowError(`Tool name collision in step '${stepName}': '${mcpTool.name}' is exposed by both '${firstOwner}' and '${serverId}'. ` +
444
- `Tool names must be unique across all connected servers within a step.`, {
445
- code: 'MCP_TOOL_NAME_COLLISION',
446
- category: 'ENGINE',
447
- agentAction: 'stop',
448
- retryable: false,
517
+ // ---------------------------------------------------------------------------
518
+ // issue #311 — per-tool strict selection. Gated on TWO things:
519
+ // 1. the step's own strict declaration — a step that never opted in takes none of
520
+ // this, so its `toolDefs` array (contents AND order) reaches the provider
521
+ // byte-identical to pre-#311; and
522
+ // 2. the PROVIDER's `toolArgsStrict` capability. Marking tools is only
523
+ // meaningful for a provider that actually reads `ToolDefinition.strict` and
524
+ // threads it onto the request. BOTH in-repo tool-capable providers now do
525
+ // (Anthropic on the tool object, OpenAI inside `function`); a third-party
526
+ // `--provider-module` that does not would otherwise get `strict_sent: true`
527
+ // evidence against a wire carrying nothing — the falsity this guard exists to
528
+ // prevent. Conservative by default: an absent capability reads as false, so
529
+ // such modules are safe without declaring anything, and the conformance suite
530
+ // holds each declarer to actually placing it on the wire.
531
+ // ---------------------------------------------------------------------------
532
+ if (stepDef.structured_output === 'strict' && !strictCapable) {
533
+ // The provider cannot consume the marker. Do NOT re-sort (the wire keeps its
534
+ // as-built server order — the pre-#311 shape for these providers), do NOT mark,
535
+ // and deliberately do NOT run the eligibility walk: those verdicts encode the
536
+ // ANTHROPIC strict profile, so reporting their reasons/caveats for a provider
537
+ // that could never send strict anyway would be misleading precision.
538
+ //
539
+ // Evidence still lands, and it is the honest version: one entry per DECLARED
540
+ // tool, in DECLARED order (the run-record contract), each saying plainly that
541
+ // strict was requested and not sent because this provider does not support it.
542
+ // The entries are built from a SORTED COPY — the wire array itself must stay in
543
+ // as-built order, so entry order and wire order legitimately differ here.
544
+ const declaredIndex = new Map(stepDef.tools.map((id, i) => [id, i]));
545
+ const declaredOrder = [...toolDefs].sort((a, b) => (declaredIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
546
+ (declaredIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER));
547
+ for (const tool of declaredOrder) {
548
+ toolArgsEntries.push({
549
+ name: tool.name,
550
+ strict_requested: true,
551
+ strict_sent: false,
552
+ reasons: ['provider_unsupported'],
449
553
  });
450
554
  }
451
- barenameOwner.set(mcpTool.name, serverId);
452
- toolDefs.push({
453
- id: `${serverId}:${mcpTool.name}`,
454
- serverId,
455
- name: mcpTool.name,
456
- description: mcpTool.description,
457
- inputSchema: mcpTool.inputSchema,
458
- });
459
- }
460
- }
461
- // ---------------------------------------------------------------------------
462
- // issue #311 — per-tool strict selection. Gated on TWO things:
463
- // 1. the step's own strict declaration — a step that never opted in takes none of
464
- // this, so its `toolDefs` array (contents AND order) reaches the provider
465
- // byte-identical to pre-#311; and
466
- // 2. the PROVIDER's `toolArgsStrict` capability. Marking tools is only
467
- // meaningful for a provider that actually reads `ToolDefinition.strict` and
468
- // threads it onto the request. BOTH in-repo tool-capable providers now do
469
- // (Anthropic on the tool object, OpenAI inside `function`); a third-party
470
- // `--provider-module` that does not would otherwise get `strict_sent: true`
471
- // evidence against a wire carrying nothing — the falsity this guard exists to
472
- // prevent. Conservative by default: an absent capability reads as false, so
473
- // such modules are safe without declaring anything, and the conformance suite
474
- // holds each declarer to actually placing it on the wire.
475
- // ---------------------------------------------------------------------------
476
- if (stepDef.structured_output === 'strict' && !strictCapable) {
477
- // The provider cannot consume the marker. Do NOT re-sort (the wire keeps its
478
- // as-built server order — the pre-#311 shape for these providers), do NOT mark,
479
- // and deliberately do NOT run the eligibility walk: those verdicts encode the
480
- // ANTHROPIC strict profile, so reporting their reasons/caveats for a provider
481
- // that could never send strict anyway would be misleading precision.
482
- //
483
- // Evidence still lands, and it is the honest version: one entry per DECLARED
484
- // tool, in DECLARED order (the run-record contract), each saying plainly that
485
- // strict was requested and not sent because this provider does not support it.
486
- // The entries are built from a SORTED COPY — the wire array itself must stay in
487
- // as-built order, so entry order and wire order legitimately differ here.
488
- const declaredIndex = new Map(stepDef.tools.map((id, i) => [id, i]));
489
- const declaredOrder = [...toolDefs].sort((a, b) => (declaredIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
490
- (declaredIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER));
491
- for (const tool of declaredOrder) {
492
- toolArgsEntries.push({
493
- name: tool.name,
494
- strict_requested: true,
495
- strict_sent: false,
496
- reasons: ['provider_unsupported'],
497
- });
498
555
  }
499
- }
500
- else if (stepDef.structured_output === 'strict' && gate !== undefined) {
501
- // issue #313 the ENDPOINT gate, on the tools dimension. Ordered AFTER the
502
- // capability arm on purpose (D-3 precedence): a provider that cannot consume the
503
- // marker reports `provider_unsupported` even when it also carries a gate, because
504
- // "this provider never sends strict" is the more fundamental fact and the two
505
- // literals must never conflate.
506
- //
507
- // Same shape as the arm above declared-order entries from a sorted copy, wire
508
- // untouched, no walk because the reason is likewise endpoint-level, not
509
- // per-schema: assessing tools here would report eligibility findings about
510
- // schemas that were never going to be sent strict at all.
511
- const declaredIndex = new Map(stepDef.tools.map((id, i) => [id, i]));
512
- const declaredOrder = [...toolDefs].sort((a, b) => (declaredIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
513
- (declaredIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER));
514
- for (const tool of declaredOrder) {
515
- toolArgsEntries.push({
516
- name: tool.name,
517
- strict_requested: true,
518
- strict_sent: false,
519
- reasons: ['compat_endpoint'],
520
- });
521
- }
522
- }
523
- else if (stepDef.structured_output === 'strict') {
524
- // Re-sort into the author's DECLARED order. The assembly above walks server by
525
- // server, so the wire order otherwise depends on MCP server grouping and each
526
- // server's own listing order — neither of which the author controls. The budget
527
- // walk below is order-sensitive (it is greedy), so "which tools got strict" must
528
- // be a function of something the author can see and reorder: their own list.
529
- const declaredIndex = new Map(stepDef.tools.map((id, i) => [id, i]));
530
- toolDefs.sort((a, b) => (declaredIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
531
- (declaredIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER));
532
- const sticky = toolArgsSticky.get(stepName);
533
- // The API's own per-request limits. NOTE: the 20 here is the strict-tool cap and
534
- // has nothing to do with `maxToolCalls`'s unrelated default of 20 — never conflate.
535
- const MAX_STRICT_TOOLS = 20;
536
- const MAX_SUMMED_OPTIONALS = 24;
537
- let strictCount = 0;
538
- let optionalSum = 0;
539
- for (const tool of toolDefs) {
540
- const verdict = assessStructuredOutputEligibility({
541
- schema: tool.inputSchema,
542
- tools: false,
543
- subject: 'tool_args',
544
- profile,
545
- });
546
- const caveats = verdict.verdict === 'ineligible'
547
- ? (verdict.caveats ?? []).map((c) => c.code)
548
- : verdict.verdict === 'eligible_with_caveats'
549
- ? verdict.caveats.map((c) => c.code)
550
- : [];
551
- const entry = {
552
- name: tool.name,
553
- strict_requested: true,
554
- strict_sent: false,
555
- ...(caveats.length > 0 ? { caveats } : {}),
556
- };
557
- if (verdict.verdict === 'ineligible') {
558
- // Ineligible tools consume ZERO budget: the API's 24-optional sum spans the
559
- // schemas strict is actually ATTACHED to, so charging a tool that never gets
560
- // strict would starve later, eligible tools for no reason.
561
- entry.reasons = verdict.reasons.map((r) => r.code);
562
- }
563
- else if (sticky !== undefined) {
564
- // A previous attempt for this step took a live 400. Re-sending the same
565
- // schemas would earn the same rejection, so this attempt starts unconstrained
566
- // and every eligible tool reports the ORIGINAL reason verbatim.
567
- entry.reasons = [sticky.reason];
568
- }
569
- else if (profile === 'openai') {
570
- // issue #313: NO budget walk under the OpenAI profile. Anthropic publishes a
571
- // 20-strict-tool and 24-summed-optional per-request budget; OpenAI publishes
572
- // NEITHER (executed: 128 strict tools in one request ⇒ 200, and the only
573
- // ceiling found is the generic 128-element tools ARRAY cap, which authoring
574
- // hits long before this walk would). So every eligible tool is marked, and
575
- // `budget_excluded` is never minted under this profile — inventing a budget
576
- // here would withhold strict for a limit that does not exist.
577
- tool.strict = true;
578
- entry.strict_sent = true;
556
+ else if (stepDef.structured_output === 'strict' && gate !== undefined) {
557
+ // issue #313 the ENDPOINT gate, on the tools dimension. Ordered AFTER the
558
+ // capability arm on purpose (D-3 precedence): a provider that cannot consume the
559
+ // marker reports `provider_unsupported` even when it also carries a gate, because
560
+ // "this provider never sends strict" is the more fundamental fact and the two
561
+ // literals must never conflate.
562
+ //
563
+ // Same shape as the arm above — declared-order entries from a sorted copy, wire
564
+ // untouched, no walkbecause the reason is likewise endpoint-level, not
565
+ // per-schema: assessing tools here would report eligibility findings about
566
+ // schemas that were never going to be sent strict at all.
567
+ const declaredIndex = new Map(stepDef.tools.map((id, i) => [id, i]));
568
+ const declaredOrder = [...toolDefs].sort((a, b) => (declaredIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
569
+ (declaredIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER));
570
+ for (const tool of declaredOrder) {
571
+ toolArgsEntries.push({
572
+ name: tool.name,
573
+ strict_requested: true,
574
+ strict_sent: false,
575
+ reasons: ['compat_endpoint'],
576
+ });
579
577
  }
580
- else {
581
- // Greedy-skip in declared order, INCLUSIVE boundaries (landing exactly on a
582
- // limit fits). A tool that doesn't fit is SKIPPED and the walk CONTINUES
583
- // stopping at the first miss would let one fat schema disable strict for
584
- // every tool behind it.
585
- const optionals = verdict.optional_count ?? 0;
586
- if (strictCount + 1 <= MAX_STRICT_TOOLS &&
587
- optionalSum + optionals <= MAX_SUMMED_OPTIONALS) {
578
+ }
579
+ else if (stepDef.structured_output === 'strict') {
580
+ // Re-sort into the author's DECLARED order. The assembly above walks server by
581
+ // server, so the wire order otherwise depends on MCP server grouping and each
582
+ // server's own listing order — neither of which the author controls. The budget
583
+ // walk below is order-sensitive (it is greedy), so "which tools got strict" must
584
+ // be a function of something the author can see and reorder: their own list.
585
+ const declaredIndex = new Map(stepDef.tools.map((id, i) => [id, i]));
586
+ toolDefs.sort((a, b) => (declaredIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
587
+ (declaredIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER));
588
+ const sticky = toolArgsSticky.get(stepName);
589
+ // The API's own per-request limits. NOTE: the 20 here is the strict-tool cap and
590
+ // has nothing to do with `maxToolCalls`'s unrelated default of 20 — never conflate.
591
+ const MAX_STRICT_TOOLS = 20;
592
+ const MAX_SUMMED_OPTIONALS = 24;
593
+ let strictCount = 0;
594
+ let optionalSum = 0;
595
+ for (const tool of toolDefs) {
596
+ const verdict = assessStructuredOutputEligibility({
597
+ schema: tool.inputSchema,
598
+ tools: false,
599
+ subject: 'tool_args',
600
+ profile,
601
+ });
602
+ const caveats = verdict.verdict === 'ineligible'
603
+ ? (verdict.caveats ?? []).map((c) => c.code)
604
+ : verdict.verdict === 'eligible_with_caveats'
605
+ ? verdict.caveats.map((c) => c.code)
606
+ : [];
607
+ const entry = {
608
+ name: tool.name,
609
+ strict_requested: true,
610
+ strict_sent: false,
611
+ ...(caveats.length > 0 ? { caveats } : {}),
612
+ };
613
+ if (verdict.verdict === 'ineligible') {
614
+ // Ineligible tools consume ZERO budget: the API's 24-optional sum spans the
615
+ // schemas strict is actually ATTACHED to, so charging a tool that never gets
616
+ // strict would starve later, eligible tools for no reason.
617
+ entry.reasons = verdict.reasons.map((r) => r.code);
618
+ }
619
+ else if (sticky !== undefined) {
620
+ // A previous attempt for this step took a live 400. Re-sending the same
621
+ // schemas would earn the same rejection, so this attempt starts unconstrained
622
+ // and every eligible tool reports the ORIGINAL reason verbatim.
623
+ entry.reasons = [sticky.reason];
624
+ }
625
+ else if (profile === 'openai') {
626
+ // issue #313: NO budget walk under the OpenAI profile. Anthropic publishes a
627
+ // 20-strict-tool and 24-summed-optional per-request budget; OpenAI publishes
628
+ // NEITHER (executed: 128 strict tools in one request ⇒ 200, and the only
629
+ // ceiling found is the generic 128-element tools ARRAY cap, which authoring
630
+ // hits long before this walk would). So every eligible tool is marked, and
631
+ // `budget_excluded` is never minted under this profile — inventing a budget
632
+ // here would withhold strict for a limit that does not exist.
588
633
  tool.strict = true;
589
634
  entry.strict_sent = true;
590
- strictCount += 1;
591
- optionalSum += optionals;
592
635
  }
593
636
  else {
594
- entry.reasons = ['budget_excluded'];
637
+ // Greedy-skip in declared order, INCLUSIVE boundaries (landing exactly on a
638
+ // limit fits). A tool that doesn't fit is SKIPPED and the walk CONTINUES —
639
+ // stopping at the first miss would let one fat schema disable strict for
640
+ // every tool behind it.
641
+ const optionals = verdict.optional_count ?? 0;
642
+ if (strictCount + 1 <= MAX_STRICT_TOOLS &&
643
+ optionalSum + optionals <= MAX_SUMMED_OPTIONALS) {
644
+ tool.strict = true;
645
+ entry.strict_sent = true;
646
+ strictCount += 1;
647
+ optionalSum += optionals;
648
+ }
649
+ else {
650
+ entry.reasons = ['budget_excluded'];
651
+ }
595
652
  }
653
+ toolArgsEntries.push(entry);
596
654
  }
597
- toolArgsEntries.push(entry);
598
655
  }
599
- }
600
- const baseExecutor = async (namespacedName, args) => {
601
- const [serverId, toolName] = namespacedName.split(':');
602
- return mcpClient.call(serverId, toolName, args);
603
- };
604
- // Wrap the executor to enforce max_fan_out when set.
605
- // Counts calls to start_run and start_run_batch (regardless of server prefix).
606
- let fanOutCallCount = 0;
607
- const maxFanOut = stepDef.max_fan_out;
608
- const executor = async (namespacedName, args) => {
609
- const toolName = namespacedName.includes(':')
610
- ? namespacedName.split(':')[1]
611
- : namespacedName;
612
- if (toolName === 'start_run' || toolName === 'start_run_batch') {
613
- fanOutCallCount += 1;
614
- if (maxFanOut !== undefined && fanOutCallCount > maxFanOut) {
615
- throw new WorkflowError(`max_fan_out of ${maxFanOut} reached for step '${stepName}'. ` +
616
- `No further start_run or start_run_batch calls are permitted in this step.`, {
617
- code: 'VALIDATION_BATCH_TOO_LARGE',
618
- category: 'VALIDATION',
619
- agentAction: 'provide_input',
620
- retryable: false,
621
- });
656
+ const baseExecutor = async (namespacedName, args) => {
657
+ const [serverId, toolName] = namespacedName.split(':');
658
+ return mcpClient.call(serverId, toolName, args);
659
+ };
660
+ // Wrap the executor to enforce max_fan_out when set.
661
+ // Counts calls to start_run and start_run_batch (regardless of server prefix).
662
+ let fanOutCallCount = 0;
663
+ const maxFanOut = stepDef.max_fan_out;
664
+ const executor = async (namespacedName, args) => {
665
+ const toolName = namespacedName.includes(':')
666
+ ? namespacedName.split(':')[1]
667
+ : namespacedName;
668
+ if (toolName === 'start_run' || toolName === 'start_run_batch') {
669
+ fanOutCallCount += 1;
670
+ if (maxFanOut !== undefined && fanOutCallCount > maxFanOut) {
671
+ throw new WorkflowError(`max_fan_out of ${maxFanOut} reached for step '${stepName}'. ` +
672
+ `No further start_run or start_run_batch calls are permitted in this step.`, {
673
+ code: 'VALIDATION_BATCH_TOO_LARGE',
674
+ category: 'VALIDATION',
675
+ agentAction: 'provide_input',
676
+ retryable: false,
677
+ });
678
+ }
622
679
  }
680
+ return baseExecutor(namespacedName, args);
681
+ };
682
+ if (!isToolCapable(deps.provider)) {
683
+ throw new Error('invariant: provider lost tool capability between startup and step execution');
623
684
  }
624
- return baseExecutor(namespacedName, args);
625
- };
626
- if (!isToolCapable(deps.provider)) {
627
- throw new Error('invariant: provider lost tool capability between startup and step execution');
685
+ // #robust-anthropic-provider Part 1: same output-over-input precedence as the callStep
686
+ // path above. This EFFECTIVE-OUTPUT schema still feeds ONLY the submit tool + system
687
+ // prompt (unchanged) — issue #224 [gate] SCHEMA-ROUTING PIN: do NOT repoint it at the
688
+ // newly-separated raw schemas below.
689
+ const toolsEffectiveOutputSchema = stepDef.output_schema ??
690
+ stepDef.input_schema;
691
+ toolsResult = await deps.provider.callStepWithTools(promptForAttempt, toolDefs, executor, {
692
+ llmClock,
693
+ ...(toolsEffectiveOutputSchema !== undefined
694
+ ? { inputSchema: toolsEffectiveOutputSchema }
695
+ : {}),
696
+ // issue #224 (D2): the RAW schemas, separate from the effective-output schema
697
+ // above — consumed ONLY by the in-conversation validateAgentSubmission
698
+ // correction loop (never the submit tool / system prompt).
699
+ ...(stepDef.input_schema !== undefined
700
+ ? {
701
+ validationInputSchema: stepDef.input_schema,
702
+ }
703
+ : {}),
704
+ ...(stepDef.output_schema !== undefined
705
+ ? {
706
+ validationOutputSchema: stepDef.output_schema,
707
+ }
708
+ : {}),
709
+ maxToolCalls: stepDef.max_tool_calls ?? 20,
710
+ ...(stepDef.max_fan_out !== undefined
711
+ ? { maxFanOut: stepDef.max_fan_out }
712
+ : {}),
713
+ toolTimeoutMs: (stepDef.tool_timeout ?? 30) * 1000,
714
+ ...(agentProfileInstructions !== undefined ? { agentProfileInstructions } : {}),
715
+ });
628
716
  }
629
- // #robust-anthropic-provider Part 1: same output-over-input precedence as the callStep
630
- // path above. This EFFECTIVE-OUTPUT schema still feeds ONLY the submit tool + system
631
- // prompt (unchanged) — issue #224 [gate] SCHEMA-ROUTING PIN: do NOT repoint it at the
632
- // newly-separated raw schemas below.
633
- const toolsEffectiveOutputSchema = stepDef.output_schema ??
634
- stepDef.input_schema;
635
- toolsResult = await deps.provider.callStepWithTools(promptForAttempt, toolDefs, executor, {
636
- ...(toolsEffectiveOutputSchema !== undefined
637
- ? { inputSchema: toolsEffectiveOutputSchema }
638
- : {}),
639
- // issue #224 (D2): the RAW schemas, separate from the effective-output schema
640
- // aboveconsumed ONLY by the in-conversation validateAgentSubmission
641
- // correction loop (never the submit tool / system prompt).
642
- ...(stepDef.input_schema !== undefined
643
- ? {
644
- validationInputSchema: stepDef.input_schema,
645
- }
646
- : {}),
647
- ...(stepDef.output_schema !== undefined
648
- ? {
649
- validationOutputSchema: stepDef.output_schema,
717
+ catch (err) {
718
+ console.error(`\n✗ Step '${stepName}' (tools) failed: ${safeErrorText(err)}`);
719
+ // issue #401, CHOKEPOINT (1): recorded AFTER the original line, never instead of
720
+ // it. Returns rather than throws, which is what makes double-minting structurally
721
+ // impossible the last-resort catch below never sees this path.
722
+ await recordDriveFailure(deps.store, runId, buildEntry(err, stepName, providerForEvidence ?? 'unknown', attemptStartedAt));
723
+ return 'failed';
724
+ }
725
+ stepInput = toolsResult.output;
726
+ toolCallsForMeta = toolsResult.toolCalls;
727
+ // issue #332 item 6: the tools path never consumes structuredOutputPlan (there is no
728
+ // grammar-constrained call here callStepWithTools has no strict concept at all), so
729
+ // a strict-DECLARED, tools-bearing step used to leave structuredOutputMetaForStep
730
+ // undefined — falling through to execution-loop.ts's synthesized `external_agent`
731
+ // stamp, which claims "realm made no request at all". That's a misattribution: realm's
732
+ // OWN agent DID drive this step, via the tools path, which structurally cannot honor
733
+ // strict. Mint the honest, distinct reason here instead of letting the engine
734
+ // synthesize the wrong one.
735
+ //
736
+ // NOT a ladder outcome — deliberately does NOT touch structuredOutputSticky. Sticky
737
+ // exists to remember a LIVE downgrade across repair-loop attempts for the SAME step
738
+ // (armed only at the live-downgrade sites below, :698-702/:736-740 in the non-tools
739
+ // branch); this is a per-step STRUCTURAL fact (declares `tools`) that is identical on
740
+ // every attempt and needs no memory across attempts.
741
+ if (stepDef.structured_output === 'strict') {
742
+ structuredOutputMetaForStep = {
743
+ requested: true,
744
+ sent: false,
745
+ downgrade_reason: 'unsupported_context_tools',
746
+ };
747
+ }
748
+ // issue #311 — the TOOL-ARGUMENTS evidence block. COEXISTS with the #332 mint above,
749
+ // which is left exactly as it was: that mint states the OUTPUT-dimension truth (this
750
+ // step's own answer was not grammar-constrained), and it stays true no matter how many
751
+ // tools carried strict. This block adds the independent per-tool story.
752
+ if (stepDef.structured_output === 'strict' && toolArgsEntries.length > 0) {
753
+ // The drop machinery is capability-gated too. On the capability-false arm strict was
754
+ // never attached, so there is nothing to drop: a (misbehaving or future) provider
755
+ // reporting one must not be able to mint a `dropped_mid_attempt` record, flip any
756
+ // entry, or arm the sticky map — the run-record contract says that record is absent
757
+ // on an attempt that never attached strict.
758
+ // issue #313 extends the #350 conjunct with the gate: on a gated endpoint strict
759
+ // was never attached either, so a reported drop must not mint a record here.
760
+ const drop = strictCapable && gate === undefined ? toolsResult.toolArgsStrictDrop : undefined;
761
+ if (drop !== undefined) {
762
+ // DROP TRUTH: flip ONLY the entries strict was actually attached to. An entry that
763
+ // was ineligible or budget-excluded never carried strict, so the drop says nothing
764
+ // about it — overwriting its reasons here would erase why it was really skipped.
765
+ for (const entry of toolArgsEntries) {
766
+ if (!entry.strict_sent)
767
+ continue;
768
+ entry.strict_sent = false; // `strict_sent` = FINAL posture of the attempt
769
+ entry.reasons = [drop.reason];
650
770
  }
651
- : {}),
652
- maxToolCalls: stepDef.max_tool_calls ?? 20,
653
- ...(stepDef.max_fan_out !== undefined ? { maxFanOut: stepDef.max_fan_out } : {}),
654
- toolTimeoutMs: (stepDef.tool_timeout ?? 30) * 1000,
655
- ...(agentProfileInstructions !== undefined ? { agentProfileInstructions } : {}),
656
- });
657
- }
658
- catch (err) {
659
- console.error(`\n✗ Step '${stepName}' (tools) failed: ${err instanceof Error ? err.message : String(err)}`);
660
- return 'failed';
661
- }
662
- stepInput = toolsResult.output;
663
- toolCallsForMeta = toolsResult.toolCalls;
664
- // issue #332 item 6: the tools path never consumes structuredOutputPlan (there is no
665
- // grammar-constrained call here — callStepWithTools has no strict concept at all), so
666
- // a strict-DECLARED, tools-bearing step used to leave structuredOutputMetaForStep
667
- // undefined — falling through to execution-loop.ts's synthesized `external_agent`
668
- // stamp, which claims "realm made no request at all". That's a misattribution: realm's
669
- // OWN agent DID drive this step, via the tools path, which structurally cannot honor
670
- // strict. Mint the honest, distinct reason here instead of letting the engine
671
- // synthesize the wrong one.
672
- //
673
- // NOT a ladder outcome — deliberately does NOT touch structuredOutputSticky. Sticky
674
- // exists to remember a LIVE downgrade across repair-loop attempts for the SAME step
675
- // (armed only at the live-downgrade sites below, :698-702/:736-740 in the non-tools
676
- // branch); this is a per-step STRUCTURAL fact (declares `tools`) that is identical on
677
- // every attempt and needs no memory across attempts.
678
- if (stepDef.structured_output === 'strict') {
679
- structuredOutputMetaForStep = {
680
- requested: true,
681
- sent: false,
682
- downgrade_reason: 'unsupported_context_tools',
683
- };
684
- }
685
- // issue #311 — the TOOL-ARGUMENTS evidence block. COEXISTS with the #332 mint above,
686
- // which is left exactly as it was: that mint states the OUTPUT-dimension truth (this
687
- // step's own answer was not grammar-constrained), and it stays true no matter how many
688
- // tools carried strict. This block adds the independent per-tool story.
689
- if (stepDef.structured_output === 'strict' && toolArgsEntries.length > 0) {
690
- // The drop machinery is capability-gated too. On the capability-false arm strict was
691
- // never attached, so there is nothing to drop: a (misbehaving or future) provider
692
- // reporting one must not be able to mint a `dropped_mid_attempt` record, flip any
693
- // entry, or arm the sticky map — the run-record contract says that record is absent
694
- // on an attempt that never attached strict.
695
- // issue #313 extends the #350 conjunct with the gate: on a gated endpoint strict
696
- // was never attached either, so a reported drop must not mint a record here.
697
- const drop = strictCapable && gate === undefined ? toolsResult.toolArgsStrictDrop : undefined;
698
- if (drop !== undefined) {
699
- // DROP TRUTH: flip ONLY the entries strict was actually attached to. An entry that
700
- // was ineligible or budget-excluded never carried strict, so the drop says nothing
701
- // about it — overwriting its reasons here would erase why it was really skipped.
702
- for (const entry of toolArgsEntries) {
703
- if (!entry.strict_sent)
704
- continue;
705
- entry.strict_sent = false; // `strict_sent` = FINAL posture of the attempt
706
- entry.reasons = [drop.reason];
707
771
  }
708
- }
709
- structuredOutputMetaForStep = {
710
- ...structuredOutputMetaForStep,
711
- requested: true,
712
- tool_args: {
713
- tools: toolArgsEntries,
714
- // PER-ATTEMPT, and absent on a sticky attempt by construction: a sticky attempt
715
- // never attaches strict, so the provider has nothing to drop and reports no
716
- // drop. `api_message` lives here, never on the step-level meta.
717
- ...(drop !== undefined ? { dropped_mid_attempt: drop } : {}),
718
- },
719
- };
720
- // Arm the tool-args sticky on a 400 ONLY (see the map's own comment for why a 503
721
- // deliberately does not arm it).
722
- if (drop?.reason === 'api_rejected_schema' && !toolArgsSticky.has(stepName)) {
723
- toolArgsSticky.set(stepName, {
724
- reason: drop.reason,
725
- ...(drop.api_message !== undefined ? { api_message: drop.api_message } : {}),
726
- });
772
+ structuredOutputMetaForStep = {
773
+ ...structuredOutputMetaForStep,
774
+ requested: true,
775
+ tool_args: {
776
+ tools: toolArgsEntries,
777
+ // PER-ATTEMPT, and absent on a sticky attempt by construction: a sticky attempt
778
+ // never attaches strict, so the provider has nothing to drop and reports no
779
+ // drop. `api_message` lives here, never on the step-level meta.
780
+ ...(drop !== undefined ? { dropped_mid_attempt: drop } : {}),
781
+ },
782
+ };
783
+ // Arm the tool-args sticky on a 400 ONLY (see the map's own comment for why a 503
784
+ // deliberately does not arm it).
785
+ if (drop?.reason === 'api_rejected_schema' && !toolArgsSticky.has(stepName)) {
786
+ toolArgsSticky.set(stepName, {
787
+ reason: drop.reason,
788
+ ...(drop.api_message !== undefined ? { api_message: drop.api_message } : {}),
789
+ });
790
+ }
727
791
  }
728
792
  }
729
- }
730
- else {
731
- // Retry the LLM call once on failure before giving up.
732
- let callError;
733
- stepInput = {};
734
- for (let attempt = 0; attempt < 2; attempt++) {
793
+ else {
794
+ // issue #401: the outer two-attempt retry loop is RETIRED. It silently rescued
795
+ // transient failures by calling again which is exactly why a failing drive left
796
+ // no trace: the first failure was swallowed and the second one exited. A single
797
+ // attempt now, with the failure RECORDED; re-attaching is the retry.
735
798
  try {
736
799
  if (structuredOutputPlan !== undefined) {
737
800
  // issue #236: the declared-step path — always call callStepWithMeta so the
738
801
  // synthesis rule (design §5 [R2-3]) can distinguish a genuinely-absent meta
739
802
  // (third-party provider ⇒ provider_unsupported) from a gate/sticky decision
740
803
  // that never even attempted a call.
741
- const { output, meta } = await deps.provider.callStepWithMeta(promptForAttempt, inputSchema, agentProfileInstructions, { structuredOutputStrict: structuredOutputPlan.send });
804
+ const { output, meta } = await deps.provider.callStepWithMeta(promptForAttempt, inputSchema, agentProfileInstructions, { structuredOutputStrict: structuredOutputPlan.send, llmClock });
742
805
  stepInput = output;
743
806
  if (structuredOutputPlan.ineligibleMeta !== undefined) {
744
807
  // Gate-ineligible or sticky — strict was never attempted this call at all.
@@ -776,182 +839,214 @@ export async function runAgent(deps, options) {
776
839
  }
777
840
  }
778
841
  else {
779
- stepInput = await deps.provider.callStep(promptForAttempt, inputSchema, agentProfileInstructions);
842
+ stepInput = await deps.provider.callStep(promptForAttempt, inputSchema, agentProfileInstructions, { llmClock });
780
843
  }
781
- callError = undefined;
782
- break;
783
844
  }
784
845
  catch (err) {
785
- callError = err;
786
- // issue #236 [R2-7]: a ladder failure still carries a structuredOutput meta on
787
- // the thrown error arm sticky from it too, so a subsequent repair/retry never
788
- // re-attempts strict even though this attempt produced no result at all.
789
- const failedMeta = err
790
- ?.structuredOutput;
791
- if (failedMeta !== undefined &&
792
- failedMeta.downgrade_reason !== undefined &&
793
- failedMeta.downgrade_reason !== 'gate_ineligible' &&
794
- !structuredOutputSticky.has(stepName)) {
795
- structuredOutputSticky.set(stepName, {
796
- downgrade_reason: failedMeta.downgrade_reason,
797
- ...(failedMeta.api_message !== undefined
798
- ? { api_message: failedMeta.api_message }
799
- : {}),
800
- });
801
- }
802
- console.warn(` ⚠ LLM call attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
846
+ // The catch-side sticky-arming block that used to live here is DELETED as dead
847
+ // code: this catch returns 'failed' immediately, the sticky map is
848
+ // per-invocation, and nothing later reads it. The SUCCESS-path arming site
849
+ // above is the one that serves the #217 repair loop, and it is untouched.
850
+ console.error(`\n✗ Step '${stepName}' LLM call failed: ${safeErrorText(err)}`);
851
+ await recordDriveFailure(deps.store, runId, buildEntry(err, stepName, providerForEvidence ?? 'unknown', attemptStartedAt));
852
+ return 'failed';
803
853
  }
804
854
  }
805
- if (callError !== undefined) {
806
- console.error(`\n✗ Step '${stepName}' LLM call failed after 2 attempts`);
807
- return 'failed';
808
- }
809
855
  }
856
+ else {
857
+ // Auto step — the engine dispatches to the service adapter directly.
858
+ console.log(`→ [auto] ${stepName}`);
859
+ stepInput = {};
860
+ }
861
+ // issue #313 — the PROVENANCE chokepoint. Every path above that mints a
862
+ // `structuredOutputMetaForStep` (gate, sticky, compat, live ladder, tools mint, the
863
+ // provider_unsupported synthesis) funnels through here, so stamping the provider once at
864
+ // this single point covers them all and cannot be forgotten on a new arm. The engine's
865
+ // OWN synthesized `external_agent` stamps never pass through here and therefore carry no
866
+ // provider — correctly, since realm did not drive those attempts.
867
+ if (structuredOutputMetaForStep !== undefined && providerForEvidence !== undefined) {
868
+ structuredOutputMetaForStep = {
869
+ ...structuredOutputMetaForStep,
870
+ provider: providerForEvidence,
871
+ };
872
+ }
873
+ result = await executeChain(deps.store, definition, {
874
+ runId,
875
+ command: stepName,
876
+ input: stepInput,
877
+ dispatcher: async () => stepInput,
878
+ registry: deps.registry,
879
+ ...(deps.traceBufferStore !== undefined
880
+ ? { traceBufferStore: deps.traceBufferStore }
881
+ : {}),
882
+ // issue #236: stepMeta now ALSO passes when structuredOutput exists (previously only
883
+ // passed when toolCalls existed) — the two are independent, either alone must thread.
884
+ ...(toolCallsForMeta !== undefined || structuredOutputMetaForStep !== undefined
885
+ ? {
886
+ stepMeta: {
887
+ ...(toolCallsForMeta !== undefined ? { toolCalls: toolCallsForMeta } : {}),
888
+ ...(structuredOutputMetaForStep !== undefined
889
+ ? { structuredOutput: structuredOutputMetaForStep }
890
+ : {}),
891
+ },
892
+ }
893
+ : {}),
894
+ // issue #197 PR-2: a FRESH nonce per step-attempt — resolved per call, never cached, so
895
+ // the strict-flip (checked inside shouldMintWriterNonce) is honored even if the env var
896
+ // changes mid-process (tests flip it). Also fresh per issue #217 repair attempt, since
897
+ // this call sits inside the repair loop.
898
+ ...(shouldMintWriterNonce(deps) ? { writerNonce: crypto.randomUUID() } : {}),
899
+ });
900
+ // issue #217: the in-drive schema-feedback repair gate. Fires ONLY when ALL SIX conjuncts
901
+ // hold — see plans/issue-217/design-v2.md §Mechanism for the rationale on (i)-(v).
902
+ //
903
+ // Conjunct (vi) — CORRECTED from the design record's literal `result.command === stepName`
904
+ // (flagged as a divergence in the implementation report): the record's premise was that
905
+ // executeChain "returns the DEEPER step's own envelope" on a chain-replacement error,
906
+ // citing execution-loop.ts:2855-2859/:3015 (executeChainInternal's recursive early-return,
907
+ // which DOES set `command` to the deeper step). But run-agent.ts calls the PUBLIC
908
+ // `executeChain` wrapper, not executeChainInternal directly — and that wrapper
909
+ // unconditionally overwrites the returned envelope's `command` back to the TOP-LEVEL
910
+ // requested command on every call (execution-loop.ts:3094, `command: options.command`),
911
+ // confirmed empirically against the built engine. So `result.command` always equals
912
+ // `stepName` here and can never discriminate a deeper chained step's error from this step's
913
+ // own — the literal conjunct is vacuously true and provides zero protection.
914
+ //
915
+ // The corrected, structurally-sound discriminator: a pre-claim validation rejection is
916
+ // write-free (no run-record version bump — see execute-step.ts:77-80 / execution-loop.ts's
917
+ // Step 2b/2c, both before claimStep). So if `result.run_version` has advanced past
918
+ // `versionBeforeAttempt` (captured fresh at the top of each repair attempt), something
919
+ // committed to the run BEFORE this error occurred — e.g. THIS step's own claim+settle,
920
+ // followed by a DEEPER chained step's pre-claim rejection — so the error cannot be this
921
+ // step's own output/input. A concurrent external writer bumping the run mid-attempt also
922
+ // lands here — the gate then fails CLOSED (repair forfeited, today's failure path). See
923
+ // run-agent.test.ts's "chained-auto no-false-repair" and concurrent-writer tests.
924
+ //
925
+ // issue #220 (SHIPPED): countRejection now persists a bounded rejection counter via a
926
+ // real CAS write on a counted rejection — rejected attempts are NO LONGER write-free w.r.t.
927
+ // the run record. What keeps this conjunct sound anyway is bump-and-report: the write's
928
+ // return value is discarded, and the rejection's own ENVELOPE keeps reporting the
929
+ // PRE-write version (the Step-1 `run`), so `result.run_version` still equals
930
+ // `versionBeforeAttempt` here across repairs 2..N. Pin (a) (bump-and-report) guards this
931
+ // invariant — see execution-loop.ts's countRejection for the mechanism, and
932
+ // packages/core/src/engine/validation-exhaustion.test.ts's pin (a) for the pin.
933
+ if (result.status === 'error' &&
934
+ (result.error_code === 'VALIDATION_OUTPUT_SCHEMA' ||
935
+ result.error_code === 'VALIDATION_INPUT_SCHEMA') &&
936
+ stepDef.execution === 'agent' &&
937
+ (toolCallsForMeta === undefined || toolCallsForMeta.length === 0) &&
938
+ repairsUsed < schemaRetries &&
939
+ // issue #401: a duplicate attached writer's version bump forfeits the repair BY
940
+ // DESIGN (record R-3). The error-code-keyed mint below still records the wedge
941
+ // truthfully, so forfeiting a repair never costs the operator the visibility.
942
+ result.run_version === versionBeforeAttempt) {
943
+ repairsUsed++;
944
+ const record = buildFailedAttemptRecord({
945
+ run_id: runId,
946
+ workflow_id: definition.id,
947
+ step_id: stepName,
948
+ ts: new Date().toISOString(),
949
+ error_code: result.error_code,
950
+ ajv_errors: result.error_details?.['errors'] ?? [],
951
+ params: stepInput,
952
+ trace_entry_count: 0,
953
+ });
954
+ lastRejection = {
955
+ kind: result.error_code === 'VALIDATION_OUTPUT_SCHEMA' ? 'output' : 'input',
956
+ summary: record.validation_error_summary.map(renderValidationSummaryEntry).join('\n'),
957
+ };
958
+ console.error(` ⚠ output rejected (${result.error_code}); repairing (attempt ${repairsUsed}/${schemaRetries})`);
959
+ continue;
960
+ }
961
+ break;
810
962
  }
811
- else {
812
- // Auto step — the engine dispatches to the service adapter directly.
813
- console.log(`→ [auto] ${stepName}`);
814
- stepInput = {};
815
- }
816
- // issue #313 the PROVENANCE chokepoint. Every path above that mints a
817
- // `structuredOutputMetaForStep` (gate, sticky, compat, live ladder, tools mint, the
818
- // provider_unsupported synthesis) funnels through here, so stamping the provider once at
819
- // this single point covers them all and cannot be forgotten on a new arm. The engine's
820
- // OWN synthesized `external_agent` stamps never pass through here and therefore carry no
821
- // provider correctly, since realm did not drive those attempts.
822
- if (structuredOutputMetaForStep !== undefined && providerForEvidence !== undefined) {
823
- structuredOutputMetaForStep = {
824
- ...structuredOutputMetaForStep,
825
- provider: providerForEvidence,
826
- };
827
- }
828
- result = await executeChain(deps.store, definition, {
829
- runId,
830
- command: stepName,
831
- input: stepInput,
832
- dispatcher: async () => stepInput,
833
- registry: deps.registry,
834
- ...(deps.traceBufferStore !== undefined
835
- ? { traceBufferStore: deps.traceBufferStore }
836
- : {}),
837
- // issue #236: stepMeta now ALSO passes when structuredOutput exists (previously only
838
- // passed when toolCalls existed) the two are independent, either alone must thread.
839
- ...(toolCallsForMeta !== undefined || structuredOutputMetaForStep !== undefined
840
- ? {
841
- stepMeta: {
842
- ...(toolCallsForMeta !== undefined ? { toolCalls: toolCallsForMeta } : {}),
843
- ...(structuredOutputMetaForStep !== undefined
844
- ? { structuredOutput: structuredOutputMetaForStep }
845
- : {}),
846
- },
963
+ if (result.status === 'error') {
964
+ // #134: a NOT-REGISTERED handler/adapter settles RECOVERABLY — the run is NOT failed, the step
965
+ // is parked awaiting a capable runner. Detect structurally via error_code (not message text) and
966
+ // print capability-aware guidance instead of a bare `✗ Step failed`. The return stays 'failed'
967
+ // (no 'blocked' AgentRunResult variant, by design) — the distinction lives in the message.
968
+ // issue #401: a capability block mints NO drive-failure entry the `capability_block`
969
+ // finding already owns this disclosure, and two findings for one fact is noise.
970
+ const isCapabilityBlock = result.error_code === 'ENGINE_HANDLER_NOT_REGISTERED' ||
971
+ result.error_code === 'ENGINE_ADAPTER_NOT_REGISTERED';
972
+ // issue #217: append the repair count ONLY when at least one repair actually ran — never
973
+ // "after 0 schema-repair attempts".
974
+ const repairSuffix = repairsUsed > 0 ? ` after ${repairsUsed} schema-repair attempts` : '';
975
+ if (isCapabilityBlock) {
976
+ currentRun = await deps.store.get(runId);
977
+ const block = findCapabilityBlockedSteps(currentRun).find((b) => b.step === stepName);
978
+ const need = block !== undefined
979
+ ? `${block.requirement.kind} '${block.requirement.name}'`
980
+ : result.error_code === 'ENGINE_HANDLER_NOT_REGISTERED'
981
+ ? 'the missing handler'
982
+ : 'the missing adapter';
983
+ console.error(`\n⚠ Step '${stepName}' is blocked: ${need} is not registered in this runner. ` +
984
+ `The run is NOT failed — add ${need} and re-attach (\`realm agent --run-id ${runId}\`).`);
985
+ }
986
+ else {
987
+ console.error(`\n✗ Step '${stepName}' failed: ${result.errors.join(', ')}${repairSuffix}`);
988
+ // ═══ issue #401, CHOKEPOINT (4) — the disposition table, KEYED ON ERROR CODE ═══
989
+ //
990
+ // A validation rejection that reaches here has WEDGED the run: it settles nothing, so
991
+ // there is no seal to carry the news and no evidence to read. Every OTHER
992
+ // non-capability code SETTLES THE STEP — `failed_steps` plus the step's evidence are
993
+ // the visibility, so recording those would duplicate a fact the run already tells.
994
+ // (Not "either seals or is capability-owned": non-sealing envelopes exist, and a
995
+ // settled step on a still-live run is the common case.)
996
+ //
997
+ // Deliberately NOT keyed on `repairsUsed`: every bypass of the repair gate — a
998
+ // tools-path rejection, a concurrent writer's version bump, `schemaRetries: 0`, an
999
+ // AUTO step — arrives here with `repairsUsed === 0` and wedges just the same.
1000
+ //
1001
+ // Applies to ALL execution kinds. An auto step's validation exit is write-free and
1002
+ // pre-claim, which wedges the run identically to an agent step's.
1003
+ if (result.error_code === 'VALIDATION_OUTPUT_SCHEMA' ||
1004
+ result.error_code === 'VALIDATION_INPUT_SCHEMA') {
1005
+ await recordDriveFailure(deps.store, runId, {
1006
+ at: new Date().toISOString(),
1007
+ step: stepName,
1008
+ provider: providerForEvidence ?? 'unknown',
1009
+ error_class: 'validation_rejected',
1010
+ message: sanitizeError(result.errors.join(', ')).slice(0, MESSAGE_CAP),
1011
+ elapsed_ms: Date.now() - attemptStartedAt,
1012
+ });
847
1013
  }
848
- : {}),
849
- // issue #197 PR-2: a FRESH nonce per step-attempt — resolved per call, never cached, so
850
- // the strict-flip (checked inside shouldMintWriterNonce) is honored even if the env var
851
- // changes mid-process (tests flip it). Also fresh per issue #217 repair attempt, since
852
- // this call sits inside the repair loop.
853
- ...(shouldMintWriterNonce(deps) ? { writerNonce: crypto.randomUUID() } : {}),
854
- });
855
- // issue #217: the in-drive schema-feedback repair gate. Fires ONLY when ALL SIX conjuncts
856
- // hold — see plans/issue-217/design-v2.md §Mechanism for the rationale on (i)-(v).
857
- //
858
- // Conjunct (vi) — CORRECTED from the design record's literal `result.command === stepName`
859
- // (flagged as a divergence in the implementation report): the record's premise was that
860
- // executeChain "returns the DEEPER step's own envelope" on a chain-replacement error,
861
- // citing execution-loop.ts:2855-2859/:3015 (executeChainInternal's recursive early-return,
862
- // which DOES set `command` to the deeper step). But run-agent.ts calls the PUBLIC
863
- // `executeChain` wrapper, not executeChainInternal directly — and that wrapper
864
- // unconditionally overwrites the returned envelope's `command` back to the TOP-LEVEL
865
- // requested command on every call (execution-loop.ts:3094, `command: options.command`),
866
- // confirmed empirically against the built engine. So `result.command` always equals
867
- // `stepName` here and can never discriminate a deeper chained step's error from this step's
868
- // own — the literal conjunct is vacuously true and provides zero protection.
869
- //
870
- // The corrected, structurally-sound discriminator: a pre-claim validation rejection is
871
- // write-free (no run-record version bump — see execute-step.ts:77-80 / execution-loop.ts's
872
- // Step 2b/2c, both before claimStep). So if `result.run_version` has advanced past
873
- // `versionBeforeAttempt` (captured fresh at the top of each repair attempt), something
874
- // committed to the run BEFORE this error occurred — e.g. THIS step's own claim+settle,
875
- // followed by a DEEPER chained step's pre-claim rejection — so the error cannot be this
876
- // step's own output/input. A concurrent external writer bumping the run mid-attempt also
877
- // lands here — the gate then fails CLOSED (repair forfeited, today's failure path). See
878
- // run-agent.test.ts's "chained-auto no-false-repair" and concurrent-writer tests.
879
- //
880
- // issue #220 (SHIPPED): countRejection now persists a bounded rejection counter via a
881
- // real CAS write on a counted rejection — rejected attempts are NO LONGER write-free w.r.t.
882
- // the run record. What keeps this conjunct sound anyway is bump-and-report: the write's
883
- // return value is discarded, and the rejection's own ENVELOPE keeps reporting the
884
- // PRE-write version (the Step-1 `run`), so `result.run_version` still equals
885
- // `versionBeforeAttempt` here across repairs 2..N. Pin (a) (bump-and-report) guards this
886
- // invariant — see execution-loop.ts's countRejection for the mechanism, and
887
- // packages/core/src/engine/validation-exhaustion.test.ts's pin (a) for the pin.
888
- if (result.status === 'error' &&
889
- (result.error_code === 'VALIDATION_OUTPUT_SCHEMA' ||
890
- result.error_code === 'VALIDATION_INPUT_SCHEMA') &&
891
- stepDef.execution === 'agent' &&
892
- (toolCallsForMeta === undefined || toolCallsForMeta.length === 0) &&
893
- repairsUsed < schemaRetries &&
894
- result.run_version === versionBeforeAttempt) {
895
- repairsUsed++;
896
- const record = buildFailedAttemptRecord({
897
- run_id: runId,
898
- workflow_id: definition.id,
899
- step_id: stepName,
900
- ts: new Date().toISOString(),
901
- error_code: result.error_code,
902
- ajv_errors: result.error_details?.['errors'] ?? [],
903
- params: stepInput,
904
- trace_entry_count: 0,
905
- });
906
- lastRejection = {
907
- kind: result.error_code === 'VALIDATION_OUTPUT_SCHEMA' ? 'output' : 'input',
908
- summary: record.validation_error_summary.map(renderValidationSummaryEntry).join('\n'),
909
- };
910
- console.error(` ⚠ output rejected (${result.error_code}); repairing (attempt ${repairsUsed}/${schemaRetries})`);
911
- continue;
1014
+ }
1015
+ return 'failed';
912
1016
  }
913
- break;
914
- }
915
- if (result.status === 'error') {
916
- // #134: a NOT-REGISTERED handler/adapter settles RECOVERABLY — the run is NOT failed, the step
917
- // is parked awaiting a capable runner. Detect structurally via error_code (not message text) and
918
- // print capability-aware guidance instead of a bare `✗ Step failed`. The return stays 'failed'
919
- // (no 'blocked' AgentRunResult variant, by design) — the distinction lives in the message.
920
- const isCapabilityBlock = result.error_code === 'ENGINE_HANDLER_NOT_REGISTERED' ||
921
- result.error_code === 'ENGINE_ADAPTER_NOT_REGISTERED';
922
- // issue #217: append the repair count ONLY when at least one repair actually ran — never
923
- // "after 0 schema-repair attempts".
924
- const repairSuffix = repairsUsed > 0 ? ` after ${repairsUsed} schema-repair attempts` : '';
925
- if (isCapabilityBlock) {
1017
+ if (result.status === 'confirm_required') {
1018
+ // Gate will be handled at the top of the next iteration.
926
1019
  currentRun = await deps.store.get(runId);
927
- const block = findCapabilityBlockedSteps(currentRun).find((b) => b.step === stepName);
928
- const need = block !== undefined
929
- ? `${block.requirement.kind} '${block.requirement.name}'`
930
- : result.error_code === 'ENGINE_HANDLER_NOT_REGISTERED'
931
- ? 'the missing handler'
932
- : 'the missing adapter';
933
- console.error(`\n⚠ Step '${stepName}' is blocked: ${need} is not registered in this runner. ` +
934
- `The run is NOT failed — add ${need} and re-attach (\`realm agent --run-id ${runId}\`).`);
935
- }
936
- else {
937
- console.error(`\n✗ Step '${stepName}' failed: ${result.errors.join(', ')}${repairSuffix}`);
1020
+ continue;
938
1021
  }
939
- return 'failed';
940
- }
941
- if (result.status === 'confirm_required') {
942
- // Gate will be handled at the top of the next iteration.
943
1022
  currentRun = await deps.store.get(runId);
944
- continue;
1023
+ console.log(` ✓ → ${currentRun.run_phase}`);
945
1024
  }
946
- currentRun = await deps.store.get(runId);
947
- console.log(` ✓ → ${currentRun.run_phase}`);
948
1025
  }
949
- }
950
- finally {
951
- if (mcpClient) {
952
- await mcpClient.disconnect();
1026
+ finally {
1027
+ if (mcpClient) {
1028
+ await mcpClient.disconnect();
1029
+ }
953
1030
  }
954
1031
  }
1032
+ catch (err) {
1033
+ // ═══ issue #401, CHOKEPOINT (3) — the last-resort catch ═══
1034
+ //
1035
+ // Sees everything the inner chokepoints did not already RETURN from: the MCP-init throws, a
1036
+ // store read failing mid-loop, a gate handler throwing, an engine throw out of executeChain,
1037
+ // a disconnect failing. Each one used to leave the run looking untouched.
1038
+ //
1039
+ // Classified through the SHARED classifier rather than hardcoded to 'other': an error that
1040
+ // carries a payload deserves its real class no matter which catch happens to see it.
1041
+ //
1042
+ // `step: ''` is EXPECTED for anything thrown before a step was selected.
1043
+ await recordDriveFailure(deps.store, runId, {
1044
+ ...buildEntry(err, currentStepName ?? '', providerForEvidence ?? 'unknown', attemptStartedAt),
1045
+ });
1046
+ // RE-THROWS, never returns: `runAgent`'s public contract is that these propagate, and
1047
+ // commands/agent.ts stays the console floor for anything that happens before a run exists.
1048
+ throw err;
1049
+ }
955
1050
  if (currentRun.run_phase === 'completed') {
956
1051
  console.log(`\nRun complete: ${runId}`);
957
1052
  // Print the last agent step's output so the result is visible without