patchwork-os 0.2.0-beta.10.canary.98 → 0.2.0-beta.11

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 (230) hide show
  1. package/README.md +20 -1
  2. package/dist/adapters/grok.js +6 -1
  3. package/dist/adapters/grok.js.map +1 -1
  4. package/dist/approvalHttp.js +61 -66
  5. package/dist/approvalHttp.js.map +1 -1
  6. package/dist/bridge.js +3 -2
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/claudeOrchestrator.js +15 -0
  9. package/dist/claudeOrchestrator.js.map +1 -1
  10. package/dist/commands/connect.d.ts +47 -0
  11. package/dist/commands/connect.js +419 -0
  12. package/dist/commands/connect.js.map +1 -0
  13. package/dist/commands/patchworkInit.d.ts +7 -0
  14. package/dist/commands/patchworkInit.js +26 -0
  15. package/dist/commands/patchworkInit.js.map +1 -1
  16. package/dist/commands/recipe.d.ts +14 -0
  17. package/dist/commands/recipe.js +51 -0
  18. package/dist/commands/recipe.js.map +1 -1
  19. package/dist/commands/recipeInstall.js +15 -0
  20. package/dist/commands/recipeInstall.js.map +1 -1
  21. package/dist/connectors/postgres.d.ts +7 -0
  22. package/dist/connectors/postgres.js +16 -2
  23. package/dist/connectors/postgres.js.map +1 -1
  24. package/dist/connectors/snowflake.js +6 -0
  25. package/dist/connectors/snowflake.js.map +1 -1
  26. package/dist/connectors/tokenStorage.d.ts +8 -0
  27. package/dist/connectors/tokenStorage.js +42 -3
  28. package/dist/connectors/tokenStorage.js.map +1 -1
  29. package/dist/drivers/claude/api.js +15 -2
  30. package/dist/drivers/claude/api.js.map +1 -1
  31. package/dist/drivers/claude/subprocess.js +1 -29
  32. package/dist/drivers/claude/subprocess.js.map +1 -1
  33. package/dist/drivers/gemini/index.js +9 -5
  34. package/dist/drivers/gemini/index.js.map +1 -1
  35. package/dist/drivers/openai/index.js +29 -4
  36. package/dist/drivers/openai/index.js.map +1 -1
  37. package/dist/drivers/outputCap.d.ts +27 -0
  38. package/dist/drivers/outputCap.js +50 -0
  39. package/dist/drivers/outputCap.js.map +1 -0
  40. package/dist/featureFlags.d.ts +9 -0
  41. package/dist/featureFlags.js +16 -0
  42. package/dist/featureFlags.js.map +1 -1
  43. package/dist/fp/commandDescription.d.ts +6 -0
  44. package/dist/fp/commandDescription.js +4 -4
  45. package/dist/fp/commandDescription.js.map +1 -1
  46. package/dist/fp/policyParser.js +13 -7
  47. package/dist/fp/policyParser.js.map +1 -1
  48. package/dist/haltPushDispatch.js +7 -22
  49. package/dist/haltPushDispatch.js.map +1 -1
  50. package/dist/index.js +148 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/mcpRoutes.js +1 -1
  53. package/dist/mcpRoutes.js.map +1 -1
  54. package/dist/oauth.d.ts +13 -0
  55. package/dist/oauth.js +34 -1
  56. package/dist/oauth.js.map +1 -1
  57. package/dist/recipeOrchestration.js +10 -0
  58. package/dist/recipeOrchestration.js.map +1 -1
  59. package/dist/recipeRoutes.d.ts +39 -0
  60. package/dist/recipeRoutes.js +214 -12
  61. package/dist/recipeRoutes.js.map +1 -1
  62. package/dist/recipes/agentExecutor.d.ts +20 -0
  63. package/dist/recipes/agentExecutor.js +32 -10
  64. package/dist/recipes/agentExecutor.js.map +1 -1
  65. package/dist/recipes/chainedRunner.d.ts +41 -2
  66. package/dist/recipes/chainedRunner.js +134 -6
  67. package/dist/recipes/chainedRunner.js.map +1 -1
  68. package/dist/recipes/connectorPreflight.js +30 -0
  69. package/dist/recipes/connectorPreflight.js.map +1 -1
  70. package/dist/recipes/haltCategory.d.ts +5 -0
  71. package/dist/recipes/haltCategory.js +7 -1
  72. package/dist/recipes/haltCategory.js.map +1 -1
  73. package/dist/recipes/judgeVerdict.d.ts +10 -0
  74. package/dist/recipes/judgeVerdict.js +10 -0
  75. package/dist/recipes/judgeVerdict.js.map +1 -1
  76. package/dist/recipes/names.d.ts +5 -0
  77. package/dist/recipes/names.js +10 -5
  78. package/dist/recipes/names.js.map +1 -1
  79. package/dist/recipes/parser.js +32 -9
  80. package/dist/recipes/parser.js.map +1 -1
  81. package/dist/recipes/pricing/costRouter.d.ts +43 -0
  82. package/dist/recipes/pricing/costRouter.js +44 -0
  83. package/dist/recipes/pricing/costRouter.js.map +1 -0
  84. package/dist/recipes/pricing/priceTable.d.ts +76 -0
  85. package/dist/recipes/pricing/priceTable.js +144 -0
  86. package/dist/recipes/pricing/priceTable.js.map +1 -0
  87. package/dist/recipes/runBudget.d.ts +82 -32
  88. package/dist/recipes/runBudget.js +182 -49
  89. package/dist/recipes/runBudget.js.map +1 -1
  90. package/dist/recipes/schema.d.ts +25 -4
  91. package/dist/recipes/schemaGenerator.d.ts +9 -0
  92. package/dist/recipes/schemaGenerator.js +386 -75
  93. package/dist/recipes/schemaGenerator.js.map +1 -1
  94. package/dist/recipes/simulation/aggregateRunRisk.d.ts +43 -0
  95. package/dist/recipes/simulation/aggregateRunRisk.js +117 -0
  96. package/dist/recipes/simulation/aggregateRunRisk.js.map +1 -0
  97. package/dist/recipes/simulation/costProjector.d.ts +32 -0
  98. package/dist/recipes/simulation/costProjector.js +194 -0
  99. package/dist/recipes/simulation/costProjector.js.map +1 -0
  100. package/dist/recipes/simulation/sideEffects.d.ts +32 -0
  101. package/dist/recipes/simulation/sideEffects.js +62 -0
  102. package/dist/recipes/simulation/sideEffects.js.map +1 -0
  103. package/dist/recipes/simulation/simulate.d.ts +36 -0
  104. package/dist/recipes/simulation/simulate.js +264 -0
  105. package/dist/recipes/simulation/simulate.js.map +1 -0
  106. package/dist/recipes/simulation/simulateMockedRun.d.ts +52 -0
  107. package/dist/recipes/simulation/simulateMockedRun.js +72 -0
  108. package/dist/recipes/simulation/simulateMockedRun.js.map +1 -0
  109. package/dist/recipes/simulation/synthesizeMockedOutputs.d.ts +31 -0
  110. package/dist/recipes/simulation/synthesizeMockedOutputs.js +50 -0
  111. package/dist/recipes/simulation/synthesizeMockedOutputs.js.map +1 -0
  112. package/dist/recipes/simulation/types.d.ts +198 -0
  113. package/dist/recipes/simulation/types.js +30 -0
  114. package/dist/recipes/simulation/types.js.map +1 -0
  115. package/dist/recipes/stepObservation.d.ts +12 -0
  116. package/dist/recipes/stepObservation.js +21 -1
  117. package/dist/recipes/stepObservation.js.map +1 -1
  118. package/dist/recipes/tools/airtable.d.ts +15 -0
  119. package/dist/recipes/tools/airtable.js +240 -0
  120. package/dist/recipes/tools/airtable.js.map +1 -0
  121. package/dist/recipes/tools/caldiy.d.ts +13 -0
  122. package/dist/recipes/tools/caldiy.js +214 -0
  123. package/dist/recipes/tools/caldiy.js.map +1 -0
  124. package/dist/recipes/tools/circleci.d.ts +10 -0
  125. package/dist/recipes/tools/circleci.js +204 -0
  126. package/dist/recipes/tools/circleci.js.map +1 -0
  127. package/dist/recipes/tools/cloudflare.d.ts +13 -0
  128. package/dist/recipes/tools/cloudflare.js +211 -0
  129. package/dist/recipes/tools/cloudflare.js.map +1 -0
  130. package/dist/recipes/tools/docs.d.ts +18 -0
  131. package/dist/recipes/tools/docs.js +94 -0
  132. package/dist/recipes/tools/docs.js.map +1 -0
  133. package/dist/recipes/tools/elasticsearch.d.ts +11 -0
  134. package/dist/recipes/tools/elasticsearch.js +156 -0
  135. package/dist/recipes/tools/elasticsearch.js.map +1 -0
  136. package/dist/recipes/tools/figma.d.ts +12 -0
  137. package/dist/recipes/tools/figma.js +194 -0
  138. package/dist/recipes/tools/figma.js.map +1 -0
  139. package/dist/recipes/tools/grafana.d.ts +11 -0
  140. package/dist/recipes/tools/grafana.js +216 -0
  141. package/dist/recipes/tools/grafana.js.map +1 -0
  142. package/dist/recipes/tools/http.d.ts +1 -1
  143. package/dist/recipes/tools/http.js +10 -28
  144. package/dist/recipes/tools/http.js.map +1 -1
  145. package/dist/recipes/tools/index.d.ts +26 -0
  146. package/dist/recipes/tools/index.js +26 -0
  147. package/dist/recipes/tools/index.js.map +1 -1
  148. package/dist/recipes/tools/monday.d.ts +22 -0
  149. package/dist/recipes/tools/monday.js +242 -0
  150. package/dist/recipes/tools/monday.js.map +1 -0
  151. package/dist/recipes/tools/obsidian.d.ts +15 -0
  152. package/dist/recipes/tools/obsidian.js +172 -0
  153. package/dist/recipes/tools/obsidian.js.map +1 -0
  154. package/dist/recipes/tools/paystack.d.ts +11 -0
  155. package/dist/recipes/tools/paystack.js +211 -0
  156. package/dist/recipes/tools/paystack.js.map +1 -0
  157. package/dist/recipes/tools/pipedrive.d.ts +16 -0
  158. package/dist/recipes/tools/pipedrive.js +233 -0
  159. package/dist/recipes/tools/pipedrive.js.map +1 -0
  160. package/dist/recipes/tools/postgres.d.ts +15 -0
  161. package/dist/recipes/tools/postgres.js +185 -0
  162. package/dist/recipes/tools/postgres.js.map +1 -0
  163. package/dist/recipes/tools/posthog.d.ts +16 -0
  164. package/dist/recipes/tools/posthog.js +219 -0
  165. package/dist/recipes/tools/posthog.js.map +1 -0
  166. package/dist/recipes/tools/redis.d.ts +9 -0
  167. package/dist/recipes/tools/redis.js +140 -0
  168. package/dist/recipes/tools/redis.js.map +1 -0
  169. package/dist/recipes/tools/resend.d.ts +8 -0
  170. package/dist/recipes/tools/resend.js +153 -0
  171. package/dist/recipes/tools/resend.js.map +1 -0
  172. package/dist/recipes/tools/salesforce.d.ts +16 -0
  173. package/dist/recipes/tools/salesforce.js +184 -0
  174. package/dist/recipes/tools/salesforce.js.map +1 -0
  175. package/dist/recipes/tools/sendgrid.d.ts +9 -0
  176. package/dist/recipes/tools/sendgrid.js +174 -0
  177. package/dist/recipes/tools/sendgrid.js.map +1 -0
  178. package/dist/recipes/tools/shopify.d.ts +16 -0
  179. package/dist/recipes/tools/shopify.js +265 -0
  180. package/dist/recipes/tools/shopify.js.map +1 -0
  181. package/dist/recipes/tools/snowflake.d.ts +16 -0
  182. package/dist/recipes/tools/snowflake.js +173 -0
  183. package/dist/recipes/tools/snowflake.js.map +1 -0
  184. package/dist/recipes/tools/stripe.js +13 -12
  185. package/dist/recipes/tools/stripe.js.map +1 -1
  186. package/dist/recipes/tools/supabase.d.ts +9 -0
  187. package/dist/recipes/tools/supabase.js +132 -0
  188. package/dist/recipes/tools/supabase.js.map +1 -0
  189. package/dist/recipes/tools/todoist.d.ts +15 -0
  190. package/dist/recipes/tools/todoist.js +227 -0
  191. package/dist/recipes/tools/todoist.js.map +1 -0
  192. package/dist/recipes/tools/twilio.d.ts +11 -0
  193. package/dist/recipes/tools/twilio.js +180 -0
  194. package/dist/recipes/tools/twilio.js.map +1 -0
  195. package/dist/recipes/tools/vercel.d.ts +9 -0
  196. package/dist/recipes/tools/vercel.js +145 -0
  197. package/dist/recipes/tools/vercel.js.map +1 -0
  198. package/dist/recipes/tools/webflow.d.ts +11 -0
  199. package/dist/recipes/tools/webflow.js +243 -0
  200. package/dist/recipes/tools/webflow.js.map +1 -0
  201. package/dist/recipes/tools/woocommerce.d.ts +18 -0
  202. package/dist/recipes/tools/woocommerce.js +259 -0
  203. package/dist/recipes/tools/woocommerce.js.map +1 -0
  204. package/dist/recipes/tools/wrapConnectorExecute.d.ts +25 -0
  205. package/dist/recipes/tools/wrapConnectorExecute.js +37 -0
  206. package/dist/recipes/tools/wrapConnectorExecute.js.map +1 -0
  207. package/dist/recipes/validation.js +264 -0
  208. package/dist/recipes/validation.js.map +1 -1
  209. package/dist/recipes/yamlRunner.d.ts +112 -3
  210. package/dist/recipes/yamlRunner.js +482 -19
  211. package/dist/recipes/yamlRunner.js.map +1 -1
  212. package/dist/runLog.d.ts +46 -0
  213. package/dist/runLog.js +4 -0
  214. package/dist/runLog.js.map +1 -1
  215. package/dist/schemas/recipe.v1.json +217 -11
  216. package/dist/server.d.ts +3 -0
  217. package/dist/server.js +4 -0
  218. package/dist/server.js.map +1 -1
  219. package/dist/streamableHttp.js +3 -2
  220. package/dist/streamableHttp.js.map +1 -1
  221. package/dist/tools/gitWrite.js +5 -1
  222. package/dist/tools/gitWrite.js.map +1 -1
  223. package/dist/tools/terminal.js +15 -49
  224. package/dist/tools/terminal.js.map +1 -1
  225. package/dist/transport.d.ts +1 -0
  226. package/dist/transport.js +7 -0
  227. package/dist/transport.js.map +1 -1
  228. package/package.json +3 -3
  229. package/scripts/mcp-stdio-shim.cjs +218 -84
  230. package/templates/recipes/incident-to-pr.yaml +187 -0
@@ -48,9 +48,11 @@ import { categoriseHaltReason } from "./haltCategory.js";
48
48
  import { assertValidManualRunId, deriveScopeKey, WriteEffectLedger, } from "./idempotencyKey.js";
49
49
  import { buildJudgeArtefactBlock, JUDGE_PROMPT_SUFFIX, parseJudgeVerdict, } from "./judgeVerdict.js";
50
50
  import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./migrations/index.js";
51
+ import { costRouter } from "./pricing/costRouter.js";
52
+ import { loadPriceTable, costUsd as priceCostUsd, } from "./pricing/priceTable.js";
51
53
  import { resolveRecipePath } from "./resolveRecipePath.js";
52
54
  import { RunBudget } from "./runBudget.js";
53
- import { detectSilentFail } from "./stepObservation.js";
55
+ import { detectSilentFail, redactSecretsForPrompt } from "./stepObservation.js";
54
56
  // Import tool registry and trigger tool self-registration
55
57
  import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
56
58
  import { resolveWorkspaceRoot } from "./workspaceRoot.js";
@@ -336,6 +338,103 @@ export async function loadRecipeServers(specs) {
336
338
  }
337
339
  }
338
340
  }
341
+ /**
342
+ * P1 cost/token corpus — drivers that incur real, metered, per-token API
343
+ * billing (the only ones whose spend is real money and thus priceable here).
344
+ * Mirrors `BILLABLE_DRIVERS` in runBudget.ts (kept local — that set is private
345
+ * and runBudget.ts is enforcement-critical / must not be modified for P1).
346
+ * `local` reports usage but costs no real money, so it is NOT billable.
347
+ */
348
+ const COST_BILLABLE_DRIVERS = new Set([
349
+ "anthropic",
350
+ "openai",
351
+ "grok",
352
+ "gemini",
353
+ ]);
354
+ function newStepUsageAccumulator() {
355
+ return { inputTokens: 0, outputTokens: 0, measured: false };
356
+ }
357
+ /**
358
+ * Fold one agent call's usage into a per-step accumulator. Adds tokens when
359
+ * `usage` is present; adds USD only when the served model is billable AND
360
+ * present in the price table (NEVER a `0` placeholder for the unpriced case).
361
+ */
362
+ function accumulateAgentUsage(acc, usage, servedBy, priceTable) {
363
+ if (!usage)
364
+ return;
365
+ acc.measured = true;
366
+ acc.inputTokens += usage.inputTokens;
367
+ acc.outputTokens += usage.outputTokens;
368
+ const driver = servedBy?.driver;
369
+ const model = servedBy?.model;
370
+ if (driver && model && COST_BILLABLE_DRIVERS.has(driver)) {
371
+ const cost = priceCostUsd(model, usage, priceTable);
372
+ if (typeof cost === "number") {
373
+ acc.costUsd = (acc.costUsd ?? 0) + cost;
374
+ }
375
+ }
376
+ }
377
+ /**
378
+ * Build the optional token fields for a step result from its accumulator.
379
+ * Returns an empty object (no fields) when the step reported no usage, so a
380
+ * tool step or unmeasured-driver step round-trips with the fields ABSENT.
381
+ */
382
+ function stepUsageFields(acc) {
383
+ if (!acc.measured)
384
+ return {};
385
+ return {
386
+ inputTokens: acc.inputTokens,
387
+ outputTokens: acc.outputTokens,
388
+ ...(typeof acc.costUsd === "number" ? { costUsd: acc.costUsd } : {}),
389
+ };
390
+ }
391
+ /**
392
+ * P1 — single-call usage → persisted-step usage fields. Exported for the
393
+ * chained runner, whose agent steps make exactly one agent call (no
394
+ * judge→refine loop), so a per-call computation suffices. Returns undefined
395
+ * when the driver reported no usage (fields stay ABSENT). `costUsd` set only
396
+ * for a billable driver + priced model; never a `0` placeholder.
397
+ */
398
+ export function computeAgentCallUsage(usage, servedBy, priceTable = loadPriceTable()) {
399
+ if (!usage)
400
+ return undefined;
401
+ const driver = servedBy?.driver;
402
+ const model = servedBy?.model;
403
+ let cost;
404
+ if (driver && model && COST_BILLABLE_DRIVERS.has(driver)) {
405
+ const c = priceCostUsd(model, usage, priceTable);
406
+ if (typeof c === "number")
407
+ cost = c;
408
+ }
409
+ return {
410
+ inputTokens: usage.inputTokens,
411
+ outputTokens: usage.outputTokens,
412
+ ...(typeof cost === "number" ? { costUsd: cost } : {}),
413
+ };
414
+ }
415
+ function newRunUsageAccumulator() {
416
+ return { inputTokens: 0, outputTokens: 0, measured: false };
417
+ }
418
+ function foldStepIntoRun(run, step) {
419
+ if (!step.measured)
420
+ return;
421
+ run.measured = true;
422
+ run.inputTokens += step.inputTokens;
423
+ run.outputTokens += step.outputTokens;
424
+ if (typeof step.costUsd === "number") {
425
+ run.costUsd = (run.costUsd ?? 0) + step.costUsd;
426
+ }
427
+ }
428
+ /** Build the optional `tokenTotals` for a run, or undefined when none measured. */
429
+ function runTokenTotals(run) {
430
+ if (!run.measured)
431
+ return undefined;
432
+ return {
433
+ inputTokens: run.inputTokens,
434
+ outputTokens: run.outputTokens,
435
+ ...(typeof run.costUsd === "number" ? { costUsd: run.costUsd } : {}),
436
+ };
437
+ }
339
438
  export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
340
439
  if (recipe.servers?.length) {
341
440
  await loadRecipeServers(recipe.servers);
@@ -343,6 +442,12 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
343
442
  const now = deps.now ? deps.now() : new Date();
344
443
  // Resolve recipe-level context blocks (type: env) into seed context
345
444
  const envCtx = {};
445
+ // SECRETS-IN-VARS: track which ctx keys came from a `type: env` block so the
446
+ // agent (LLM-facing) prompt can redact them. Their raw values still flow to
447
+ // TOOL steps (an http header / DB password legitimately needs the secret),
448
+ // but they must never reach the model verbatim — the secure default is
449
+ // redaction. See PR body / docs/recipe-feature-investigation-2026-06-05.md.
450
+ const secretKeys = new Set();
346
451
  if (Array.isArray(recipe.context)) {
347
452
  for (const block of recipe
348
453
  .context ?? []) {
@@ -350,8 +455,10 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
350
455
  if (b.type === "env" && Array.isArray(b.keys)) {
351
456
  for (const key of b.keys) {
352
457
  const v = process.env[key];
353
- if (v !== undefined)
458
+ if (v !== undefined) {
354
459
  envCtx[key] = v;
460
+ secretKeys.add(key);
461
+ }
355
462
  }
356
463
  }
357
464
  }
@@ -473,6 +580,13 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
473
580
  }
474
581
  const outputs = [];
475
582
  const stepResults = [];
583
+ // P1 cost/token corpus. The price table is loaded once per run (fail-open).
584
+ // `currentStepUsage` accumulates usage across all agent calls of the CURRENT
585
+ // agent step (including judge→refine re-runs via `runAgentText`); `runUsage`
586
+ // sums measured steps into the run-level total.
587
+ const priceTable = loadPriceTable();
588
+ const runUsage = newRunUsageAccumulator();
589
+ let currentStepUsage = newStepUsageAccumulator();
476
590
  let stepsRun = 0;
477
591
  let runError;
478
592
  // Bug (2): the flat runner historically recorded the first non-optional
@@ -553,6 +667,179 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
553
667
  ts: Date.now(),
554
668
  });
555
669
  };
670
+ // ── OPT-IN judge → refine loop (helper closure) ──────────────────────────
671
+ //
672
+ // ⚠️ INVARIANT DEPARTURE — this drives a bounded revise→re-judge loop and
673
+ // MAY gate the run on exhaustion. It departs the augment-only invariant in
674
+ // judgeVerdict.ts, but is reachable ONLY when the judge step opts in via
675
+ // `agent.max_revisions > 0`. The augment-only PR3a path is untouched.
676
+ //
677
+ // `runAgentText` mirrors the main agent path's text processing exactly
678
+ // (strip leading narration, then JSON-fence parse + sanitize, else use the
679
+ // raw string) so a revised draft commits to ctx the same way a first-pass
680
+ // agent step would. It returns `{ value, ok }`; `ok: false` signals a
681
+ // failed / silent-fail / empty agent response — the caller stops the loop
682
+ // and treats it as exhausted (we don't re-judge a non-result).
683
+ const runAgentText = async (prompt, driver, model, mcpAccess, downshift) => {
684
+ // Phase 4: route revisions too, so a downshift on the reviewed step also
685
+ // applies to its refine-loop re-runs (no-op when downshift is absent).
686
+ const routed = resolveRouting({ driver, model }, downshift, prompt, runBudget);
687
+ const agentReturn = await _executeAgent({
688
+ prompt,
689
+ driver: routed.driver === "api" ? "anthropic" : routed.driver,
690
+ model: routed.model,
691
+ ...(mcpAccess !== undefined && { mcpAccess }),
692
+ }, buildAgentExecutorDeps(stepDeps, deps));
693
+ runBudget.reconcile(
694
+ // Prefer the driver executeAgent actually resolved+ran; fall back to
695
+ // the routed value only when servedBy is absent (non-executeAgent
696
+ // callers). Stops auto-detected runs being mis-attributed to "auto".
697
+ agentReturn.servedBy?.driver ??
698
+ (routed.driver === "api" ? "anthropic" : (routed.driver ?? "auto")), agentReturn.usage,
699
+ // Resolved model for USD pricing (Phase 3). Absent → unpriced → the USD
700
+ // cap fails open for this call.
701
+ agentReturn.servedBy?.model,
702
+ // Char counts for the opt-in unmeasured-driver ≈$ estimate (warn-only).
703
+ { inputChars: prompt.length, outputChars: agentReturn.text.length });
704
+ // P1: fold this refine-loop agent call into the current step's usage.
705
+ accumulateAgentUsage(currentStepUsage, agentReturn.usage, agentReturn.servedBy, priceTable);
706
+ const text = agentReturn.text;
707
+ // Same failure detection as the main agent branch: explicit failure
708
+ // marker or silent-fail patterns ⇒ not a usable result.
709
+ if (text.startsWith("[agent step failed:") || detectSilentFail(text)) {
710
+ return { value: text, ok: false };
711
+ }
712
+ const stripped = stripLeadingNarration(text);
713
+ if (!stripped.trim()) {
714
+ return { value: stripped, ok: false };
715
+ }
716
+ try {
717
+ const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [
718
+ null,
719
+ stripped,
720
+ ];
721
+ const parsed = sanitizeParsed(JSON.parse((jsonMatch[1] ?? "").trim()));
722
+ return { value: parsed, ok: true };
723
+ }
724
+ catch {
725
+ return { value: stripped, ok: true };
726
+ }
727
+ };
728
+ const runJudgeRefineLoop = async (params) => {
729
+ const { agentCfg, reviewsKey, maxRevisions, judgeStepId, firstVerdict, judgeStepResult, failOpenAgent, } = params;
730
+ // Find the agent step whose output the judge reviews. A judge that
731
+ // reviews a tool step or a seed var (no agent to re-run) cannot be
732
+ // refined — skip the loop gracefully, leaving the augment-only verdict
733
+ // already stashed on the judge step result untouched.
734
+ const reviewedStep = recipe.steps.find((s) => s.agent && (s.agent.into ?? "agent_output") === reviewsKey);
735
+ if (!reviewedStep?.agent) {
736
+ return { haltAfterFailure: false };
737
+ }
738
+ const reviewedAgent = reviewedStep.agent;
739
+ let currentVerdict = firstVerdict;
740
+ let revisions = 0;
741
+ while (revisions < maxRevisions &&
742
+ currentVerdict.verdict === "request_changes") {
743
+ // Budget gate: never exceed the run's token budget. If admission is
744
+ // refused, stop early (treat as exhausted) — the budget halt is
745
+ // surfaced by the next top-of-loop admission check for later steps.
746
+ const admission = runBudget.admit();
747
+ if (!admission.admitted) {
748
+ break;
749
+ }
750
+ // REVISE: re-run the reviewed agent with the prior draft + fixList.
751
+ const priorDraft = ctx[reviewsKey];
752
+ const fixList = currentVerdict.fixList ?? [];
753
+ const revisionBlock = `\n\n<revision-request>\n` +
754
+ `A reviewer requested changes to your previous draft. Address every` +
755
+ ` item, then return the full revised draft only.\n\n` +
756
+ `<previous-draft>\n${typeof priorDraft === "string" ? priorDraft : JSON.stringify(priorDraft, null, 2)}\n</previous-draft>\n\n` +
757
+ `<fix-list>\n${fixList.length > 0 ? fixList.map((f) => `- ${f}`).join("\n") : "- (no explicit fix list provided)"}\n</fix-list>\n` +
758
+ `</revision-request>`;
759
+ const revisionPrompt = render(reviewedAgent.prompt, redactSecretsForPrompt(ctx, secretKeys)) +
760
+ revisionBlock;
761
+ const revised = await runAgentText(revisionPrompt, reviewedAgent.driver, reviewedAgent.model, reviewedAgent.mcpAccess, reviewedAgent.downshift);
762
+ if (!revised.ok) {
763
+ // A failed / empty revision can't be re-judged — stop and treat the
764
+ // loop as exhausted with the last good verdict still in place.
765
+ break;
766
+ }
767
+ // R4 #1 (HIGH): stage the revised draft locally — do NOT commit to ctx
768
+ // yet. Committing before the verdict resolves leaves an UNAPPROVED draft
769
+ // in ctx on any loop break (unparseable verdict, budget denial, failed
770
+ // re-judge), so downstream steps treat it as approved. The revised value
771
+ // is only promoted to ctx once a verdict accepts it (approve, or
772
+ // exhaustion with on_exhausted: "proceed").
773
+ const pendingRevised = revised.value;
774
+ // RE-JUDGE: rebuild the judge prompt against the revised artefact. The
775
+ // judge reviews the STAGED draft, not ctx (which still holds the prior
776
+ // accepted value).
777
+ const reJudgePrompt = render(agentCfg.prompt, redactSecretsForPrompt(ctx, secretKeys)) +
778
+ buildJudgeArtefactBlock(pendingRevised) +
779
+ JUDGE_PROMPT_SUFFIX;
780
+ const judged = await runAgentText(reJudgePrompt, agentCfg.driver, agentCfg.model, agentCfg.mcpAccess);
781
+ if (!judged.ok) {
782
+ // Audit 2026-06-03 (MEDIUM #17): a failed / silent-fail / empty
783
+ // RE-JUDGE can't yield a trustworthy verdict. Mirror the revise-
784
+ // failure break above: stop and KEEP the last good verdict. Parsing
785
+ // the failure/empty text would have produced a bogus verdict (usually
786
+ // "unparseable"), silently dropping the request_changes signal and
787
+ // skipping the on_exhausted gate — the run would proceed as if the
788
+ // (unvalidated) revised draft had been approved.
789
+ break;
790
+ }
791
+ const judgedText = typeof judged.value === "string"
792
+ ? judged.value
793
+ : JSON.stringify(judged.value);
794
+ currentVerdict = parseJudgeVerdict(stripLeadingNarration(judgedText));
795
+ revisions++;
796
+ // R4 #2 (HIGH): an UNPARSEABLE verdict exits the while-loop (only
797
+ // "request_changes" continues it), but the exhaustion gate below fires
798
+ // ONLY on "request_changes" — so an unparseable verdict would leave the
799
+ // run 'ok' with the unvalidated draft never committed and no error.
800
+ // Treat it as a hard, non-ok stop (distinct from the failed-re-judge
801
+ // break above, which keeps the prior good verdict). Do NOT promote the
802
+ // staged draft.
803
+ if (currentVerdict.verdict === "unparseable") {
804
+ const reason = `judge "${judgeStepId}" returned an unparseable verdict after revision`;
805
+ judgeStepResult.judgeVerdict = currentVerdict;
806
+ judgeStepResult.revisions = revisions;
807
+ judgeStepResult.status = "error";
808
+ judgeStepResult.error = reason;
809
+ judgeStepResult.haltReason = reason;
810
+ judgeStepResult.haltCategory = "judge_revisions_exhausted";
811
+ return {
812
+ runError: reason,
813
+ haltAfterFailure: !failOpenAgent,
814
+ };
815
+ }
816
+ // R4 #1: verdict accepted the revision (approve, or non-exhausted
817
+ // continuation). Promote the staged draft to ctx so downstream steps and
818
+ // the next iteration see the improved, judged value.
819
+ ctx[reviewsKey] = pendingRevised;
820
+ }
821
+ // Record the FINAL verdict + the revision count on the judge step result.
822
+ judgeStepResult.judgeVerdict = currentVerdict;
823
+ judgeStepResult.revisions = revisions;
824
+ // EXHAUSTION: still requesting changes after the loop.
825
+ if (currentVerdict.verdict === "request_changes") {
826
+ const onExhausted = agentCfg.on_exhausted ?? "halt";
827
+ if (onExhausted === "halt") {
828
+ const reason = `judge "${judgeStepId}" did not approve after ${maxRevisions} revisions`;
829
+ judgeStepResult.status = "error";
830
+ judgeStepResult.error = reason;
831
+ judgeStepResult.haltReason = reason;
832
+ judgeStepResult.haltCategory = "judge_revisions_exhausted";
833
+ return {
834
+ runError: reason,
835
+ // Respect fail-open like other agent failures.
836
+ haltAfterFailure: !failOpenAgent,
837
+ };
838
+ }
839
+ // "proceed": leave status ok, keep the recorded (unapproved) verdict.
840
+ }
841
+ return { haltAfterFailure: false };
842
+ };
556
843
  // The step loop is wrapped so an uncaught throw from any unguarded
557
844
  // call site (a `when`/prompt render on a malformed step, a path-jail
558
845
  // re-check, etc.) cannot escape `runYamlRecipe` and strand the
@@ -650,7 +937,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
650
937
  // PR3a: judge prompt convention. Append the structured-verdict
651
938
  // suffix and, when `reviews: <stepId>` is set, inject the
652
939
  // upstream step's output as an <artefact> block.
653
- let renderedPrompt = render(agentCfg.prompt, ctx);
940
+ let renderedPrompt = render(agentCfg.prompt, redactSecretsForPrompt(ctx, secretKeys));
654
941
  if (isJudge) {
655
942
  if (agentCfg.reviews) {
656
943
  renderedPrompt += buildJudgeArtefactBlock(ctx[agentCfg.reviews]);
@@ -660,6 +947,9 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
660
947
  const intoKey = agentCfg.into ?? "agent_output";
661
948
  const stepId = intoKey;
662
949
  const stepStart = Date.now();
950
+ // P1: fresh per-step usage accumulator for this agent step (and any
951
+ // judge→refine re-runs it spawns via runAgentText, which share it).
952
+ currentStepUsage = newStepUsageAccumulator();
663
953
  let agentResult;
664
954
  // Bug (2): fail-open semantics for THIS agent step. Mirrors the
665
955
  // tool-branch `failOpen` (step.optional OR recipe-level
@@ -677,18 +967,35 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
677
967
  // `RunBudget.reconcile` records a fail-open warning per driver per
678
968
  // run and continues.
679
969
  try {
970
+ // Phase 4: opt-in cost-aware routing. No-op (returns preferred) when
971
+ // the step has no `downshift` list or no USD cap is set.
972
+ const routed = resolveRouting({ driver: agentCfg.driver, model: agentCfg.model }, agentCfg.downshift, renderedPrompt, runBudget);
680
973
  const agentReturn = await _executeAgent({
681
974
  prompt: renderedPrompt,
682
- driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
683
- model: agentCfg.model,
975
+ driver: routed.driver === "api" ? "anthropic" : routed.driver,
976
+ model: routed.model,
684
977
  ...(agentCfg.mcpAccess !== undefined && {
685
978
  mcpAccess: agentCfg.mcpAccess,
686
979
  }),
687
980
  }, buildAgentExecutorDeps(stepDeps, deps));
688
981
  agentResult = agentReturn.text;
689
- runBudget.reconcile(agentCfg.driver === "api"
690
- ? "anthropic"
691
- : (agentCfg.driver ?? "auto"), agentReturn.usage);
982
+ runBudget.reconcile(
983
+ // Prefer the driver executeAgent actually resolved+ran; the routed
984
+ // value is only the fallback for non-executeAgent callers (it is
985
+ // often undefined → previously logged "auto").
986
+ agentReturn.servedBy?.driver ??
987
+ (routed.driver === "api"
988
+ ? "anthropic"
989
+ : (routed.driver ?? "auto")), agentReturn.usage,
990
+ // Resolved model for USD pricing (Phase 3); absent → fail open.
991
+ agentReturn.servedBy?.model,
992
+ // Char counts for the opt-in unmeasured-driver ≈$ estimate.
993
+ {
994
+ inputChars: renderedPrompt.length,
995
+ outputChars: agentReturn.text.length,
996
+ });
997
+ // P1: fold this primary agent call into the current step's usage.
998
+ accumulateAgentUsage(currentStepUsage, agentReturn.usage, agentReturn.servedBy, priceTable);
692
999
  // Catch both `[agent step failed: ...]` (existing) and the
693
1000
  // silent-fail patterns `[agent step skipped: ...]` etc. via the
694
1001
  // shared detector. Per-step opt-out via `silentFailDetection: false`.
@@ -750,13 +1057,43 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
750
1057
  const judgeVerdict = isJudge
751
1058
  ? parseJudgeVerdict(stripped)
752
1059
  : undefined;
753
- stepResults.push({
1060
+ const judgeStepResult = {
754
1061
  id: stepId,
755
1062
  tool: "agent",
756
1063
  status: "ok",
757
1064
  ...(judgeVerdict !== undefined && { judgeVerdict }),
758
1065
  durationMs: Date.now() - stepStart,
759
- });
1066
+ };
1067
+ stepResults.push(judgeStepResult);
1068
+ // ── OPT-IN judge → refine loop ───────────────────────────────
1069
+ // ⚠️ INVARIANT DEPARTURE: when the judge step opts in via
1070
+ // `max_revisions > 0`, a `request_changes` verdict now DRIVES a
1071
+ // bounded revise→re-judge loop instead of merely stashing the
1072
+ // verdict. This deliberately departs the augment-only invariant
1073
+ // (see judgeVerdict.ts) — but ONLY when the opt-in fields are
1074
+ // present. With them absent the block below is skipped entirely
1075
+ // and behavior is byte-identical to the PR3a augment-only path.
1076
+ if (isJudge &&
1077
+ agentCfg.reviews &&
1078
+ typeof agentCfg.max_revisions === "number" &&
1079
+ agentCfg.max_revisions > 0 &&
1080
+ judgeVerdict?.verdict === "request_changes") {
1081
+ const loopOutcome = await runJudgeRefineLoop({
1082
+ agentCfg,
1083
+ reviewsKey: agentCfg.reviews,
1084
+ maxRevisions: agentCfg.max_revisions,
1085
+ judgeStepId: stepId,
1086
+ firstVerdict: judgeVerdict,
1087
+ judgeStepResult,
1088
+ failOpenAgent,
1089
+ });
1090
+ if (loopOutcome.runError !== undefined) {
1091
+ runError = runError ?? loopOutcome.runError;
1092
+ }
1093
+ if (loopOutcome.haltAfterFailure) {
1094
+ haltAfterFailure = true;
1095
+ }
1096
+ }
760
1097
  // Slice 2 — per-step expect eval. Runs on the value just
761
1098
  // committed to ctx[intoKey]. Halt failure flips the just-pushed
762
1099
  // result to error and rolls back the ctx commit so downstream
@@ -802,6 +1139,14 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
802
1139
  durationMs: Date.now() - stepStart,
803
1140
  });
804
1141
  }
1142
+ // P1: attach this agent step's summed token usage (across primary +
1143
+ // any judge→refine re-runs) to the result just pushed, and fold it
1144
+ // into the run-level total. Fields are ABSENT when no usage measured.
1145
+ const pushedAgentResult = stepResults[stepResults.length - 1];
1146
+ if (pushedAgentResult) {
1147
+ Object.assign(pushedAgentResult, stepUsageFields(currentStepUsage));
1148
+ }
1149
+ foldStepIntoRun(runUsage, currentStepUsage);
805
1150
  stepsRun++;
806
1151
  persistLiveStepResults();
807
1152
  emitStepDone(stepIdForEmit);
@@ -1059,8 +1404,21 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
1059
1404
  ...(s.haltReason ? { haltReason: s.haltReason } : {}),
1060
1405
  ...(s.haltCategory ? { haltCategory: s.haltCategory } : {}),
1061
1406
  ...(s.judgeVerdict ? { judgeVerdict: s.judgeVerdict } : {}),
1407
+ // P1: carry per-step token usage through to the persisted run row.
1408
+ // Absent for tool / unmeasured-driver steps (round-trips unchanged).
1409
+ ...(typeof s.inputTokens === "number"
1410
+ ? { inputTokens: s.inputTokens }
1411
+ : {}),
1412
+ ...(typeof s.outputTokens === "number"
1413
+ ? { outputTokens: s.outputTokens }
1414
+ : {}),
1415
+ ...(typeof s.costUsd === "number" ? { costUsd: s.costUsd } : {}),
1062
1416
  durationMs: s.durationMs,
1063
1417
  }));
1418
+ // P1: run-level token aggregate + budget totals (latter only when a
1419
+ // budget was configured — never persist all-zero no-budget totals).
1420
+ const tokenTotals = runTokenTotals(runUsage);
1421
+ const budgetTotals = recipe.budget ? runBudget.totals() : undefined;
1064
1422
  if (deps.runLog && runSeq !== undefined) {
1065
1423
  deps.runLog.completeRun(runSeq, {
1066
1424
  status: runError ? "error" : "done",
@@ -1071,6 +1429,11 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
1071
1429
  ...(runError !== undefined && { errorMessage: runError }),
1072
1430
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
1073
1431
  ...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
1432
+ ...(runBudget.finalWarnings().length > 0
1433
+ ? { budgetWarnings: runBudget.finalWarnings() }
1434
+ : {}),
1435
+ ...(tokenTotals ? { tokenTotals } : {}),
1436
+ ...(budgetTotals ? { budgetTotals } : {}),
1074
1437
  });
1075
1438
  emit("recipe_done", {
1076
1439
  runSeq,
@@ -1108,6 +1471,8 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
1108
1471
  stepResults: finalStepResults,
1109
1472
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
1110
1473
  ...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
1474
+ ...(tokenTotals ? { tokenTotals } : {}),
1475
+ ...(budgetTotals ? { budgetTotals } : {}),
1111
1476
  });
1112
1477
  }
1113
1478
  }
@@ -1154,6 +1519,14 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
1154
1519
  stepResults,
1155
1520
  errorMessage: runError,
1156
1521
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
1522
+ ...(runBudget.finalWarnings().length > 0
1523
+ ? { budgetWarnings: runBudget.finalWarnings() }
1524
+ : {}),
1525
+ // P1: forward run-level token aggregate to callers / persisters.
1526
+ ...(() => {
1527
+ const tt = runTokenTotals(runUsage);
1528
+ return tt ? { tokenTotals: tt } : {};
1529
+ })(),
1157
1530
  };
1158
1531
  }
1159
1532
  export async function executeStep(step, ctx, deps) {
@@ -1554,6 +1927,43 @@ export function defaultClaudeCodeFn(prompt, opts) {
1554
1927
  return Promise.resolve(`[agent step failed: ${err instanceof Error ? err.message : String(err)}]`);
1555
1928
  }
1556
1929
  }
1930
+ /**
1931
+ * Map a driver's `providerMeta` to AgentUsage. Returns undefined unless BOTH
1932
+ * token counts are present as numbers — a half-populated count would mislead
1933
+ * RunBudget. Pure + exported for tests.
1934
+ */
1935
+ export function providerMetaToUsage(meta) {
1936
+ if (!meta)
1937
+ return undefined;
1938
+ const inputTokens = meta.inputTokens;
1939
+ const outputTokens = meta.outputTokens;
1940
+ if (typeof inputTokens === "number" && typeof outputTokens === "number") {
1941
+ return { inputTokens, outputTokens };
1942
+ }
1943
+ return undefined;
1944
+ }
1945
+ const ROUTER_CHARS_PER_TOKEN = 4;
1946
+ /**
1947
+ * Apply opt-in cost-aware routing (Phase 4) to choose the driver/model for an
1948
+ * agent dispatch. Returns `preferred` UNCHANGED when there is no downshift list
1949
+ * or no USD cap is set (byte-identical to no routing). The output-token figure
1950
+ * is a deliberately-rough 1:1-of-input pre-dispatch estimate — enough to pick a
1951
+ * gear; the real cost is reconciled after the call (see the cost-routing ADR).
1952
+ * Exported for unit testing.
1953
+ */
1954
+ export function resolveRouting(preferred, downshift, promptText, budget) {
1955
+ if (!downshift || downshift.length === 0)
1956
+ return preferred;
1957
+ const remainingUsd = budget.remainingUsd();
1958
+ if (remainingUsd === undefined)
1959
+ return preferred; // no USD cap → no routing
1960
+ const estInputTokens = Math.ceil(promptText.length / ROUTER_CHARS_PER_TOKEN);
1961
+ const estOutputTokens = estInputTokens; // rough 1:1 assumption (documented)
1962
+ return costRouter(preferred, downshift, {
1963
+ remainingUsd,
1964
+ quote: (driver, model) => budget.quoteUsd(driver, model, estInputTokens, estOutputTokens),
1965
+ });
1966
+ }
1557
1967
  /** Returns a providerDriverFn with a per-run driver cache (not shared across runs). */
1558
1968
  export function makeProviderDriverFn() {
1559
1969
  const cache = new Map();
@@ -1599,6 +2009,26 @@ export function makeProviderDriverFn() {
1599
2009
  if (!result.text) {
1600
2010
  return `[agent step failed: ${driverName} returned empty output (possible timeout or auth error)]`;
1601
2011
  }
2012
+ // Forward token usage (when the driver reported it) so RunBudget can
2013
+ // enforce a real budget for openai/grok/gemini instead of failing
2014
+ // open. No usage → bare string, normalised to {text} downstream.
2015
+ const usage = providerMetaToUsage(result.providerMeta);
2016
+ // Carry the model the driver ACTUALLY resolved+billed (providerMeta.
2017
+ // model, e.g. openai's "gpt-4o" default when the step omitted model) so
2018
+ // RunBudget prices the real model. executeAgent's stamp() is idempotent
2019
+ // — it preserves this servedBy rather than re-deriving from raw input.
2020
+ const resolvedModel = typeof result.providerMeta?.model === "string"
2021
+ ? result.providerMeta.model
2022
+ : undefined;
2023
+ if (usage || resolvedModel) {
2024
+ return {
2025
+ text: result.text,
2026
+ ...(usage ? { usage } : {}),
2027
+ ...(resolvedModel
2028
+ ? { servedBy: { driver: driverName, model: resolvedModel } }
2029
+ : {}),
2030
+ };
2031
+ }
1602
2032
  return result.text;
1603
2033
  }
1604
2034
  finally {
@@ -1610,13 +2040,30 @@ export function makeProviderDriverFn() {
1610
2040
  }
1611
2041
  };
1612
2042
  }
1613
- async function defaultClaudeFn(prompt, model) {
2043
+ /** Default Anthropic API request timeout. Mirrors the provider path (300s). */
2044
+ const DEFAULT_CLAUDE_API_TIMEOUT_MS = 300_000;
2045
+ /**
2046
+ * R4 #4 (HIGH): default max output tokens. The old hard-coded 1024 silently
2047
+ * truncated structured JSON (judge verdicts, multi-field agent outputs).
2048
+ */
2049
+ const DEFAULT_CLAUDE_MAX_TOKENS = 4096;
2050
+ export async function defaultClaudeFn(prompt, model, opts) {
1614
2051
  const apiKey = process.env.ANTHROPIC_API_KEY;
1615
2052
  if (!apiKey)
1616
2053
  return { text: "[agent step skipped: ANTHROPIC_API_KEY not set]" };
2054
+ const maxTokens = typeof opts?.maxTokens === "number" && opts.maxTokens > 0
2055
+ ? opts.maxTokens
2056
+ : DEFAULT_CLAUDE_MAX_TOKENS;
2057
+ // R4 #3 (HIGH): abort a stalled gateway instead of hanging the run forever.
2058
+ const timeoutMs = typeof opts?.timeoutMs === "number" && opts.timeoutMs > 0
2059
+ ? opts.timeoutMs
2060
+ : DEFAULT_CLAUDE_API_TIMEOUT_MS;
2061
+ const controller = new AbortController();
2062
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1617
2063
  try {
1618
2064
  const res = await fetch("https://api.anthropic.com/v1/messages", {
1619
2065
  method: "POST",
2066
+ signal: controller.signal,
1620
2067
  headers: {
1621
2068
  "x-api-key": apiKey,
1622
2069
  "anthropic-version": "2023-06-01",
@@ -1624,7 +2071,7 @@ async function defaultClaudeFn(prompt, model) {
1624
2071
  },
1625
2072
  body: JSON.stringify({
1626
2073
  model,
1627
- max_tokens: 1024,
2074
+ max_tokens: maxTokens,
1628
2075
  messages: [
1629
2076
  {
1630
2077
  role: "user",
@@ -1642,7 +2089,12 @@ async function defaultClaudeFn(prompt, model) {
1642
2089
  // versions) and downstream (subscription/CLI driver returns
1643
2090
  // undefined here).
1644
2091
  const data = (await res.json());
1645
- const text = data.content?.[0]?.text ?? "[agent step failed: empty response]";
2092
+ let text = data.content?.[0]?.text ?? "[agent step failed: empty response]";
2093
+ // R4 #4: detect+warn when the response was cut off at the token cap so a
2094
+ // truncated (likely unparseable) JSON payload isn't silently trusted.
2095
+ if (data.stop_reason === "max_tokens") {
2096
+ text = `[warning: response truncated at max_tokens=${maxTokens}; raise max_tokens]\n${text}`;
2097
+ }
1646
2098
  const inputTokens = data.usage?.input_tokens;
1647
2099
  const outputTokens = data.usage?.output_tokens;
1648
2100
  if (typeof inputTokens === "number" && typeof outputTokens === "number") {
@@ -1651,10 +2103,20 @@ async function defaultClaudeFn(prompt, model) {
1651
2103
  return { text };
1652
2104
  }
1653
2105
  catch (err) {
2106
+ const aborted = controller.signal.aborted ||
2107
+ (err instanceof Error && err.name === "AbortError");
2108
+ if (aborted) {
2109
+ return {
2110
+ text: `[agent step failed: Anthropic API request timed out after ${timeoutMs}ms]`,
2111
+ };
2112
+ }
1654
2113
  return {
1655
2114
  text: `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`,
1656
2115
  };
1657
2116
  }
2117
+ finally {
2118
+ clearTimeout(timeout);
2119
+ }
1658
2120
  }
1659
2121
  export async function defaultLocalFn(prompt, model) {
1660
2122
  try {
@@ -1749,16 +2211,17 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
1749
2211
  return result ?? "";
1750
2212
  };
1751
2213
  const executeAgent = async (prompt, model, driver, mcpAccess) => {
1752
- // chainedRunner's AgentExecutor contract still returns a plain string
1753
- // PR2b's token-budget consumer will plug in here as well, but for now
1754
- // we discard `.usage`.
1755
- const result = await _executeAgent({
2214
+ // Surface the FULL AgentResult (text + usage + servedBy) so the chained
2215
+ // runner can reconcile real spend against the run budget alignment with
2216
+ // the flat path, which already reads `.usage`. (Previously this closure
2217
+ // discarded everything but `.text`, leaving the chained path's budget
2218
+ // unenforced — the S1 SECURITY finding.)
2219
+ return _executeAgent({
1756
2220
  prompt,
1757
2221
  model,
1758
- driver,
2222
+ driver: driver === "api" ? "anthropic" : driver,
1759
2223
  ...(mcpAccess !== undefined && { mcpAccess }),
1760
2224
  }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
1761
- return result.text;
1762
2225
  };
1763
2226
  // ---------------------------------------------------------------------
1764
2227
  // BEGIN A-PR2 EDIT BLOCK — `loadNestedRecipe` jail (dogfood F-04).