patchwork-os 0.2.0-beta.10.canary.99 → 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.
- package/README.md +20 -1
- package/dist/adapters/grok.js +6 -1
- package/dist/adapters/grok.js.map +1 -1
- package/dist/approvalHttp.js +61 -66
- package/dist/approvalHttp.js.map +1 -1
- package/dist/bridge.js +3 -2
- package/dist/bridge.js.map +1 -1
- package/dist/claudeOrchestrator.js +15 -0
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +7 -0
- package/dist/commands/patchworkInit.js +26 -0
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +14 -0
- package/dist/commands/recipe.js +51 -0
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +15 -0
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/connectors/postgres.d.ts +7 -0
- package/dist/connectors/postgres.js +16 -2
- package/dist/connectors/postgres.js.map +1 -1
- package/dist/connectors/snowflake.js +6 -0
- package/dist/connectors/snowflake.js.map +1 -1
- package/dist/connectors/tokenStorage.d.ts +8 -0
- package/dist/connectors/tokenStorage.js +42 -3
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/drivers/claude/api.js +15 -2
- package/dist/drivers/claude/api.js.map +1 -1
- package/dist/drivers/claude/subprocess.js +1 -29
- package/dist/drivers/claude/subprocess.js.map +1 -1
- package/dist/drivers/gemini/index.js +9 -5
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/drivers/openai/index.js +29 -4
- package/dist/drivers/openai/index.js.map +1 -1
- package/dist/drivers/outputCap.d.ts +27 -0
- package/dist/drivers/outputCap.js +50 -0
- package/dist/drivers/outputCap.js.map +1 -0
- package/dist/featureFlags.d.ts +9 -0
- package/dist/featureFlags.js +16 -0
- package/dist/featureFlags.js.map +1 -1
- package/dist/fp/commandDescription.d.ts +6 -0
- package/dist/fp/commandDescription.js +4 -4
- package/dist/fp/commandDescription.js.map +1 -1
- package/dist/fp/policyParser.js +13 -7
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/haltPushDispatch.js +7 -22
- package/dist/haltPushDispatch.js.map +1 -1
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -1
- package/dist/mcpRoutes.js +1 -1
- package/dist/mcpRoutes.js.map +1 -1
- package/dist/oauth.d.ts +13 -0
- package/dist/oauth.js +34 -1
- package/dist/oauth.js.map +1 -1
- package/dist/recipeOrchestration.js +10 -0
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +39 -0
- package/dist/recipeRoutes.js +214 -12
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +20 -0
- package/dist/recipes/agentExecutor.js +32 -10
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +41 -2
- package/dist/recipes/chainedRunner.js +134 -6
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/connectorPreflight.js +30 -0
- package/dist/recipes/connectorPreflight.js.map +1 -1
- package/dist/recipes/haltCategory.d.ts +5 -0
- package/dist/recipes/haltCategory.js +7 -1
- package/dist/recipes/haltCategory.js.map +1 -1
- package/dist/recipes/judgeVerdict.d.ts +10 -0
- package/dist/recipes/judgeVerdict.js +10 -0
- package/dist/recipes/judgeVerdict.js.map +1 -1
- package/dist/recipes/names.d.ts +5 -0
- package/dist/recipes/names.js +10 -5
- package/dist/recipes/names.js.map +1 -1
- package/dist/recipes/parser.js +32 -9
- package/dist/recipes/parser.js.map +1 -1
- package/dist/recipes/pricing/costRouter.d.ts +43 -0
- package/dist/recipes/pricing/costRouter.js +44 -0
- package/dist/recipes/pricing/costRouter.js.map +1 -0
- package/dist/recipes/pricing/priceTable.d.ts +76 -0
- package/dist/recipes/pricing/priceTable.js +144 -0
- package/dist/recipes/pricing/priceTable.js.map +1 -0
- package/dist/recipes/runBudget.d.ts +82 -32
- package/dist/recipes/runBudget.js +182 -49
- package/dist/recipes/runBudget.js.map +1 -1
- package/dist/recipes/schema.d.ts +25 -4
- package/dist/recipes/schemaGenerator.d.ts +9 -0
- package/dist/recipes/schemaGenerator.js +386 -75
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/simulation/aggregateRunRisk.d.ts +43 -0
- package/dist/recipes/simulation/aggregateRunRisk.js +117 -0
- package/dist/recipes/simulation/aggregateRunRisk.js.map +1 -0
- package/dist/recipes/simulation/costProjector.d.ts +32 -0
- package/dist/recipes/simulation/costProjector.js +194 -0
- package/dist/recipes/simulation/costProjector.js.map +1 -0
- package/dist/recipes/simulation/sideEffects.d.ts +32 -0
- package/dist/recipes/simulation/sideEffects.js +62 -0
- package/dist/recipes/simulation/sideEffects.js.map +1 -0
- package/dist/recipes/simulation/simulate.d.ts +36 -0
- package/dist/recipes/simulation/simulate.js +264 -0
- package/dist/recipes/simulation/simulate.js.map +1 -0
- package/dist/recipes/simulation/simulateMockedRun.d.ts +52 -0
- package/dist/recipes/simulation/simulateMockedRun.js +72 -0
- package/dist/recipes/simulation/simulateMockedRun.js.map +1 -0
- package/dist/recipes/simulation/synthesizeMockedOutputs.d.ts +31 -0
- package/dist/recipes/simulation/synthesizeMockedOutputs.js +50 -0
- package/dist/recipes/simulation/synthesizeMockedOutputs.js.map +1 -0
- package/dist/recipes/simulation/types.d.ts +198 -0
- package/dist/recipes/simulation/types.js +30 -0
- package/dist/recipes/simulation/types.js.map +1 -0
- package/dist/recipes/stepObservation.d.ts +12 -0
- package/dist/recipes/stepObservation.js +21 -1
- package/dist/recipes/stepObservation.js.map +1 -1
- package/dist/recipes/tools/airtable.d.ts +15 -0
- package/dist/recipes/tools/airtable.js +240 -0
- package/dist/recipes/tools/airtable.js.map +1 -0
- package/dist/recipes/tools/caldiy.d.ts +13 -0
- package/dist/recipes/tools/caldiy.js +214 -0
- package/dist/recipes/tools/caldiy.js.map +1 -0
- package/dist/recipes/tools/circleci.d.ts +10 -0
- package/dist/recipes/tools/circleci.js +204 -0
- package/dist/recipes/tools/circleci.js.map +1 -0
- package/dist/recipes/tools/cloudflare.d.ts +13 -0
- package/dist/recipes/tools/cloudflare.js +211 -0
- package/dist/recipes/tools/cloudflare.js.map +1 -0
- package/dist/recipes/tools/docs.d.ts +18 -0
- package/dist/recipes/tools/docs.js +94 -0
- package/dist/recipes/tools/docs.js.map +1 -0
- package/dist/recipes/tools/elasticsearch.d.ts +11 -0
- package/dist/recipes/tools/elasticsearch.js +156 -0
- package/dist/recipes/tools/elasticsearch.js.map +1 -0
- package/dist/recipes/tools/figma.d.ts +12 -0
- package/dist/recipes/tools/figma.js +194 -0
- package/dist/recipes/tools/figma.js.map +1 -0
- package/dist/recipes/tools/grafana.d.ts +11 -0
- package/dist/recipes/tools/grafana.js +216 -0
- package/dist/recipes/tools/grafana.js.map +1 -0
- package/dist/recipes/tools/http.d.ts +1 -1
- package/dist/recipes/tools/http.js +10 -28
- package/dist/recipes/tools/http.js.map +1 -1
- package/dist/recipes/tools/index.d.ts +26 -0
- package/dist/recipes/tools/index.js +26 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/monday.d.ts +22 -0
- package/dist/recipes/tools/monday.js +242 -0
- package/dist/recipes/tools/monday.js.map +1 -0
- package/dist/recipes/tools/obsidian.d.ts +15 -0
- package/dist/recipes/tools/obsidian.js +172 -0
- package/dist/recipes/tools/obsidian.js.map +1 -0
- package/dist/recipes/tools/paystack.d.ts +11 -0
- package/dist/recipes/tools/paystack.js +211 -0
- package/dist/recipes/tools/paystack.js.map +1 -0
- package/dist/recipes/tools/pipedrive.d.ts +16 -0
- package/dist/recipes/tools/pipedrive.js +233 -0
- package/dist/recipes/tools/pipedrive.js.map +1 -0
- package/dist/recipes/tools/postgres.d.ts +15 -0
- package/dist/recipes/tools/postgres.js +185 -0
- package/dist/recipes/tools/postgres.js.map +1 -0
- package/dist/recipes/tools/posthog.d.ts +16 -0
- package/dist/recipes/tools/posthog.js +219 -0
- package/dist/recipes/tools/posthog.js.map +1 -0
- package/dist/recipes/tools/redis.d.ts +9 -0
- package/dist/recipes/tools/redis.js +140 -0
- package/dist/recipes/tools/redis.js.map +1 -0
- package/dist/recipes/tools/resend.d.ts +8 -0
- package/dist/recipes/tools/resend.js +153 -0
- package/dist/recipes/tools/resend.js.map +1 -0
- package/dist/recipes/tools/salesforce.d.ts +16 -0
- package/dist/recipes/tools/salesforce.js +184 -0
- package/dist/recipes/tools/salesforce.js.map +1 -0
- package/dist/recipes/tools/sendgrid.d.ts +9 -0
- package/dist/recipes/tools/sendgrid.js +174 -0
- package/dist/recipes/tools/sendgrid.js.map +1 -0
- package/dist/recipes/tools/shopify.d.ts +16 -0
- package/dist/recipes/tools/shopify.js +265 -0
- package/dist/recipes/tools/shopify.js.map +1 -0
- package/dist/recipes/tools/snowflake.d.ts +16 -0
- package/dist/recipes/tools/snowflake.js +173 -0
- package/dist/recipes/tools/snowflake.js.map +1 -0
- package/dist/recipes/tools/stripe.js +13 -12
- package/dist/recipes/tools/stripe.js.map +1 -1
- package/dist/recipes/tools/supabase.d.ts +9 -0
- package/dist/recipes/tools/supabase.js +132 -0
- package/dist/recipes/tools/supabase.js.map +1 -0
- package/dist/recipes/tools/todoist.d.ts +15 -0
- package/dist/recipes/tools/todoist.js +227 -0
- package/dist/recipes/tools/todoist.js.map +1 -0
- package/dist/recipes/tools/twilio.d.ts +11 -0
- package/dist/recipes/tools/twilio.js +180 -0
- package/dist/recipes/tools/twilio.js.map +1 -0
- package/dist/recipes/tools/vercel.d.ts +9 -0
- package/dist/recipes/tools/vercel.js +145 -0
- package/dist/recipes/tools/vercel.js.map +1 -0
- package/dist/recipes/tools/webflow.d.ts +11 -0
- package/dist/recipes/tools/webflow.js +243 -0
- package/dist/recipes/tools/webflow.js.map +1 -0
- package/dist/recipes/tools/woocommerce.d.ts +18 -0
- package/dist/recipes/tools/woocommerce.js +259 -0
- package/dist/recipes/tools/woocommerce.js.map +1 -0
- package/dist/recipes/tools/wrapConnectorExecute.d.ts +25 -0
- package/dist/recipes/tools/wrapConnectorExecute.js +37 -0
- package/dist/recipes/tools/wrapConnectorExecute.js.map +1 -0
- package/dist/recipes/validation.js +264 -0
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +112 -3
- package/dist/recipes/yamlRunner.js +482 -19
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/runLog.d.ts +46 -0
- package/dist/runLog.js +4 -0
- package/dist/runLog.js.map +1 -1
- package/dist/schemas/recipe.v1.json +217 -11
- package/dist/server.d.ts +3 -0
- package/dist/server.js +4 -0
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.js +3 -2
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/gitWrite.js +5 -1
- package/dist/tools/gitWrite.js.map +1 -1
- package/dist/tools/terminal.js +15 -49
- package/dist/tools/terminal.js.map +1 -1
- package/dist/transport.d.ts +1 -0
- package/dist/transport.js +7 -0
- package/dist/transport.js.map +1 -1
- package/package.json +3 -3
- package/scripts/mcp-stdio-shim.cjs +218 -84
- 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:
|
|
683
|
-
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(
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
|
|
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).
|