@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.
- package/dist/agent/drive-failure.d.ts +62 -0
- package/dist/agent/drive-failure.d.ts.map +1 -0
- package/dist/agent/drive-failure.js +241 -0
- package/dist/agent/drive-failure.js.map +1 -0
- package/dist/agent/providers/agent-utils.d.ts +156 -0
- package/dist/agent/providers/agent-utils.d.ts.map +1 -1
- package/dist/agent/providers/agent-utils.js +423 -1
- package/dist/agent/providers/agent-utils.js.map +1 -1
- package/dist/agent/providers/anthropic-provider.d.ts +51 -1
- package/dist/agent/providers/anthropic-provider.d.ts.map +1 -1
- package/dist/agent/providers/anthropic-provider.js +109 -30
- package/dist/agent/providers/anthropic-provider.js.map +1 -1
- package/dist/agent/providers/llm-provider.d.ts +13 -1
- package/dist/agent/providers/llm-provider.d.ts.map +1 -1
- package/dist/agent/providers/llm-provider.js +8 -4
- package/dist/agent/providers/llm-provider.js.map +1 -1
- package/dist/agent/providers/openai-provider.d.ts +6 -1
- package/dist/agent/providers/openai-provider.d.ts.map +1 -1
- package/dist/agent/providers/openai-provider.js +75 -20
- package/dist/agent/providers/openai-provider.js.map +1 -1
- package/dist/agent/providers/openai-reasoning-provider.d.ts +4 -1
- package/dist/agent/providers/openai-reasoning-provider.d.ts.map +1 -1
- package/dist/agent/providers/openai-reasoning-provider.js +21 -6
- package/dist/agent/providers/openai-reasoning-provider.js.map +1 -1
- package/dist/agent/run-agent.d.ts +9 -0
- package/dist/agent/run-agent.d.ts.map +1 -1
- package/dist/agent/run-agent.js +775 -680
- package/dist/agent/run-agent.js.map +1 -1
- package/dist/agent/run-attach.d.ts.map +1 -1
- package/dist/agent/run-attach.js +26 -2
- package/dist/agent/run-attach.js.map +1 -1
- package/dist/agent/test-support/anthropic-stub.d.ts +45 -0
- package/dist/agent/test-support/anthropic-stub.d.ts.map +1 -0
- package/dist/agent/test-support/anthropic-stub.js +146 -0
- package/dist/agent/test-support/anthropic-stub.js.map +1 -0
- package/dist/agent/test-support/composed-journey.d.ts +40 -0
- package/dist/agent/test-support/composed-journey.d.ts.map +1 -0
- package/dist/agent/test-support/composed-journey.js +104 -0
- package/dist/agent/test-support/composed-journey.js.map +1 -0
- package/dist/agent/test-support/mcp-server-entry.d.ts +20 -0
- package/dist/agent/test-support/mcp-server-entry.d.ts.map +1 -0
- package/dist/agent/test-support/mcp-server-entry.js +39 -0
- package/dist/agent/test-support/mcp-server-entry.js.map +1 -0
- package/dist/agent/test-support/openai-stub.d.ts +47 -0
- package/dist/agent/test-support/openai-stub.d.ts.map +1 -0
- package/dist/agent/test-support/openai-stub.js +115 -0
- package/dist/agent/test-support/openai-stub.js.map +1 -0
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +44 -3
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +37 -3
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/gc.d.ts.map +1 -1
- package/dist/commands/gc.js.map +1 -1
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +61 -2
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +86 -0
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/listen.d.ts +13 -0
- package/dist/commands/listen.d.ts.map +1 -1
- package/dist/commands/listen.js +36 -4
- package/dist/commands/listen.js.map +1 -1
- package/dist/commands/purge.d.ts +22 -1
- package/dist/commands/purge.d.ts.map +1 -1
- package/dist/commands/purge.js +70 -42
- package/dist/commands/purge.js.map +1 -1
- package/dist/commands/register.d.ts +25 -0
- package/dist/commands/register.d.ts.map +1 -1
- package/dist/commands/register.js +91 -21
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/respond.d.ts.map +1 -1
- package/dist/commands/respond.js +23 -1
- package/dist/commands/respond.js.map +1 -1
- package/dist/commands/run.d.ts +34 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +225 -11
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +48 -8
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/validate.d.ts +19 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +358 -84
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/watch.d.ts +30 -0
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +159 -23
- package/dist/commands/watch.js.map +1 -1
- package/dist/commands/workflow-list.d.ts +16 -0
- package/dist/commands/workflow-list.d.ts.map +1 -0
- package/dist/commands/workflow-list.js +96 -0
- package/dist/commands/workflow-list.js.map +1 -0
- package/dist/commands-registry.d.ts.map +1 -1
- package/dist/commands-registry.js +2 -0
- package/dist/commands-registry.js.map +1 -1
- package/dist/extensions/load-project-extensions.d.ts.map +1 -1
- package/dist/extensions/load-project-extensions.js +9 -3
- package/dist/extensions/load-project-extensions.js.map +1 -1
- package/dist/index.js +22 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/loader-warnings.d.ts +45 -3
- package/dist/lib/loader-warnings.d.ts.map +1 -1
- package/dist/lib/loader-warnings.js +106 -5
- package/dist/lib/loader-warnings.js.map +1 -1
- package/package.json +5 -4
package/dist/agent/run-agent.js
CHANGED
|
@@ -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
|
|
101
|
-
//
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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 walk — because 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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|