@sweny-ai/core 0.1.199 → 0.1.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/executor.js CHANGED
@@ -30,7 +30,7 @@ import { toolRunContext } from "./run-context.js";
30
30
  import { fenceUntrusted, fenceUntrustedJson } from "./untrusted.js";
31
31
  import { asClaude } from "./harness/compat.js";
32
32
  import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
33
- import { BudgetGuard, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
33
+ import { BudgetGuard, BudgetRefusal, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
34
34
  import { createRunDecider, wantsDecider } from "./decider.js";
35
35
  import { redact, runSecretValues } from "./journal.js";
36
36
  import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
@@ -90,6 +90,7 @@ export async function execute(workflow, input, options) {
90
90
  };
91
91
  results.set(at.node, result);
92
92
  trace.steps.push({ node: at.node, status: "failed", iteration: at.iteration });
93
+ at.syncSpend?.();
93
94
  safeObserve(options.observer, { type: "node:exit", node: at.node, result }, logger);
94
95
  logger.error(` ${err.message}`, { node: at.node });
95
96
  journalDegraded();
@@ -130,6 +131,130 @@ async function executeRun(workflow, input, options, results, trace, at) {
130
131
  // Spend budgets (#449): the run ceiling is the lower of the workflow's
131
132
  // `budget` and the caller's (`--max-tokens`, `--max-cost`).
132
133
  const budgetGuard = new BudgetGuard(minLimits(toLimits(workflow.budget), toLimits(options.budget)));
134
+ // The executor's own completions (judge, route, retry reflection, safe-output
135
+ // screen) report usage into the same guard as node runs, under their kind,
136
+ // and into the journal so a resume keeps them. Every call passes a gate
137
+ // first: once a ceiling is reached (or a usage record could not be
138
+ // journaled) the call is refused, not made. `sink` counts one completion's
139
+ // total and keeps any breach for the caller; it never throws into the
140
+ // adapter, so a journal failure inside it is held in `meterFault` and
141
+ // rethrown by the executor at the next step.
142
+ let meterFault;
143
+ const throwIfMeterFault = () => {
144
+ if (meterFault)
145
+ throw meterFault;
146
+ };
147
+ const metered = (refuse, sink) => {
148
+ const gate = () => {
149
+ throwIfMeterFault();
150
+ const stop = refuse();
151
+ if (stop)
152
+ throw new BudgetRefusal(stop);
153
+ };
154
+ return {
155
+ defaultJudgeModel: claude.defaultJudgeModel,
156
+ run: (req) => claude.run(req),
157
+ evaluate: async (opts) => {
158
+ gate();
159
+ return claude.evaluate({ ...opts, onUsage: sink });
160
+ },
161
+ ask: async (opts) => {
162
+ gate();
163
+ return claude.ask({ ...opts, onUsage: sink });
164
+ },
165
+ };
166
+ };
167
+ const journalMeter = (kind, node, iteration, u) => {
168
+ if (!journal?.meter || meterFault)
169
+ return;
170
+ try {
171
+ journal.meter(kind, node, iteration, u);
172
+ }
173
+ catch (err) {
174
+ meterFault = err;
175
+ }
176
+ };
177
+ const syncSpend = () => {
178
+ if (budgetGuard.hasMeteredCalls)
179
+ trace.spend = budgetGuard.breakdown();
180
+ };
181
+ at.syncSpend = syncSpend;
182
+ // A route call that crossed the run budget, or was refused because the run
183
+ // budget was already spent. The next node is then refused at its start; a
184
+ // route that ends the run fails the node it left.
185
+ let routeBreach;
186
+ let routingFrom;
187
+ const routeClaude = metered(() => (routeBreach ??= budgetGuard.runExhausted()), (u) => {
188
+ const b = budgetGuard.meter("route", u);
189
+ if (routingFrom)
190
+ journalMeter("route", routingFrom.node, routingFrom.iteration, u);
191
+ syncSpend();
192
+ if (b) {
193
+ routeBreach ??= b;
194
+ logger.warn(` ${describeOverrun(b)}; the next node does not start`);
195
+ }
196
+ });
197
+ // Routing from `from`. `budgetStop` says the run ended there on the budget:
198
+ // the caller then writes no route record, so the journal keeps the visit
199
+ // ended but unrouted. A resume (with a larger cap) then routes a visit that
200
+ // succeeded again, like a crash between node:end and the route; a visit
201
+ // that had failed (`on_fail: continue`) is run again.
202
+ const advance = async (from) => {
203
+ routeBreach = undefined;
204
+ routingFrom = { node: from, iteration: nodeRunCounts.get(from) ?? 1 };
205
+ // No paid routing once the run budget is spent: the decider (an HTTP
206
+ // model call) is skipped like the agent's route evaluation, which the gate refuses.
207
+ const spentBeforeRouting = budgetGuard.runExhausted();
208
+ if (spentBeforeRouting && routing.decider && !trace.deciderOff)
209
+ trace.deciderOff = "run budget spent";
210
+ const routeOpts = spentBeforeRouting ? { ...routing, decider: null } : routing;
211
+ let routed;
212
+ let refused = false;
213
+ try {
214
+ routed = await advanceFromNode(workflow, from, results, input, routeClaude, observer, edgeCounts, logger, trace, routeOpts);
215
+ }
216
+ catch (err) {
217
+ throwIfMeterFault();
218
+ if (!(err instanceof BudgetRefusal))
219
+ throw err;
220
+ logger.warn(` ${describeOverrun(err.breach)}; not routing from '${from}'`, { node: from });
221
+ routed = { next: null };
222
+ refused = true;
223
+ }
224
+ finally {
225
+ routingFrom = undefined;
226
+ }
227
+ throwIfMeterFault();
228
+ const breach = routeBreach;
229
+ routeBreach = undefined;
230
+ if (breach && routed.next === null) {
231
+ // No node is left to refuse: the stop lands on the node the route left.
232
+ const prior = results.get(from);
233
+ if (prior && !prior.budget) {
234
+ const error = describeOverrun(breach, from);
235
+ // A node that had already failed keeps its own error; the budget reason has its own field.
236
+ const priorError = prior.data?.error;
237
+ const amended = {
238
+ ...prior,
239
+ status: "failed",
240
+ data: { ...prior.data, error: priorError ?? error, budget_error: error, budget_exceeded: true },
241
+ budget: breach,
242
+ };
243
+ results.set(from, amended);
244
+ // The trace and observers already saw the visit end: they get the amended result as an amendment, not a second exit.
245
+ const iteration = nodeRunCounts.get(from) ?? 1;
246
+ const step = [...trace.steps].reverse().find((st) => st.node === from && st.iteration === iteration);
247
+ if (step)
248
+ step.status = "failed";
249
+ safeObserve(observer, { type: "node:amended", node: from, result: amended }, logger);
250
+ }
251
+ // Routing already announced its own end (an invalid target); a refused route announced nothing.
252
+ if (refused)
253
+ safeObserve(observer, { type: "route", from, to: "(end)", reason: "budget exceeded" }, logger);
254
+ return { ...routed, budgetStop: true };
255
+ }
256
+ return routed;
257
+ };
133
258
  const harnessPolicy = options.harnessPolicy ?? resolveHarnessPolicy(runEnv);
134
259
  // #473: a checkout that persisted a git credential (actions/checkout's
135
260
  // default). Each harness masks it for read-only and staged nodes, or reports
@@ -279,8 +404,9 @@ async function executeRun(workflow, input, options, results, trace, at) {
279
404
  logger.info(` replayed from the run journal: ${replay.result.status}`, { node: currentId });
280
405
  const from = currentId;
281
406
  if (replay.next === undefined) {
282
- const routed = await advanceFromNode(workflow, from, results, input, claude, observer, edgeCounts, logger, trace, routing);
283
- journal?.route(from, routed.next, routed.rung, decider?.counters);
407
+ const routed = await advance(from);
408
+ if (!routed.budgetStop)
409
+ journal?.route(from, routed.next, routed.rung, decider?.counters);
284
410
  currentId = routed.next;
285
411
  }
286
412
  else {
@@ -304,6 +430,27 @@ async function executeRun(workflow, input, options, results, trace, at) {
304
430
  continue;
305
431
  }
306
432
  journal?.nodeStart(currentId, iteration);
433
+ // The run budget is already spent (by a route call, or by the node before
434
+ // a skipped one): this node neither starts nor routes on, whatever its
435
+ // `requires` say, so a skipped node cannot carry the run past the stop.
436
+ // A checkpointed visit (resume) already ran its agent: its writes still apply.
437
+ const runSpent = replay?.kind === "checkpoint" ? undefined : budgetGuard.runExhausted();
438
+ if (runSpent) {
439
+ const error = describeOverrun(runSpent, currentId);
440
+ const result = {
441
+ status: "failed",
442
+ data: { error, budget_exceeded: true },
443
+ toolCalls: [],
444
+ budget: runSpent,
445
+ };
446
+ results.set(currentId, result);
447
+ journal?.nodeEnd(currentId, iteration, result, writeState);
448
+ trace.steps.push({ node: currentId, status: "failed", iteration });
449
+ safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
450
+ logger.warn(` ${error}; not starting the node`, { node: currentId });
451
+ safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "node failed (budget exceeded)" }, logger);
452
+ break;
453
+ }
307
454
  // Gather tools from the node's skills, then apply the node's optional
308
455
  // skill-tool filter (`tools.allow` / `tools.deny`). Filtered tools are
309
456
  // never registered for the run, so the model cannot see or call them.
@@ -406,8 +553,9 @@ async function executeRun(workflow, input, options, results, trace, at) {
406
553
  }
407
554
  // Apply normal routing rules (dry run gate + resolveNext).
408
555
  // TODO: dedupe with requires path — see advanceFromNode helper below
409
- const routed = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, routing);
410
- journal?.route(currentId, routed.next, routed.rung, decider?.counters);
556
+ const routed = await advance(currentId);
557
+ if (!routed.budgetStop)
558
+ journal?.route(currentId, routed.next, routed.rung, decider?.counters);
411
559
  currentId = routed.next;
412
560
  continue;
413
561
  }
@@ -496,12 +644,40 @@ async function executeRun(workflow, input, options, results, trace, at) {
496
644
  // run's. The gate says whether the harness can keep them; a strict policy
497
645
  // refuses the node when it cannot report the budgeted unit at all.
498
646
  const nodeBudget = budgetGuard.node(toLimits(node.budget));
647
+ // Resume: this visit's spend before the crash counts against its node
648
+ // budget again (the run total already has it from the seed), whether the
649
+ // write stage is replayed or the visit runs again.
650
+ const visitPrior = replay?.kind === "checkpoint" ? replay.spent : replay ? undefined : journal?.visitSpent?.(currentId, iteration);
651
+ if (visitPrior)
652
+ nodeBudget.restore(visitPrior);
499
653
  const budgetOn = budgetGuard.active(nodeBudget.limits);
500
654
  const budgetCheck = budgetOn && options.harness
501
655
  ? budgetGate(options.harness.capabilities, minLimits(nodeBudget.limits, budgetGuard.runLimits), permissions.strict || harnessPolicy === "strict")
502
656
  : undefined;
503
657
  // A budget stop: fail_soft and on_fail: continue never apply, the run halts.
504
658
  let budgetStop;
659
+ // This visit's own completions (judge scoring, retry reflection, output
660
+ // screen) count against the node and the run; a crossing is kept here.
661
+ let meterBreach;
662
+ const visitId = currentId;
663
+ const visitClaude = (kind) => metered(
664
+ // Latched: once refused, every later call of this visit is refused too.
665
+ () => (meterBreach ??= budgetOn ? nodeBudget.exhausted() : undefined), (u) => {
666
+ const b = nodeBudget.meter(kind, u);
667
+ journalMeter(kind, visitId, iteration, u);
668
+ syncSpend();
669
+ meterBreach ??= b;
670
+ });
671
+ const judgeClaude = visitClaude("judge");
672
+ const reflectClaude = visitClaude("reflect");
673
+ const screenClaude = visitClaude("screen");
674
+ // A ceiling reached before a paid executor call (judges, reflection): the
675
+ // node fails with the budget reason instead of making the call.
676
+ const stopAtCeiling = (r, b) => {
677
+ const error = describeOverrun(b, visitId);
678
+ logger.warn(` ${error}`, { node: currentId });
679
+ return { ...r, status: "failed", data: { ...r.data, error, budget_exceeded: true }, budget: b };
680
+ };
505
681
  let previous;
506
682
  while (true) {
507
683
  if (replay?.kind === "checkpoint") {
@@ -509,6 +685,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
509
685
  // write intents; the write stage below skips what the journal shows was already applied.
510
686
  ({ result, agentRunFailed, attempt } = replay);
511
687
  intents.push(...replay.intents);
688
+ // The visit's own spend before the crash counts against its node budget
689
+ // again (the run total already has it from the seed).
512
690
  break;
513
691
  }
514
692
  const refusal = gate?.refuse ?? budgetCheck?.refuse;
@@ -562,7 +740,6 @@ async function executeRun(workflow, input, options, results, trace, at) {
562
740
  }
563
741
  const journalUsage = journal?.usage
564
742
  ? (u) => {
565
- liveUsage = { ...liveUsage, ...u };
566
743
  if (journalFault)
567
744
  return;
568
745
  try {
@@ -574,12 +751,12 @@ async function executeRun(workflow, input, options, results, trace, at) {
574
751
  }
575
752
  }
576
753
  : undefined;
577
- const onUsage = attemptBudget || journalUsage
578
- ? (u) => {
579
- attemptBudget?.onUsage(u);
580
- journalUsage?.(u);
581
- }
582
- : undefined;
754
+ // Live usage is always collected: with no limit it still feeds the split by kind.
755
+ const onUsage = (u) => {
756
+ liveUsage = { ...liveUsage, ...u };
757
+ attemptBudget?.onUsage(u);
758
+ journalUsage?.(u);
759
+ };
583
760
  try {
584
761
  result = await claude.run({
585
762
  nodeId: currentId,
@@ -597,13 +774,17 @@ async function executeRun(workflow, input, options, results, trace, at) {
597
774
  ...(nodePolicy ? { policy: nodePolicy } : {}),
598
775
  ...(readOnlyNode || stageOutputs ? {} : { mcpServers: skillMcpServers }),
599
776
  ...(readOnlyNode ? { readOnly: true } : {}),
600
- ...(onUsage ? { onUsage } : {}),
777
+ onUsage,
601
778
  onProgress: (message) => {
602
779
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
603
780
  },
604
781
  });
605
782
  // Commit the attempt's spend before anything here can throw (#549); `finish` below reads the breach.
606
- attemptBudget?.settle(result.usage);
783
+ if (attemptBudget)
784
+ attemptBudget.settle(result.usage);
785
+ // No limit to enforce: still count the run, so the split by kind is complete.
786
+ else
787
+ budgetGuard.meter("node", attemptSpend(result.usage, liveUsage));
607
788
  }
608
789
  catch (err) {
609
790
  if (journalFault)
@@ -741,13 +922,37 @@ async function executeRun(workflow, input, options, results, trace, at) {
741
922
  result.evals = [failure];
742
923
  }
743
924
  else {
744
- const evalResults = await evaluateAll(node.eval, result, {
745
- aliases: toolAliases,
746
- claude,
747
- node,
748
- workflow,
749
- });
750
- result.evals = evalResults;
925
+ // With a budget on, judges run one at a time, so a crossing (or a
926
+ // ceiling already reached) refuses the rest instead of all starting at once.
927
+ let evalResults;
928
+ try {
929
+ evalResults = await evaluateAll(node.eval, result, {
930
+ aliases: toolAliases,
931
+ claude: judgeClaude,
932
+ node,
933
+ workflow,
934
+ sequential: budgetOn,
935
+ ...(signal ? { signal } : {}),
936
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
937
+ });
938
+ }
939
+ catch (err) {
940
+ throwIfMeterFault();
941
+ if (!(err instanceof BudgetRefusal))
942
+ throw err;
943
+ }
944
+ throwIfMeterFault();
945
+ if (evalResults)
946
+ result.evals = evalResults;
947
+ if (meterBreach || !evalResults) {
948
+ // Judge spend crossed a ceiling, or a judge was refused at one (the
949
+ // refusal latched it): the node fails whatever the verdicts said.
950
+ const stop = meterBreach ?? nodeBudget.exhausted() ?? budgetGuard.runExhausted();
951
+ result = stop ? stopAtCeiling(result, stop) : result;
952
+ agentRunFailed = true;
953
+ budgetStop = stop;
954
+ break;
955
+ }
751
956
  outcome = aggregateEval(evalResults, node.eval_policy ?? "all_pass");
752
957
  }
753
958
  if (outcome.pass)
@@ -786,13 +991,21 @@ async function executeRun(workflow, input, options, results, trace, at) {
786
991
  evalFailures: outcome.failures,
787
992
  toolCalls: result.toolCalls,
788
993
  nodeInstruction: resolvedInstruction,
789
- claude,
994
+ claude: reflectClaude,
790
995
  logger,
791
996
  context,
792
997
  model: nodeModel,
793
998
  signal,
794
999
  timeoutMs,
795
1000
  });
1001
+ throwIfMeterFault();
1002
+ // A reflection refused at a ceiling (or one whose spend crossed it): no further attempt.
1003
+ if (meterBreach) {
1004
+ result = stopAtCeiling(result, meterBreach);
1005
+ agentRunFailed = true;
1006
+ budgetStop = meterBreach;
1007
+ break;
1008
+ }
796
1009
  currentInstruction = `${preamble}\n\n---\n\n${instruction}`;
797
1010
  safeObserve(observer, { type: "node:retry", node: currentId, attempt: attempt + 1, reason: outcome.error ?? "eval failed", preamble }, logger);
798
1011
  attempt++;
@@ -840,19 +1053,35 @@ async function executeRun(workflow, input, options, results, trace, at) {
840
1053
  state: writeState,
841
1054
  logger,
842
1055
  input: runInput,
843
- screen: async (writes) => claude.ask({
844
- instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
845
- context: { writes },
846
- model: nodeModel,
847
- signal,
848
- timeoutMs,
849
- }),
1056
+ ...(journal?.reconcile
1057
+ ? {
1058
+ reconcile: (sk, tool, args, ctx) => journal.reconcile(visitId, iteration, sk, tool, args, ctx),
1059
+ }
1060
+ : {}),
1061
+ screen: async (writes) => {
1062
+ const verdict = await screenClaude.ask({
1063
+ instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
1064
+ context: { writes },
1065
+ model: nodeModel,
1066
+ signal,
1067
+ timeoutMs,
1068
+ });
1069
+ // A screen whose spend crossed a ceiling, or whose meter record the
1070
+ // journal could not write, allows nothing: the writes are vetoed.
1071
+ return meterBreach || meterFault ? null : verdict;
1072
+ },
850
1073
  });
1074
+ throwIfMeterFault();
851
1075
  result = { ...result, outputs: stage.receipts };
852
1076
  if (stage.error) {
853
1077
  logger.warn(` ${stage.error}`, { node: currentId });
854
1078
  result = { ...result, status: "failed", data: { ...result.data, error: stage.error } };
855
1079
  }
1080
+ if (meterBreach && !budgetStop) {
1081
+ // The screen's spend crossed a ceiling: the node fails and the run halts.
1082
+ result = stopAtCeiling(result, meterBreach);
1083
+ budgetStop = meterBreach;
1084
+ }
856
1085
  }
857
1086
  else if (intents.length > 0) {
858
1087
  result = {
@@ -885,17 +1114,33 @@ async function executeRun(workflow, input, options, results, trace, at) {
885
1114
  }
886
1115
  // Dry run gate + routing — shared with requires path via advanceFromNode helper.
887
1116
  const routedFrom = currentId;
888
- const routed = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, routing);
889
- journal?.route(routedFrom, routed.next, routed.rung, decider?.counters);
1117
+ const routed = await advance(routedFrom);
1118
+ if (!routed.budgetStop)
1119
+ journal?.route(routedFrom, routed.next, routed.rung, decider?.counters);
890
1120
  currentId = routed.next;
891
1121
  }
892
1122
  safeObserve(observer, {
893
1123
  type: "workflow:end",
894
1124
  results: Object.fromEntries(results),
895
1125
  }, logger);
1126
+ syncSpend();
896
1127
  return { results, trace };
897
1128
  }
898
1129
  // ─── Internals ───────────────────────────────────────────────────
1130
+ /**
1131
+ * An attempt's spend as usage: per unit, the larger of the final report and
1132
+ * the last live one (what `BudgetAttempt.settle` commits), so the split is the
1133
+ * same with and without a limit. Undefined when neither reported anything.
1134
+ */
1135
+ function attemptSpend(final, live) {
1136
+ const f = spendOf(final);
1137
+ const l = spendOf(live);
1138
+ const tokens = f.tokens === undefined && l.tokens === undefined ? undefined : Math.max(f.tokens ?? 0, l.tokens ?? 0);
1139
+ const costUsd = f.costUsd === undefined && l.costUsd === undefined ? undefined : Math.max(f.costUsd ?? 0, l.costUsd ?? 0);
1140
+ if (tokens === undefined && costUsd === undefined)
1141
+ return undefined;
1142
+ return { ...(tokens !== undefined ? { inputTokens: tokens } : {}), ...(costUsd !== undefined ? { costUsd } : {}) };
1143
+ }
899
1144
  /**
900
1145
  * Thrown when a conditional routing decision could not be made and there is
901
1146
  * no explicit default (unconditional) edge to fall back to. Fails closed: the
@@ -51,7 +51,7 @@ import { agentEnvOverlay, applyEnvOverlay, finishAgentEnv, heldCredentials, over
51
51
  import { ACP_CAPABILITIES } from "./capabilities.js";
52
52
  import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
53
53
  import { gitCredentialPolicy } from "../git-credentials.js";
54
- import { buildNodePrompt } from "./prompts.js";
54
+ import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
55
55
  import { makeAbort } from "./abort.js";
56
56
  import { parseToolResultContent, schemaMismatches, summarizeToolError, tryParseJSON } from "./parse.js";
57
57
  import { startToolBridge } from "./tool-bridge/server.js";
@@ -937,6 +937,9 @@ export class AcpHarness {
937
937
  }
938
938
  if (conv.agentVersion)
939
939
  this.agentVersion = conv.agentVersion;
940
+ // A completion that failed was still billed: report before the outcome.
941
+ if (conv.costUsd !== undefined)
942
+ reportCompletionUsage(req.onUsage, { costUsd: conv.costUsd }, this.logger);
940
943
  if (conv.spawnError)
941
944
  return failClosed(`the ACP agent could not start: ${conv.spawnError}`);
942
945
  if (conv.abortReason === "timeout")
@@ -8,7 +8,7 @@
8
8
  * This is the ONLY supported LLM backend. The whole point of sweny
9
9
  * is to use headless Claude Code - never the raw Anthropic API.
10
10
  */
11
- import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
11
+ import type { Claude, Tool, ToolContext, NodeUsage, Logger, McpServerConfig } from "../types.js";
12
12
  import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
13
13
  import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, ToolClass } from "./types.js";
14
14
  import { type HarnessPolicyMode } from "./policy.js";
@@ -250,6 +250,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
250
250
  timeoutMs?: number;
251
251
  /** Caller-supplied abort signal. Aborting it interrupts the query. */
252
252
  signal?: AbortSignal;
253
+ onUsage?: (usage: NodeUsage) => void;
253
254
  }): Promise<string | null>;
254
255
  /** Free-text completion. Delegates to core {@link coreAsk} over {@link complete}. */
255
256
  ask(opts: {
@@ -260,6 +261,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
260
261
  timeoutMs?: number;
261
262
  /** Caller-supplied abort signal. Aborting it interrupts the query. */
262
263
  signal?: AbortSignal;
264
+ onUsage?: (usage: NodeUsage) => void;
263
265
  }): Promise<string>;
264
266
  }
265
267
  /** @deprecated Use {@link ClaudeCodeHarnessOptions}. */
@@ -16,7 +16,7 @@ import { AGENT_AUTH_VARS, agentEnvOverlay, applyEnvOverlay, finishAgentEnv, over
16
16
  import { gitCredentialGap, policyGate } from "./policy.js";
17
17
  import { gitCredentialPolicy } from "../git-credentials.js";
18
18
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
19
- import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
19
+ import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt, reportCompletionUsage, } from "./prompts.js";
20
20
  import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
21
21
  import { startToolBridge } from "./tool-bridge/server.js";
22
22
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
@@ -832,6 +832,9 @@ export class ClaudeCodeHarness {
832
832
  if (message.type === "result") {
833
833
  const resultMsg = message;
834
834
  sawResult = true;
835
+ // A completion that failed was still billed: report before the outcome.
836
+ if (req.onUsage)
837
+ reportCompletionUsage(req.onUsage, extractUsage(resultMsg), this.logger);
835
838
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
836
839
  response = resultMsg.result;
837
840
  }
@@ -32,7 +32,7 @@ import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, agentEnvOverlay, applyEnvOverlay,
32
32
  import { CODEX_CAPABILITIES } from "./capabilities.js";
33
33
  import { policyGate, resolveHarnessPolicy } from "./policy.js";
34
34
  import { gitCredentialPolicy } from "../git-credentials.js";
35
- import { buildNodePrompt } from "./prompts.js";
35
+ import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
36
36
  import { makeAbort } from "./abort.js";
37
37
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
38
38
  import { startToolBridge } from "./tool-bridge/server.js";
@@ -1055,6 +1055,8 @@ export class CodexHarness {
1055
1055
  // Classification calls use no tools; any tool call is over budget.
1056
1056
  maxToolCalls: 0,
1057
1057
  });
1058
+ // A completion that failed was still billed: report before the outcome.
1059
+ reportCompletionUsage(req.onUsage, out.usage, this.logger);
1058
1060
  if (out.spawnError)
1059
1061
  return failClosed(`codex could not start: ${out.spawnError}`);
1060
1062
  if (out.abortReason === "timeout")
@@ -35,6 +35,7 @@ export function claudeCompat(claude) {
35
35
  model: req.model,
36
36
  timeoutMs: req.timeoutMs,
37
37
  signal: req.signal,
38
+ ...(req.onUsage ? { onUsage: req.onUsage } : {}),
38
39
  });
39
40
  },
40
41
  };
@@ -10,7 +10,7 @@ export { claudeCompat, asClaude } from "./compat.js";
10
10
  export type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, } from "./types.js";
11
11
  export { policyGate, budgetGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES, } from "./policy.js";
12
12
  export type { HarnessPolicyMode } from "./policy.js";
13
- export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
13
+ export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt, reportCompletionUsage } from "./prompts.js";
14
14
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
15
15
  export type { ClaudeCodeHarnessOptions } from "./claude-code.js";
16
16
  export { CodexHarness, CODEX_CAPABILITIES, MIN_CODEX_VERSION } from "./codex.js";
@@ -9,7 +9,7 @@ import { PiHarness } from "./pi.js";
9
9
  import { parseAcpAgent, unsupportedAgentError } from "./agents.js";
10
10
  export { claudeCompat, asClaude } from "./compat.js";
11
11
  export { policyGate, budgetGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES, } from "./policy.js";
12
- export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
12
+ export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt, reportCompletionUsage } from "./prompts.js";
13
13
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
14
14
  export { CodexHarness, CODEX_CAPABILITIES, MIN_CODEX_VERSION } from "./codex.js";
15
15
  export { AcpHarness, ACP_CAPABILITIES, ACP_PROTOCOL_VERSION } from "./acp.js";
@@ -38,7 +38,7 @@ import { PI_AUTH_VARS, PI_ENV_PREFIXES, PI_NON_SECRET_VARS, agentEnvOverlay, app
38
38
  import { PI_CAPABILITIES } from "./capabilities.js";
39
39
  import { policyGate, resolveHarnessPolicy } from "./policy.js";
40
40
  import { gitCredentialPolicy } from "../git-credentials.js";
41
- import { buildNodePrompt } from "./prompts.js";
41
+ import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
42
42
  import { makeAbort } from "./abort.js";
43
43
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
44
44
  import { startToolBridge } from "./tool-bridge/server.js";
@@ -1006,6 +1006,8 @@ export class PiHarness {
1006
1006
  // Classification calls use no tools; any tool call is over budget.
1007
1007
  maxToolCalls: 0,
1008
1008
  });
1009
+ // A completion that failed was still billed: report before the outcome.
1010
+ reportCompletionUsage(req.onUsage, out.usage, this.logger);
1009
1011
  if (out.spawnError)
1010
1012
  return failClosed(`pi could not start: ${out.spawnError}`);
1011
1013
  if (out.abortReason === "timeout")
@@ -4,8 +4,16 @@
4
4
  * `ClaudeClient.ask` / `ClaudeClient.evaluate` built before the harness seam
5
5
  * (#330); prompts.test.ts pins them against the old builders.
6
6
  */
7
- import type { Claude, JSONSchema, Logger } from "../types.js";
7
+ import type { Claude, JSONSchema, Logger, NodeUsage } from "../types.js";
8
8
  import type { AgentHarness } from "./types.js";
9
+ /**
10
+ * Hand a completion's usage to the caller's `onUsage`, once, before the
11
+ * adapter looks at whether the completion succeeded (a failed one was still
12
+ * billed). A hook that throws is logged and ignored: it never changes the
13
+ * completion's result, so a good answer is never turned into a failure (and
14
+ * a judge or route call is never repeated because of it).
15
+ */
16
+ export declare function reportCompletionUsage(onUsage: ((usage: NodeUsage) => void) | undefined, usage: NodeUsage | undefined, logger?: Logger): void;
9
17
  /** @internal Registers the legacy object behind a compat harness. */
10
18
  export declare function registerLegacyClaude(harness: AgentHarness, claude: Claude): void;
11
19
  /** @internal The legacy `Claude` behind a compat harness, if any. */
@@ -48,6 +56,7 @@ export declare function ask(harness: AgentHarness, opts: {
48
56
  model?: string;
49
57
  timeoutMs?: number;
50
58
  signal?: AbortSignal;
59
+ onUsage?: (usage: NodeUsage) => void;
51
60
  }): Promise<string>;
52
61
  /**
53
62
  * Route evaluation: pick one of N choices. Fails closed: null when the
@@ -62,4 +71,5 @@ export declare function evaluate(harness: AgentHarness, opts: {
62
71
  }[];
63
72
  timeoutMs?: number;
64
73
  signal?: AbortSignal;
74
+ onUsage?: (usage: NodeUsage) => void;
65
75
  }, logger?: Logger): Promise<string | null>;
@@ -6,6 +6,23 @@
6
6
  */
7
7
  import { consoleLogger } from "../types.js";
8
8
  import { fenceUntrustedJson } from "../untrusted.js";
9
+ /**
10
+ * Hand a completion's usage to the caller's `onUsage`, once, before the
11
+ * adapter looks at whether the completion succeeded (a failed one was still
12
+ * billed). A hook that throws is logged and ignored: it never changes the
13
+ * completion's result, so a good answer is never turned into a failure (and
14
+ * a judge or route call is never repeated because of it).
15
+ */
16
+ export function reportCompletionUsage(onUsage, usage, logger) {
17
+ if (!onUsage || !usage)
18
+ return;
19
+ try {
20
+ onUsage(usage);
21
+ }
22
+ catch (err) {
23
+ (logger ?? consoleLogger).warn(`usage hook failed: ${err instanceof Error ? err.message : String(err)}`);
24
+ }
25
+ }
9
26
  /** Legacy `Claude` objects wrapped by `claudeCompat()`, so their own ask/evaluate still win. */
10
27
  const LEGACY = new WeakMap();
11
28
  /** @internal Registers the legacy object behind a compat harness. */
@@ -76,13 +93,14 @@ export async function ask(harness, opts) {
76
93
  const legacy = legacyClaudeOf(harness);
77
94
  if (legacy)
78
95
  return legacy.ask(opts);
79
- const { instruction, context, model, timeoutMs, signal } = opts;
96
+ const { instruction, context, model, timeoutMs, signal, onUsage } = opts;
80
97
  const response = await harness.complete({
81
98
  prompt: buildAskPrompt(instruction, context),
82
99
  model,
83
100
  timeoutMs,
84
101
  signal,
85
102
  purpose: "ask",
103
+ ...(onUsage ? { onUsage } : {}),
86
104
  });
87
105
  return (response ?? "").trim();
88
106
  }
@@ -95,12 +113,13 @@ export async function evaluate(harness, opts, logger) {
95
113
  if (legacy)
96
114
  return legacy.evaluate(opts);
97
115
  const log = logger ?? harness.logger ?? consoleLogger;
98
- const { question, context, choices, timeoutMs, signal } = opts;
116
+ const { question, context, choices, timeoutMs, signal, onUsage } = opts;
99
117
  const response = await harness.complete({
100
118
  prompt: buildEvaluatePrompt(question, context, choices),
101
119
  timeoutMs,
102
120
  signal,
103
121
  purpose: "evaluate",
122
+ ...(onUsage ? { onUsage } : {}),
104
123
  });
105
124
  // The adapter already logged why; fail closed with no decision.
106
125
  if (response === null)