@spendgraph/workflows 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +59 -0
  2. package/dist/noesis/attribution.js +0 -20
  3. package/dist/noesis/context.js +0 -8
  4. package/dist/noesis/draft/draft.js +0 -13
  5. package/dist/noesis/events.js +0 -10
  6. package/dist/noesis/execute/execute.js +2 -17
  7. package/dist/noesis/execute/index.d.ts +2 -0
  8. package/dist/noesis/execute/index.js +1 -0
  9. package/dist/noesis/execute/order.js +0 -8
  10. package/dist/noesis/execute/spend.d.ts +36 -0
  11. package/dist/noesis/execute/spend.js +79 -0
  12. package/dist/noesis/execute/types.d.ts +2 -0
  13. package/dist/noesis/gates/closure-checklist/invoke.js +0 -1
  14. package/dist/noesis/gates/closure-checklist/prompt.js +0 -2
  15. package/dist/noesis/gates/cqot-gate/invoke.js +0 -1
  16. package/dist/noesis/gates/cqot-gate/prompt.js +0 -2
  17. package/dist/noesis/gates/premortem/invoke.js +0 -1
  18. package/dist/noesis/gates/premortem/prompt.js +0 -2
  19. package/dist/noesis/gates/red-team/invoke.js +0 -1
  20. package/dist/noesis/gates/red-team/prompt.js +0 -2
  21. package/dist/noesis/gates/standards-review/invoke.js +0 -1
  22. package/dist/noesis/gates/standards-review/prompt.js +0 -2
  23. package/dist/noesis/gates/think-checkpoint/invoke.js +0 -1
  24. package/dist/noesis/gates/think-checkpoint/prompt.js +0 -2
  25. package/dist/noesis/index.d.ts +2 -2
  26. package/dist/noesis/index.js +1 -1
  27. package/dist/noesis/producers/cove-answers/invoke.js +0 -1
  28. package/dist/noesis/producers/cove-answers/prompt.js +0 -2
  29. package/dist/noesis/producers/cove-questions/invoke.js +0 -1
  30. package/dist/noesis/producers/cove-questions/prompt.js +0 -2
  31. package/dist/noesis/producers/decompose/invoke.js +0 -1
  32. package/dist/noesis/producers/decompose/prompt.js +0 -18
  33. package/dist/noesis/producers/discriminating-test/invoke.js +0 -1
  34. package/dist/noesis/producers/discriminating-test/prompt.js +0 -2
  35. package/dist/noesis/producers/plan-candidates/invoke.js +0 -1
  36. package/dist/noesis/producers/plan-candidates/prompt.js +0 -2
  37. package/dist/noesis/producers/qn-brief/invoke.js +0 -1
  38. package/dist/noesis/producers/qn-brief/prompt.js +0 -9
  39. package/dist/noesis/producers/toulmin/answer.js +0 -21
  40. package/dist/noesis/producers/toulmin/invoke.js +0 -10
  41. package/dist/noesis/producers/toulmin/prompt.js +0 -9
  42. package/dist/noesis/producers/toulmin/render.js +0 -24
  43. package/dist/noesis/question.js +0 -19
  44. package/dist/noesis/repair/reflection/invoke.js +0 -1
  45. package/dist/noesis/repair/reflection/prompt.js +0 -2
  46. package/dist/noesis/retry.js +0 -34
  47. package/dist/noesis/route.js +0 -27
  48. package/dist/noesis/router/triage/invoke.js +0 -23
  49. package/dist/noesis/router/triage/prompt.js +0 -8
  50. package/dist/noesis/run/artifacts.js +0 -17
  51. package/dist/noesis/run/errors.js +0 -18
  52. package/dist/noesis/run/index.d.ts +2 -2
  53. package/dist/noesis/run/index.js +1 -1
  54. package/dist/noesis/run/ledger.d.ts +49 -1
  55. package/dist/noesis/run/ledger.js +19 -13
  56. package/dist/noesis/run/loop.js +4 -22
  57. package/dist/noesis/run/options.d.ts +11 -0
  58. package/dist/noesis/run/options.js +18 -0
  59. package/dist/noesis/run/plan.js +4 -16
  60. package/dist/noesis/run/produce.js +8 -37
  61. package/dist/noesis/run/redo.js +0 -20
  62. package/dist/noesis/run/result.js +1 -10
  63. package/dist/noesis/run/run.js +5 -21
  64. package/dist/noesis/run/stages/classify.js +2 -2
  65. package/dist/noesis/run/stages/direct.js +0 -1
  66. package/dist/noesis/run/stages/full.js +0 -9
  67. package/dist/noesis/run/stages/retrieve.js +0 -8
  68. package/dist/noesis/run/stages/standard.js +0 -7
  69. package/dist/noesis/run/state.d.ts +5 -0
  70. package/dist/noesis/run/state.js +5 -4
  71. package/dist/noesis/run/stream.js +0 -13
  72. package/dist/noesis/run/verify.js +5 -22
  73. package/dist/noesis/slugs.js +0 -14
  74. package/dist/noesis/stage.js +0 -10
  75. package/dist/noesis/started.js +0 -15
  76. package/dist/noesis/types.d.ts +17 -5
  77. package/dist/noesis/usage.js +0 -16
  78. package/dist/noesis/verify/verify.js +0 -29
  79. package/package.json +7 -7
@@ -6,25 +6,12 @@ import { invoke as brief } from "../producers/qn-brief/index.js";
6
6
  import { digest } from "./artifacts.js";
7
7
  import { NoToolsError } from "./errors.js";
8
8
  import { plan } from "./plan.js";
9
- /**
10
- * Everything the ledger holds so far, as a stage reads it.
11
- *
12
- * Cut through `digest`, because every caller here is reviewing decisions rather
13
- * than citing evidence and one wide tool answer would otherwise be most of the
14
- * prompt. 6C reads `ledger.artifacts()` itself, uncut.
15
- */
9
+ import { stageOptionsFor } from "./options.js";
10
+ import { NO_TOOL_SPEND } from "../execute/spend.js";
11
+ import { recordToolSpend } from "./state.js";
16
12
  export const soFar = (state, question) => JSON.stringify({ question, ...digest(state.ledger.artifacts()) }, null, 2);
17
- /**
18
- * Stages 1 and 2: interrogate the question, then split it.
19
- *
20
- * Stage 1 is the one place a person is worth asking mid-run. It answers
21
- * `is_right_question: false` when the asker aimed at the wrong thing, and
22
- * splitting the wrong question is the most expensive way to find that out. With
23
- * nobody to ask it proceeds on the question as put, and the brief says so in
24
- * the ledger either way.
25
- */
26
13
  export async function frame(client, llm, question, opts, state) {
27
- const brief1 = state.ledger.record("1", await brief(client, llm, { question, ...opts.context }, opts.stage));
14
+ const brief1 = state.ledger.record("1", await brief(client, llm, { question, ...opts.context }, stageOptionsFor(opts, "1")));
28
15
  let asked = question;
29
16
  if (!brief1.is_right_question && brief1.better_question && opts.onAsk) {
30
17
  state.emit?.({
@@ -41,20 +28,14 @@ export async function frame(client, llm, question, opts, state) {
41
28
  state.emit?.({ type: "ask.answered", replacement: replacement ?? null });
42
29
  asked = replacement ?? question;
43
30
  }
44
- const split = state.ledger.record("2", await decompose(client, llm, { question: asked, ...opts.context }, opts.stage));
31
+ const split = state.ledger.record("2", await decompose(client, llm, { question: asked, ...opts.context }, stageOptionsFor(opts, "2")));
45
32
  return { asked, split };
46
33
  }
47
- /**
48
- * Stages 4 and 4A: answer the parts, then judge what came back against the plan.
49
- *
50
- * The draft is assembled either way. 4A decides whether it is worth reading:
51
- * `continue` means the work is done, and the two that are not are the run's to
52
- * act on.
53
- */
54
34
  export async function work(client, llm, question, opts, state, split) {
55
35
  if (!opts.tools)
56
36
  throw new NoToolsError();
57
37
  const execution = await execute(opts.tools, split.sub_questions, { emit: state.emit });
38
+ recordToolSpend(state, execution.spend);
58
39
  state.ledger.record("4", {
59
40
  data: execution,
60
41
  rolloutIds: [],
@@ -69,24 +50,13 @@ export async function work(client, llm, question, opts, state, split) {
69
50
  findings: draft.findings.length,
70
51
  missing: draft.missing.map((one) => ({ id: one.id, reason: one.reason })),
71
52
  });
72
- const checkpoint = state.ledger.record("4A", await thinkCheckpoint(client, llm, { question: soFar(state, question), ...opts.context }, opts.stage));
53
+ const checkpoint = state.ledger.record("4A", await thinkCheckpoint(client, llm, { question: soFar(state, question), ...opts.context }, stageOptionsFor(opts, "4A")));
73
54
  if (checkpoint.next_action === "replan")
74
55
  return { draft, action: "replan" };
75
56
  if (checkpoint.next_action === "escalate")
76
57
  return { draft, action: "escalate" };
77
58
  return { draft, action: "go" };
78
59
  }
79
- /**
80
- * Everything up to the draft, and the checkpoint's word that it is worth having.
81
- *
82
- * The framing happens once. `replan` returns to 3A because a result outside the
83
- * tolerance a step predicted is the plan being wrong, not the question — and
84
- * re-framing would ask the person the same thing twice.
85
- *
86
- * A plan that never cleared its own audits stops here rather than executing:
87
- * the run already knows it needs somebody, and the draft it hands back says
88
- * nothing was answered rather than pretending otherwise.
89
- */
90
60
  export async function produce(client, llm, question, opts, state, audits) {
91
61
  const { asked, split } = await frame(client, llm, question, opts, state);
92
62
  for (;;) {
@@ -95,6 +65,7 @@ export async function produce(client, llm, question, opts, state, audits) {
95
65
  return assemble(asked, split, {
96
66
  answers: [],
97
67
  unresolved: split.sub_questions.map((sub) => sub.id),
68
+ spend: NO_TOOL_SPEND,
98
69
  toolMs: 0,
99
70
  });
100
71
  }
@@ -1,18 +1,6 @@
1
1
  import { STAGE_NAMES, STAGES } from "./ledger.js";
2
2
  const LONGEST_FIRST = [...STAGES].sort((a, b) => b.length - a.length);
3
3
  const boundary = (id) => new RegExp(`(^|[^a-z0-9])${id.toLowerCase()}([^a-z0-9]|$)`);
4
- /**
5
- * A stage id out of whatever a stage called it.
6
- *
7
- * `redo_from_stage` and `loop_back_to` are free strings, so they arrive as
8
- * `"2"`, `"stage 3A"` or `"the decomposition"`. Longest first, or `"5A"` is
9
- * read as `"5"`.
10
- *
11
- * Absent counts as unnameable. Both fields are `required` in their schemas and
12
- * neither is checked on the way back, so a stage that omitted one hands this
13
- * `undefined` — and a stage nobody can name redoes everything, which is the
14
- * answer this already had for a name it could not parse.
15
- */
16
4
  export function parseStage(said) {
17
5
  const text = said?.trim().toLowerCase();
18
6
  if (!text)
@@ -25,17 +13,9 @@ export function parseStage(said) {
25
13
  return mentioned;
26
14
  return STAGES.find((id) => text.includes(STAGE_NAMES[id]));
27
15
  }
28
- /**
29
- * Which half of the route has to run again.
30
- *
31
- * The draft is the seam: anything up to 4B was produced, and anything after it
32
- * only read what the draft already held. A stage nobody could name redoes the
33
- * lot, because the alternative is compiling something stale.
34
- */
35
16
  export function resumeAt(stage) {
36
17
  if (!stage)
37
18
  return "produce";
38
19
  return STAGES.indexOf(stage) <= STAGES.indexOf("4B") ? "produce" : "verify";
39
20
  }
40
- /** Where the ledger is cut back to. The earliest thing a retry can redo is 1. */
41
21
  export const dropPoint = (stage) => !stage || stage === "0" ? "1" : stage;
@@ -1,27 +1,20 @@
1
1
  import { RouteFailedError, RouteNotBuiltError } from "./errors.js";
2
- /**
3
- * What the run is worth acting on.
4
- *
5
- * An escalation outranks an abstention: a gate that asked for a person said so
6
- * about the whole run, and 6C abstaining afterwards is a consequence of it.
7
- */
8
2
  function statusOf(state) {
9
3
  if (state.ledger.escalated())
10
4
  return "escalated";
11
5
  const answered = state.answered;
12
6
  return answered && (answered.answer.abstained || !answered.accepted) ? "abstained" : "answered";
13
7
  }
14
- /** Everything the run was billed for, whole and split two ways. */
15
8
  function usageOfRun(state) {
16
9
  const byStage = state.ledger.byStage();
17
10
  return {
18
11
  ...state.ledger.usage(),
19
12
  byStage,
20
13
  byModel: state.ledger.byModel(),
14
+ tools: state.toolSpend,
21
15
  calls: byStage.reduce((total, stage) => total + stage.calls, 0),
22
16
  };
23
17
  }
24
- /** The state, once every branch has run, as a result. */
25
18
  export function finish(decided, state, latencyMs) {
26
19
  const { estimates, routing, answered, text, ledger } = state;
27
20
  if (decided.status === "failed" || !estimates || !routing || !answered || !text) {
@@ -44,8 +37,6 @@ export function finish(decided, state, latencyMs) {
44
37
  rolloutIds: ledger.rolloutIds,
45
38
  unattributed: state.unattributed ?? [],
46
39
  usage,
47
- inputTokens: usage.inputTokens,
48
- outputTokens: usage.outputTokens,
49
40
  latencyMs,
50
41
  };
51
42
  }
@@ -4,32 +4,17 @@ import { notBuilt } from "./errors.js";
4
4
  import { finish } from "./result.js";
5
5
  import { classifyStage, directStage, fullStage, retrieveStage, standardStage, } from "./stages/index.js";
6
6
  import { newRunState } from "./state.js";
7
- /**
8
- * Question in, answer out.
9
- *
10
- * `onDeliver` runs once, after the loop, and only for an answer worth acting
11
- * on. Anything that renders a deliverable belongs there rather than inside a
12
- * branch: nothing downstream reads it, no gate can check it, every back edge
13
- * would make it again, and generating a finished-looking document out of an
14
- * abstention is the worst thing this loop could produce.
15
- *
16
- * Pass the previous run's `currentTask` back in through `opts.triage` to keep a
17
- * session going: it is what lets triage answer `is_new_task` at all, and the
18
- * result carries the task forward for the turn after this one.
19
- */
20
7
  const DEFAULT_MAX_RETRIES = 5;
21
- /**
22
- * The options every stage is handed, with the emitter already on them.
23
- *
24
- * Threading it here rather than at each stage call is what let fifteen stages
25
- * start announcing themselves without one of them being edited: they all
26
- * already forward `opts.stage`, `opts.triage` and `opts.answer` verbatim.
27
- */
28
8
  const watched = (opts, emit) => ({
29
9
  ...opts,
30
10
  triage: { ...opts.triage, emit },
31
11
  stage: { ...opts.stage, emit },
32
12
  answer: { ...opts.answer, emit },
13
+ ...(opts.stages
14
+ ? {
15
+ stages: Object.fromEntries(Object.entries(opts.stages).map(([id, one]) => [id, { ...one, emit }])),
16
+ }
17
+ : {}),
33
18
  });
34
19
  export async function run(client, llm, question, options = {}) {
35
20
  const startedAt = Date.now();
@@ -53,7 +38,6 @@ export async function run(client, llm, question, options = {}) {
53
38
  },
54
39
  fallback: notBuilt("unknown"),
55
40
  });
56
- // Every price the stages left in flight, collected before the result is built.
57
41
  await state.ledger.settle();
58
42
  const result = finish(decided, state, Date.now() - startedAt);
59
43
  emit?.({
@@ -1,9 +1,9 @@
1
1
  import { routeFor } from "../../route.js";
2
2
  import { invoke as triage } from "../../router/triage/index.js";
3
3
  import { nextTask } from "../state.js";
4
- /** Stage 0, and the route its estimates earn. */
4
+ import { stageOptionsFor } from "../options.js";
5
5
  export const classifyStage = (client, llm, question, opts, state) => async () => {
6
- const stage = await triage(client, llm, { ...opts.triage, currentTask: state.currentTask, question }, { ...opts.triage });
6
+ const stage = await triage(client, llm, { ...opts.triage, currentTask: state.currentTask, question }, { ...stageOptionsFor(opts, "0"), ...opts.triage });
7
7
  state.estimates = state.ledger.record("0", { ...stage, data: stage.estimates });
8
8
  state.routing = routeFor(stage.estimates);
9
9
  state.currentTask = nextTask(stage.estimates, state.currentTask);
@@ -1,5 +1,4 @@
1
1
  import { answer } from "../../producers/toulmin/index.js";
2
- /** The direct branch: the question and what triage made of it, straight to 6C. */
3
2
  export const directStage = (client, llm, question, opts, state) => async () => {
4
3
  const answered = await answer(client, llm, { question, ...state.ledger.artifacts() }, opts.answer ?? {});
5
4
  state.answered = answered;
@@ -1,15 +1,6 @@
1
1
  import { settle } from "../loop.js";
2
2
  import { produce } from "../produce.js";
3
3
  import { compile } from "../verify.js";
4
- /**
5
- * The full branch: everything standard does, plus the audits.
6
- *
7
- * 3B and 3C interrogate the plan before it runs; 5C and 6B interrogate the
8
- * answer before it ships. Standard already plans, so what full adds is four
9
- * audits and nothing else. 6B fires on `routing.redTeam` rather than on the
10
- * route, because stakes are what earn it — a run sent here by difficulty or
11
- * ambiguity has no attacker to pay for.
12
- */
13
4
  export const fullStage = (client, llm, question, opts, state) => async () => {
14
5
  const reproduce = () => produce(client, llm, question, opts, state, true);
15
6
  await settle(client, llm, question, opts, state, await reproduce(), {
@@ -11,14 +11,6 @@ const lookup = (question, tool) => ({
11
11
  kind: "read_only",
12
12
  tool,
13
13
  });
14
- /**
15
- * The retrieve branch: one lookup, then 6C compiles what came back.
16
- *
17
- * No brief and no decomposition, because there is nothing to interrogate or
18
- * split — the question is clear and the only reason the model cannot answer it
19
- * is that the answer is somewhere else. A tool that finds nothing leaves 6C
20
- * with no grounds, which is what abstention is for.
21
- */
22
14
  export const retrieveStage = (client, llm, question, opts, state) => async () => {
23
15
  if (!opts.tools)
24
16
  throw new NoToolsError();
@@ -1,13 +1,6 @@
1
1
  import { settle } from "../loop.js";
2
2
  import { produce } from "../produce.js";
3
3
  import { compile } from "../verify.js";
4
- /**
5
- * The standard branch: frame it, split it, answer the parts, then check them.
6
- *
7
- * It plans, because 4A judges each step against the tolerance 3A wrote for it.
8
- * What it does not do is audit: no premortem or gate on the plan, and no
9
- * standards review or red team on the answer. That is what `full` buys.
10
- */
11
4
  export const standardStage = (client, llm, question, opts, state) => async () => {
12
5
  const reproduce = () => produce(client, llm, question, opts, state, false);
13
6
  await settle(client, llm, question, opts, state, await reproduce(), {
@@ -1,3 +1,4 @@
1
+ import { type ToolSpend } from "../execute/spend.js";
1
2
  import type { Emit } from "../events.js";
2
3
  import type { AnswerResult } from "../producers/toulmin/index.js";
3
4
  import type { TriageEstimates } from "../router/triage/index.js";
@@ -5,6 +6,8 @@ import type { Routing } from "../types.js";
5
6
  import { type Ledger } from "./ledger.js";
6
7
  /** What each branch writes down, since `route()` hands back only its output. */
7
8
  export interface RunState {
9
+ /** What the tools reported they cost, which no stage's tokens include. */
10
+ toolSpend: ToolSpend;
8
11
  /** Every stage's result and what it cost, in one place. */
9
12
  ledger: Ledger;
10
13
  /** Where the run layer says what it is doing. Undefined when nobody listens. */
@@ -19,6 +22,8 @@ export interface RunState {
19
22
  unattributed?: string[];
20
23
  }
21
24
  export declare const newRunState: (currentTask?: string, emit?: Emit, maxRetries?: number) => RunState;
25
+ /** Adds one execution's tool spend to the run's. A retry refunds nothing. */
26
+ export declare function recordToolSpend(state: RunState, spend: ToolSpend): void;
22
27
  /**
23
28
  * The open task after triage has read the request: the router's summary when it
24
29
  * calls the request new, otherwise the task carried in.
@@ -1,13 +1,14 @@
1
+ import { addToolSpend, NO_TOOL_SPEND } from "../execute/spend.js";
1
2
  import { newLedger } from "./ledger.js";
2
3
  export const newRunState = (currentTask, emit, maxRetries) => ({
4
+ toolSpend: NO_TOOL_SPEND,
3
5
  ledger: newLedger(maxRetries, emit),
4
6
  emit,
5
7
  currentTask,
6
8
  });
7
- /**
8
- * The open task after triage has read the request: the router's summary when it
9
- * calls the request new, otherwise the task carried in.
10
- */
9
+ export function recordToolSpend(state, spend) {
10
+ state.toolSpend = addToolSpend(state.toolSpend, spend);
11
+ }
11
12
  export function nextTask(estimates, current) {
12
13
  return estimates.is_new_task ? estimates.summary : (current ?? estimates.summary);
13
14
  }
@@ -1,18 +1,5 @@
1
1
  import { streamed } from "@spendgraph/harness";
2
2
  import { run } from "./run.js";
3
- /**
4
- * `run()`, watched.
5
- *
6
- * The run starts on the call rather than on the first read, so a client that
7
- * connects late still receives everything that already happened, and a caller
8
- * who only wants the answer never has to iterate.
9
- *
10
- * Events arrive unwrapped — a consumer switches on `event.type` directly rather
11
- * than reaching through the harness envelope the queue underneath uses.
12
- *
13
- * `opts.onEvent` still works and is called first, so a caller can log and
14
- * stream from the same run.
15
- */
16
3
  export function runStream(client, llm, question, opts = {}) {
17
4
  const inner = streamed((_emit, note) => run(client, llm, question, {
18
5
  ...opts,
@@ -7,21 +7,12 @@ import { invoke as reflection } from "../repair/reflection/index.js";
7
7
  import { blind, compare, render as renderChecks } from "../verify/index.js";
8
8
  import { soFar } from "./produce.js";
9
9
  import { parseStage, resumeAt } from "./redo.js";
10
- /**
11
- * Stages 5A, 5B and the pairing between them.
12
- *
13
- * 5B is given `blind()` rather than the questions themselves, so it answers
14
- * without sight of the claims it settles. Putting the two back together is the
15
- * step in between, and it stays in code for the same reason.
16
- *
17
- * A 5A that found nothing to check skips 5B rather than sending it an empty
18
- * turn, which every provider rejects.
19
- */
10
+ import { answerOptions, stageOptionsFor } from "./options.js";
20
11
  export async function checks(client, llm, opts, state, draft) {
21
12
  const drafted = renderDraft(draft);
22
- const questions = state.ledger.record("5A", await coveQuestions(client, llm, { question: drafted, ...opts.context }, opts.stage));
13
+ const questions = state.ledger.record("5A", await coveQuestions(client, llm, { question: drafted, ...opts.context }, stageOptionsFor(opts, "5A")));
23
14
  const answers = questions.questions.length
24
- ? state.ledger.record("5B", await coveAnswers(client, llm, { question: blind(questions), ...opts.context }, opts.stage))
15
+ ? state.ledger.record("5B", await coveAnswers(client, llm, { question: blind(questions), ...opts.context }, stageOptionsFor(opts, "5B")))
25
16
  : { answers: [] };
26
17
  const verification = compare(questions, answers, opts.verify?.confidenceFloor);
27
18
  state.emit?.({
@@ -43,15 +34,8 @@ export async function checks(client, llm, opts, state, draft) {
43
34
  });
44
35
  return `${drafted}\n\n${renderChecks(verification)}`;
45
36
  }
46
- /**
47
- * Stage R. The earliest wrong decision, and the stage to redo from.
48
- *
49
- * The gate says where the problem surfaced; this says where it started, which
50
- * is rarely the same place and is the only one worth returning to. The gate's
51
- * own suggestion is the fallback, not the answer.
52
- */
53
37
  export async function reflect(client, llm, question, opts, state, gate, reason, hint) {
54
- const looked = state.ledger.record("R", await reflection(client, llm, { question: `${soFar(state, question)}\n\nThe gate said: ${reason}`, ...opts.context }, opts.stage));
38
+ const looked = state.ledger.record("R", await reflection(client, llm, { question: `${soFar(state, question)}\n\nThe gate said: ${reason}`, ...opts.context }, stageOptionsFor(opts, "R")));
55
39
  const redoFrom = parseStage(looked.redo_from_stage) ?? parseStage(hint ?? "");
56
40
  state.emit?.({
57
41
  type: "reflect.pointed",
@@ -62,9 +46,8 @@ export async function reflect(client, llm, question, opts, state, gate, reason,
62
46
  });
63
47
  return redoFrom;
64
48
  }
65
- /** Stage 6C. Everything the ledger holds, compiled into a claim. */
66
49
  export async function compile(client, llm, question, opts, state) {
67
- const answered = await answer(client, llm, { question, ...state.ledger.artifacts() }, opts.answer ?? {});
50
+ const answered = await answer(client, llm, { question, ...state.ledger.artifacts() }, answerOptions(opts));
68
51
  state.answered = answered;
69
52
  const loose = unattributed(answered.answer, state.ledger.byStage().map((one) => one.stage));
70
53
  state.unattributed = loose;
@@ -13,20 +13,6 @@ import { BRIEF_SLUG } from "./producers/qn-brief/prompt.js";
13
13
  import { TOULMIN_SLUG } from "./producers/toulmin/prompt.js";
14
14
  import { REFLECTION_SLUG } from "./repair/reflection/prompt.js";
15
15
  import { TRIAGE_SLUG } from "./router/triage/prompt.js";
16
- /**
17
- * Which stage a slug belongs to.
18
- *
19
- * The inverse of what the fifteen modules already declare, and the reason
20
- * `runStage` can announce a stage it was only ever told the slug of. A slug
21
- * nobody claims is not an error — it means a caller reached `runStage` with a
22
- * prompt of their own, and an event for it would be a lie.
23
- *
24
- * Imported from each `prompt.ts` rather than from the module's `index.ts`,
25
- * which would be a cycle: an index re-exports `invoke`, `invoke` reaches
26
- * `announce`, and `announce` reads this map. A computed key whose value is
27
- * still uninitialised becomes the literal string "undefined" — no error, just a
28
- * stage that silently never announces itself.
29
- */
30
16
  export const STAGE_OF_SLUG = {
31
17
  [TRIAGE_SLUG]: "0",
32
18
  [BRIEF_SLUG]: "1",
@@ -3,7 +3,6 @@ import { askThrough, QUESTION_FIELD } from "./question.js";
3
3
  import { attempting } from "./retry.js";
4
4
  import { announce, failed } from "./started.js";
5
5
  import { usageOf } from "./usage.js";
6
- /** The reply came back, but not as the object the stage is supposed to return. */
7
6
  export class StageReplyError extends Error {
8
7
  slug;
9
8
  raw;
@@ -14,15 +13,6 @@ export class StageReplyError extends Error {
14
13
  this.name = "StageReplyError";
15
14
  }
16
15
  }
17
- /**
18
- * One stored prompt, called and parsed.
19
- *
20
- * Every stage does the same four things — pull by slug, put the request through
21
- * `{question}`, force the schema, hand back the object with what it cost — so
22
- * they do it here rather than thirteen times over. The call is recorded once,
23
- * as the prompt's rollout: the model client is told not to trace, or the same
24
- * tokens land again as usage and every run prices double.
25
- */
26
16
  export async function runStage(client, llm, slug, schema, input, opts = {}) {
27
17
  announce(opts.emit, slug, opts.model);
28
18
  return attempting(async () => {
@@ -1,11 +1,5 @@
1
1
  import { STAGE_NAMES } from "./run/ledger.js";
2
2
  import { STAGE_OF_SLUG } from "./slugs.js";
3
- /**
4
- * Say a stage has begun, if anyone is listening and the slug is one of ours.
5
- *
6
- * Shared by the three call paths — `runStage` and the copies triage and 6C keep
7
- * — so the three of them cannot drift on what a `stage.started` looks like.
8
- */
9
3
  export function announce(emit, slug, model) {
10
4
  if (!emit)
11
5
  return;
@@ -15,15 +9,6 @@ export function announce(emit, slug, model) {
15
9
  emit({ type: "stage.started", stage, name: STAGE_NAMES[stage], slug, model });
16
10
  }
17
11
  const message = (err) => (err instanceof Error ? err.message : String(err));
18
- /**
19
- * Say a stage did not produce what it was asked for.
20
- *
21
- * The kind is read off the error's name rather than by importing the three
22
- * reply errors, which would be a cycle — `stage.ts` reaches this file, and the
23
- * errors live beside the calls that throw them. Every one of them ends in
24
- * `ReplyError`, and that is the distinction worth drawing: a reply that came
25
- * back wrong is the prompt's problem, anything else is the provider's.
26
- */
27
12
  export function failed(emit, slug, err) {
28
13
  if (!emit)
29
14
  return;
@@ -1,10 +1,11 @@
1
1
  import type { ToolBus } from "@spendgraph/tools";
2
+ import type { ToolSpend } from "./execute/spend.js";
2
3
  import type { Asked } from "./context.js";
3
4
  import type { NoesisEvent } from "./events.js";
4
5
  import type { SubQuestionTool } from "./producers/decompose/index.js";
5
6
  import type { AnswerOptions, AnswerResult } from "./producers/toulmin/index.js";
6
7
  import type { TriageEstimates, TriageInput, TriageOptions } from "./router/triage/index.js";
7
- import type { Escalation, StageUsage } from "./run/ledger.js";
8
+ import type { Escalation, StageKey, StageUsage } from "./run/ledger.js";
8
9
  import type { StageOptions } from "./stage.js";
9
10
  import type { Usage } from "./usage.js";
10
11
  /**
@@ -78,6 +79,13 @@ export interface NoesisOptions {
78
79
  context?: Omit<Asked, "question">;
79
80
  /** Model and params for every stage between triage and the answer. */
80
81
  stage?: StageOptions;
82
+ /**
83
+ * Per-stage overrides, keyed by the stage's key, winning over `stage`.
84
+ *
85
+ * `params` is not inherited by a stage that names its own `model`: sampling
86
+ * knobs are per model, and a body written for one is rejected by another.
87
+ */
88
+ stages?: Partial<Record<StageKey, StageOptions>>;
81
89
  /**
82
90
  * Reached when a stage wants a person before it can go on.
83
91
  *
@@ -152,13 +160,17 @@ export interface NoesisResult {
152
160
  * each inside its own output under the stage `4` artifact.
153
161
  */
154
162
  usage: RunUsage;
155
- /** @deprecated Read `usage.inputTokens`. Kept so existing callers still work. */
156
- inputTokens: number;
157
- /** @deprecated Read `usage.outputTokens`. Kept so existing callers still work. */
158
- outputTokens: number;
159
163
  latencyMs: number;
160
164
  }
161
165
  export interface RunUsage extends Usage {
166
+ /**
167
+ * What the tools reported, which the numbers above deliberately exclude.
168
+ *
169
+ * A tool call is somebody else's API at somebody else's rates and is no
170
+ * rollout of ours, so it is never folded into the model spend — but it is
171
+ * real money, so it is reachable rather than buried in an artifact.
172
+ */
173
+ tools: ToolSpend;
162
174
  /** Summed over every time the loop ran each stage. */
163
175
  byStage: StageUsage[];
164
176
  byModel: Record<string, Usage>;
@@ -6,14 +6,7 @@ export const NO_USAGE = {
6
6
  citationTokens: 0,
7
7
  reasoningTokens: 0,
8
8
  };
9
- /**
10
- * Sums two costs without inventing one.
11
- *
12
- * Undefined on both sides stays undefined; a priced call landing beside an
13
- * unpriced one gives the priced total, which is a floor rather than a fiction.
14
- */
15
9
  const addCost = (a, b) => a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
16
- /** Every token field filled, so a caller never has to guard one. Cost stays absent when it is. */
17
10
  export const usageOf = (reported) => ({
18
11
  inputTokens: reported.inputTokens ?? 0,
19
12
  outputTokens: reported.outputTokens ?? 0,
@@ -35,22 +28,13 @@ export const addUsage = (total, next) => ({
35
28
  return cost === undefined ? {} : { costMicros: cost };
36
29
  })(),
37
30
  });
38
- /** Every token the call is billed for, however the provider split them. */
39
31
  export const billableTokens = (usage) => usage.inputTokens +
40
32
  usage.outputTokens +
41
33
  usage.cacheReadTokens +
42
34
  usage.cacheWriteTokens +
43
35
  usage.citationTokens +
44
36
  usage.reasoningTokens;
45
- /** What the run is known to have cost, in micro-USD, or undefined where nothing priced it. */
46
37
  export const spend = (usage) => usage.costMicros;
47
- /**
48
- * One price for several calls: undefined unless at least one of them was priced.
49
- *
50
- * The cascade in 6C runs a tier at a time, so a run can hold several prices
51
- * still in flight. Summing them with `?? 0` alone would turn "nobody priced
52
- * any of these" into a confident zero.
53
- */
54
38
  export const totalCost = (costs) => costs.some((cost) => cost !== undefined)
55
39
  ? costs.reduce((sum, cost) => sum + (cost ?? 0), 0)
56
40
  : undefined;
@@ -1,36 +1,8 @@
1
1
  const DEFAULT_FLOOR = 0.6;
2
- /**
3
- * What a check can honestly have been settled by when 5B is given no tools.
4
- *
5
- * `coveAnswers` is called with a client, a model and a question — there is no
6
- * `ToolBus` on that call, so parametric memory is the only thing the stage can
7
- * have used. Every other value in the enum is reachable only by passing a wider
8
- * set to `compare()`, which is the caller saying it wired the tools up.
9
- */
10
2
  export const SETTLED_WITHOUT_TOOLS = new Set(["knowledge"]);
11
- /**
12
- * The questions as 5B is allowed to see them: ids and questions, nothing else.
13
- *
14
- * `claim_it_tests` and `claim_type` are stripped here rather than trusted to a
15
- * prompt. Answering blind is the whole reason 5A and 5B are two stages, and a
16
- * stage that can read the claim will answer the claim.
17
- */
18
3
  export function blind(questions) {
19
4
  return questions.questions.map((q) => `${q.id}. ${q.question}`).join("\n");
20
5
  }
21
- /**
22
- * 5A and 5B paired back together by id.
23
- *
24
- * In code because it has to be: whatever does this sees both the claim and the
25
- * answer that was written without it, and a model given both is no longer a
26
- * blind check of anything.
27
- *
28
- * A reply that named no provenance at all is not overclaiming. `checked_by` is
29
- * required by the schema and nothing checks a reply against it on the way back,
30
- * so an absent one is the model being terse rather than the stage claiming a
31
- * tool it never had — it settles to `knowledge`, which is the floor anyway, and
32
- * is not reported as a correction nobody made.
33
- */
34
6
  export function compare(questions, answers, floor = DEFAULT_FLOOR, settledBy = SETTLED_WITHOUT_TOOLS) {
35
7
  const replies = new Map(answers.answers.map((a) => [a.id, a]));
36
8
  const asked = new Set(questions.questions.map((q) => q.id));
@@ -71,7 +43,6 @@ export function compare(questions, answers, floor = DEFAULT_FLOOR, settledBy = S
71
43
  };
72
44
  }
73
45
  const line = (c) => `${c.id}. ${c.claim}\n asked: ${c.question}\n found: ${c.unknown ? "nothing" : c.answer} (${c.settled ? "settled" : "unsettled"}, confidence ${c.confidence})${c.as_of ? `\n as of: ${c.as_of}` : ""}`;
74
- /** The verification as a stage reads it. */
75
46
  export function render(verification) {
76
47
  const parts = [
77
48
  `<checks>\n${verification.checked.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/workflows",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Ready-made workflows assembled from the spendgraph packages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,13 +33,13 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@spendgraph/harness": "^0.2.0",
37
- "@spendgraph/llms": "^0.2.0",
38
- "@spendgraph/prompt": "^0.2.0",
39
- "@spendgraph/tools": "^0.2.0"
36
+ "@spendgraph/harness": "^0.3.0",
37
+ "@spendgraph/llms": "^0.3.0",
38
+ "@spendgraph/prompt": "^0.3.0",
39
+ "@spendgraph/tools": "^0.3.0"
40
40
  },
41
41
  "scripts": {
42
- "build": "tsc -p tsconfig.json",
42
+ "build": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments",
43
43
  "prebuild": "npm run build --workspace @spendgraph/harness --workspace @spendgraph/llms --workspace @spendgraph/prompt --workspace @spendgraph/tools",
44
44
  "test": "vitest run",
45
45
  "eval": "vitest run --config evals/vitest.config.mts",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@locusgraph/client": "^0.8.1",
50
- "@spendgraph/evals": "^0.2.0",
50
+ "@spendgraph/evals": "^0.3.0",
51
51
  "typescript": "^5"
52
52
  },
53
53
  "engines": {