@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.
@@ -11,6 +11,7 @@ import { toMermaidBlock } from "../mermaid.js";
11
11
  import { createPaint } from "./style.js";
12
12
  import { colorEnabled, glyphs } from "./terminal.js";
13
13
  import { trustedEnvValue } from "../startup-env.js";
14
+ import { SPEND_KINDS } from "../budget.js";
14
15
  /** The short key of a `degraded` entry: the text before its first colon. */
15
16
  export function degradedKey(entry) {
16
17
  const i = entry.indexOf(":");
@@ -70,6 +71,19 @@ export function summarizeRun(results, durationMs, crashed = false, trace) {
70
71
  }
71
72
  if (staged > 0 || applied > 0)
72
73
  policy.outputs = { staged, applied };
74
+ // The executor's own completions (judge, route, ...) are on no node result,
75
+ // and a node result holds only its last attempt. With the split present the
76
+ // totals are its sum, so they reconcile with it on one basis.
77
+ const split = Object.values(trace?.spend ?? {});
78
+ const extra = Object.keys(trace?.spend ?? {}).some((k) => k !== "node");
79
+ if (extra) {
80
+ const t = split.reduce((n, k) => n + (isNum(k.tokens) ? k.tokens : 0), 0);
81
+ const c = split.reduce((n, k) => n + (isNum(k.costUsd) ? k.costUsd : 0), 0);
82
+ if (t > 0 || tokens !== undefined)
83
+ tokens = t;
84
+ if (c > 0 || costUsd !== undefined)
85
+ costUsd = Math.round(c * 1e10) / 1e10;
86
+ }
73
87
  return {
74
88
  ok: !failed,
75
89
  nodesOk,
@@ -83,6 +97,7 @@ export function summarizeRun(results, durationMs, crashed = false, trace) {
83
97
  ...(degraded.size > 0 ? { degraded: [...degraded] } : {}),
84
98
  ...(Object.keys(policy).length > 0 ? { policy } : {}),
85
99
  ...(budget ? { budget } : {}),
100
+ ...(extra ? { spend: trace.spend } : {}),
86
101
  ...(trace && countRoutes(trace).total > 0 ? { routes: countRoutes(trace) } : {}),
87
102
  ...(trace?.deciderOff ? { deciderOff: trace.deciderOff } : {}),
88
103
  };
@@ -160,9 +175,34 @@ export function formatBudgetAmount(b) {
160
175
  const unit = b.unit === "cost_usd" ? "cost" : "tokens";
161
176
  return `${unit} ${amount(b.spent)} of ${amount(b.limit)}`;
162
177
  }
163
- /** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
178
+ /** `budget exceeded: tokens 52k of 50k (run, at node gather)`, plus `, judge` when an executor call crossed it. */
164
179
  export function formatBudgetOverrun(b) {
165
- return `budget exceeded: ${formatBudgetAmount(b)} (${b.scope}, at node ${b.node})`;
180
+ return `budget exceeded: ${formatBudgetAmount(b)} (${b.scope}, at node ${b.node}${b.kind ? `, ${b.kind}` : ""})`;
181
+ }
182
+ /**
183
+ * `node $0.15, judge $0.03, route <$0.01`: spend by kind in a fixed order
184
+ * (node, judge, route, reflect, screen), only the kinds present. In tokens
185
+ * when no kind reported a cost. Undefined when there is no split to show.
186
+ */
187
+ export function formatSpendSplit(spend, sep = " ") {
188
+ const kinds = SPEND_KINDS.flatMap((k) => (spend?.[k] ? [[k, spend[k]]] : []));
189
+ if (kinds.length === 0)
190
+ return undefined;
191
+ const byCost = kinds.some(([, v]) => v.costUsd > 0);
192
+ return kinds.map(([k, v]) => `${k}${sep}${byCost ? formatCost(v.costUsd) : formatTokenCount(v.tokens)}`).join(", ");
193
+ }
194
+ /**
195
+ * `12k tokens (judge 2k)`: the executor's own kinds in brackets, on the token
196
+ * segment, or on the cost (`$0.18 (judge $0.03)`) when no tokens were reported.
197
+ */
198
+ function ownSpendBracket(spend, unit) {
199
+ const own = SPEND_KINDS.flatMap((k) => {
200
+ const v = spend?.[k];
201
+ if (k === "node" || !v || !(v[unit] > 0))
202
+ return [];
203
+ return [`${k} ${unit === "tokens" ? formatTokenCount(v.tokens) : formatCost(v.costUsd)}`];
204
+ });
205
+ return own.length === 0 ? "" : ` (${own.join(", ")})`;
166
206
  }
167
207
  /** `5 (3 decider, 1 expr, 1 agent)`: zero rungs are left out. */
168
208
  export function formatRouteCounts(r) {
@@ -182,8 +222,10 @@ export function formatReceipt(s) {
182
222
  ...(s.nodesSkipped > 0 ? [`${s.nodesSkipped} skipped`] : []),
183
223
  `${s.toolCalls} tool ${s.toolCalls === 1 ? "call" : "calls"}`,
184
224
  formatReceiptDuration(s.durationMs),
185
- ...(s.tokens !== undefined ? [`${formatTokenCount(s.tokens)} tokens`] : []),
186
- ...(s.costUsd !== undefined ? [formatCost(s.costUsd)] : []),
225
+ ...(s.tokens !== undefined ? [`${formatTokenCount(s.tokens)} tokens${ownSpendBracket(s.spend, "tokens")}`] : []),
226
+ ...(s.costUsd !== undefined
227
+ ? [`${formatCost(s.costUsd)}${s.tokens === undefined ? ownSpendBracket(s.spend, "costUsd") : ""}`]
228
+ : []),
187
229
  ...(s.harness !== undefined && s.harness !== "claude-code" ? [s.harness] : []),
188
230
  ...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
189
231
  ...(formatPolicySegment(s.policy) ? [formatPolicySegment(s.policy)] : []),
package/dist/cli/runs.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * `color` is true (TTY and no NO_COLOR); the glyph set follows `unicode`.
6
6
  * The diff always ends on one plain line with the answer counts.
7
7
  */
8
- import { findRun, listRuns } from "./run-history.js";
8
+ import { findRun, listRuns, } from "./run-history.js";
9
+ import { SPEND_KINDS } from "../budget.js";
9
10
  import { compareAnswers, readRunAnswers, } from "./run-answers.js";
10
11
  import { formatCost, formatReceiptDuration, formatTokenCount } from "./run-output.js";
11
12
  import { createPaint } from "./style.js";
@@ -84,8 +85,25 @@ export function printableRun(r) {
84
85
  tokens: finiteOrNull(r.totals.tokens),
85
86
  cost_usd: finiteOrNull(r.totals.cost_usd),
86
87
  },
88
+ ...(r.spend && typeof r.spend === "object" ? { spend: printableSpend(r.spend) } : {}),
87
89
  };
88
90
  }
91
+ /** The record's split, or its totals as all node spend when it has none. */
92
+ function splitOf(r) {
93
+ if (r.spend)
94
+ return r.spend;
95
+ return { node: { tokens: r.totals.tokens ?? 0, cost_usd: r.totals.cost_usd ?? 0, calls: 0 } };
96
+ }
97
+ /** Only the known kinds, own keys, finite numbers. */
98
+ function printableSpend(spend) {
99
+ const out = {};
100
+ for (const k of SPEND_KINDS) {
101
+ const v = Object.hasOwn(spend, k) ? spend[k] : undefined;
102
+ if (v && typeof v === "object")
103
+ out[k] = { tokens: finite(v.tokens), cost_usd: finite(v.cost_usd), calls: finite(v.calls) };
104
+ }
105
+ return out;
106
+ }
89
107
  // ── sweny runs ──────────────────────────────────────────────────
90
108
  export function formatRunsTable(records, color, nowMs = Date.now(), unicode = true) {
91
109
  const ICON = iconsFor(glyphsFor(unicode));
@@ -312,6 +330,25 @@ export function formatRunDiff(rawA, rawB, color, opts = {}) {
312
330
  for (const t of tot)
313
331
  if (t)
314
332
  out.push(` ${t}`);
333
+ // Spend by kind, when either run has the split: what moved the totals. A
334
+ // record without one (node-only, or written before the split) is all node spend.
335
+ if (a.spend || b.spend) {
336
+ const sa = splitOf(a);
337
+ const sb = splitOf(b);
338
+ const byCost = SPEND_KINDS.some((k) => (sa[k]?.cost_usd ?? 0) > 0 || (sb[k]?.cost_usd ?? 0) > 0);
339
+ for (const k of SPEND_KINDS) {
340
+ const va = sa[k];
341
+ const vb = sb[k];
342
+ if (!va && !vb)
343
+ continue;
344
+ const label = ` ${pad(k, 10)}`;
345
+ const line = byCost
346
+ ? metric(label, va ? va.cost_usd : null, vb ? vb.cost_usd : null, formatCost, deltaCost, g.arrow)
347
+ : metric(label, va ? va.tokens : null, vb ? vb.tokens : null, formatTokenCount, deltaTokens, g.arrow);
348
+ if (line)
349
+ out.push(` ${line}`);
350
+ }
351
+ }
315
352
  out.push("", " nodes");
316
353
  const aNodes = new Map(a.nodes.map((n) => [n.id, n]));
317
354
  const bNodes = new Map(b.nodes.map((n) => [n.id, n]));
@@ -6,7 +6,7 @@
6
6
  * METADATA ONLY, like the receipt line: counts, durations, tokens, cost,
7
7
  * policy facts. Never prompts, node outputs, tool inputs, or model prose.
8
8
  */
9
- import { formatBudgetAmount, formatCost, formatRouteCounts, formatReceiptDuration, formatTokenCount, policyParts, renderReceiptLine, } from "./run-output.js";
9
+ import { formatBudgetAmount, formatCost, formatRouteCounts, formatSpendSplit, formatReceiptDuration, formatTokenCount, policyParts, renderReceiptLine, } from "./run-output.js";
10
10
  import { createPaint } from "./style.js";
11
11
  import { colorEnabled, glyphsFor, richOutput, supportsUnicode, terminalColumns, ticketBoxFor } from "./terminal.js";
12
12
  /** Widest ticket, in columns, frame included. */
@@ -52,8 +52,11 @@ export function stampWords(summary) {
52
52
  }
53
53
  return { words, strong };
54
54
  }
55
+ const NBSP = "\u00a0";
55
56
  /** Field rows, label then value, metadata only. */
56
57
  function ticketFields(s) {
58
+ // A no-break space keeps each kind with its amount when the row wraps (put back after wrapping).
59
+ const spend = formatSpendSplit(s.spend, NBSP);
57
60
  const failed = Math.max(0, s.nodesTotal - s.nodesOk - s.nodesSkipped);
58
61
  const nodes = [
59
62
  `${s.nodesOk}/${s.nodesTotal}`,
@@ -67,6 +70,7 @@ function ticketFields(s) {
67
70
  ["duration", formatReceiptDuration(s.durationMs)],
68
71
  ...(s.tokens !== undefined ? [["tokens", formatTokenCount(s.tokens)]] : []),
69
72
  ...(s.costUsd !== undefined ? [["cost", formatCost(s.costUsd)]] : []),
73
+ ...(spend ? [["spend", spend]] : []),
70
74
  ...(s.harness !== undefined && s.harness !== "claude-code" ? [["harness", s.harness]] : []),
71
75
  ...(s.degraded && s.degraded.length > 0 ? [["degraded", s.degraded.join(", ")]] : []),
72
76
  ...(s.routes && s.routes.total > 0 ? [["routes", formatRouteCounts(s.routes)]] : []),
@@ -154,7 +158,8 @@ export function renderTicket(data, opts = {}) {
154
158
  lines.push(row(""));
155
159
  // Fields, aligned: label column then value column; long values wrap under the value.
156
160
  for (const [label, value] of ticketFields(s)) {
157
- wrap(value, valueWidth).forEach((part, i) => {
161
+ wrap(value, valueWidth).forEach((raw, i) => {
162
+ const part = raw.replaceAll(NBSP, " ");
158
163
  const lab = (i === 0 ? label : "").padEnd(LABEL_WIDTH);
159
164
  lines.push(row(lab + part, paint.muted(lab) + part));
160
165
  });
@@ -10,6 +10,16 @@ export interface EvaluateAllOptions {
10
10
  node?: Node;
11
11
  /** The workflow being executed. Used to resolve workflow-level `judge_model` overrides. */
12
12
  workflow?: Workflow;
13
+ /**
14
+ * Run the evaluators one at a time instead of together. The executor sets it
15
+ * when a spend budget is on, so each judge's usage is counted (and a
16
+ * crossing stops the rest) before the next one starts.
17
+ */
18
+ sequential?: boolean;
19
+ /** The run's abort signal: checked before each judge when sequential, and forwarded to judge calls. */
20
+ signal?: AbortSignal;
21
+ /** Per-call timeout forwarded to judge calls. */
22
+ timeoutMs?: number;
13
23
  }
14
24
  /**
15
25
  * Run every declared evaluator against the node's result and return one
@@ -41,11 +41,28 @@ export async function evaluateAll(evaluators, result, opts = {}) {
41
41
  throw new Error(`evaluator '${orphanJudge.name}' is kind: judge but no Claude client was provided to evaluateAll`);
42
42
  }
43
43
  // Evaluators are independent, so they run concurrently (#337): N judges on
44
- // a node cost one round-trip of wall time, not N. Results keep declaration
45
- // order. Every evaluator settles before an error surfaces, and the first
46
- // rejection in declaration order is rethrown, so the outcome never depends
47
- // on which call finishes first.
48
- const settled = await Promise.allSettled(evaluators.map((e) => evaluateOne(e, result, opts)));
44
+ // a node cost one round-trip of wall time, not N. With `sequential` (a spend
45
+ // budget is on) they run one at a time, so each judge's spend is counted
46
+ // before the next starts, and an abort stops the rest. Results keep
47
+ // declaration order. Every evaluator settles before an error surfaces, and
48
+ // the first rejection in declaration order is rethrown, so the outcome never
49
+ // depends on which call finishes first.
50
+ const settled = [];
51
+ if (opts.sequential) {
52
+ for (const e of evaluators) {
53
+ if (opts.signal?.aborted)
54
+ throw new Error("workflow aborted");
55
+ try {
56
+ settled.push({ status: "fulfilled", value: await evaluateOne(e, result, opts) });
57
+ }
58
+ catch (reason) {
59
+ settled.push({ status: "rejected", reason });
60
+ }
61
+ }
62
+ }
63
+ else {
64
+ settled.push(...(await Promise.allSettled(evaluators.map((e) => evaluateOne(e, result, opts)))));
65
+ }
49
66
  const out = [];
50
67
  for (const s of settled) {
51
68
  if (s.status === "rejected")
@@ -66,7 +83,11 @@ async function evaluateOne(e, result, opts) {
66
83
  }
67
84
  if (e.kind === "judge" && opts.claude) {
68
85
  const model = resolveJudgeModel(e, opts.node, opts.workflow, opts.claude.defaultJudgeModel);
69
- const verdict = await evaluateJudge(e, result, opts.claude, { model });
86
+ const verdict = await evaluateJudge(e, result, opts.claude, {
87
+ model,
88
+ ...(opts.signal ? { signal: opts.signal } : {}),
89
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
90
+ });
70
91
  return { name: e.name, kind: "judge", pass: verdict.pass, reasoning: capReasoning(verdict.reasoning) };
71
92
  }
72
93
  return null;
@@ -2,6 +2,9 @@ import type { Claude, Evaluator, NodeResult } from "../types.js";
2
2
  export interface JudgeOptions {
3
3
  /** Resolved judge model (evaluator > node > workflow > package default). */
4
4
  model: string;
5
+ /** The run's abort signal and per-call timeout, forwarded to the judge call. */
6
+ signal?: AbortSignal;
7
+ timeoutMs?: number;
5
8
  }
6
9
  interface ParsedResponse {
7
10
  verdict: string | null;
@@ -13,6 +13,7 @@
13
13
  // For a typical 4-node workflow with 2 judges per node, that's 8 extra
14
14
  // calls per run. The workflow-level `judge_budget` (default 50) gives
15
15
  // authors a load-time warning when the total stretches past expected.
16
+ import { BudgetRefusal } from "../budget.js";
16
17
  /**
17
18
  * Build the structured judge prompt.
18
19
  *
@@ -122,10 +123,19 @@ export async function evaluateJudge(evaluator, result, claude, opts) {
122
123
  instruction: prompt,
123
124
  context: {},
124
125
  model: opts.model,
126
+ ...(opts.signal ? { signal: opts.signal } : {}),
127
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
125
128
  });
126
129
  }
127
- catch {
128
- // Treat throws as parse failure for retry purposes; the second attempt
130
+ catch (err) {
131
+ // A budget refusal is no answer at all: it reaches the executor, which
132
+ // stops the node on the budget instead of recording a parse failure.
133
+ if (err instanceof BudgetRefusal)
134
+ throw err;
135
+ // An aborted run is no answer either: never retried as a parse failure.
136
+ if (opts.signal?.aborted)
137
+ throw err;
138
+ // Treat other throws as parse failure for retry purposes; the second attempt
129
139
  // either succeeds or surfaces the same path through the same channel.
130
140
  continue;
131
141
  }