@spendgraph/harness 0.1.0 → 0.2.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 (119) hide show
  1. package/README.md +87 -160
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +91 -0
  4. package/dist/cascade/index.d.ts +3 -0
  5. package/dist/cascade/index.js +2 -0
  6. package/dist/cascade/tier.d.ts +31 -0
  7. package/dist/cascade/tier.js +53 -0
  8. package/dist/cascade/types.d.ts +98 -0
  9. package/dist/chain/chain.d.ts +9 -0
  10. package/dist/chain/chain.js +58 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +33 -0
  13. package/dist/chain/index.d.ts +3 -0
  14. package/dist/chain/index.js +2 -0
  15. package/dist/chain/step.d.ts +9 -0
  16. package/dist/chain/step.js +14 -0
  17. package/dist/chain/types.d.ts +50 -0
  18. package/dist/index.d.ts +16 -9
  19. package/dist/index.js +8 -7
  20. package/dist/loop/act.d.ts +21 -0
  21. package/dist/loop/act.js +43 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +59 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +41 -0
  26. package/dist/loop/index.d.ts +6 -0
  27. package/dist/loop/index.js +5 -0
  28. package/dist/loop/loop.d.ts +13 -0
  29. package/dist/loop/loop.js +120 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +17 -0
  32. package/dist/loop/types.d.ts +140 -0
  33. package/dist/loop/types.js +1 -0
  34. package/dist/orchestrate/index.d.ts +4 -0
  35. package/dist/orchestrate/index.js +3 -0
  36. package/dist/orchestrate/orchestrate.d.ts +12 -0
  37. package/dist/orchestrate/orchestrate.js +68 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +38 -0
  40. package/dist/orchestrate/types.d.ts +75 -0
  41. package/dist/orchestrate/types.js +1 -0
  42. package/dist/orchestrate/work.d.ts +13 -0
  43. package/dist/orchestrate/work.js +35 -0
  44. package/dist/parallel/index.d.ts +4 -0
  45. package/dist/parallel/index.js +3 -0
  46. package/dist/parallel/merge.d.ts +26 -0
  47. package/dist/parallel/merge.js +52 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +66 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +45 -0
  52. package/dist/parallel/types.d.ts +61 -0
  53. package/dist/parallel/types.js +1 -0
  54. package/dist/refine/attempt.d.ts +22 -0
  55. package/dist/refine/attempt.js +38 -0
  56. package/dist/refine/index.d.ts +4 -0
  57. package/dist/refine/index.js +3 -0
  58. package/dist/refine/judge.d.ts +15 -0
  59. package/dist/refine/judge.js +25 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +67 -0
  62. package/dist/refine/types.d.ts +90 -0
  63. package/dist/refine/types.js +1 -0
  64. package/dist/route/classify.d.ts +52 -0
  65. package/dist/route/classify.js +57 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +33 -0
  68. package/dist/route/index.d.ts +3 -0
  69. package/dist/route/index.js +2 -0
  70. package/dist/route/route.d.ts +11 -0
  71. package/dist/route/route.js +31 -0
  72. package/dist/route/types.d.ts +53 -0
  73. package/dist/route/types.js +1 -0
  74. package/dist/stream/graph.d.ts +9 -0
  75. package/dist/stream/graph.js +16 -0
  76. package/dist/stream/index.d.ts +2 -0
  77. package/dist/stream/index.js +1 -0
  78. package/dist/stream/stream.d.ts +19 -0
  79. package/dist/stream/stream.js +65 -0
  80. package/dist/stream/types.d.ts +31 -0
  81. package/dist/stream/types.js +1 -0
  82. package/docs/README.md +41 -0
  83. package/docs/cascade.md +165 -0
  84. package/docs/chain.md +134 -0
  85. package/docs/loop.md +318 -0
  86. package/docs/orchestrate.md +128 -0
  87. package/docs/parallel.md +139 -0
  88. package/docs/refine.md +206 -0
  89. package/docs/route.md +212 -0
  90. package/package.json +18 -14
  91. package/bin/harness.mjs +0 -76
  92. package/dist/core/budget.d.ts +0 -46
  93. package/dist/core/budget.js +0 -72
  94. package/dist/core/cache.d.ts +0 -119
  95. package/dist/core/cache.js +0 -206
  96. package/dist/core/client.d.ts +0 -33
  97. package/dist/core/client.js +0 -98
  98. package/dist/core/errors.d.ts +0 -22
  99. package/dist/core/errors.js +0 -28
  100. package/dist/core/index.d.ts +0 -5
  101. package/dist/core/index.js +0 -5
  102. package/dist/core/pull.d.ts +0 -17
  103. package/dist/core/pull.js +0 -38
  104. package/dist/core/schema/serialize.d.ts +0 -12
  105. package/dist/core/schema/serialize.js +0 -42
  106. package/dist/core/schema/types.d.ts +0 -58
  107. package/dist/core/schema/validate.d.ts +0 -28
  108. package/dist/core/schema/validate.js +0 -98
  109. package/dist/harness.d.ts +0 -128
  110. package/dist/harness.js +0 -187
  111. package/dist/prompt/codegen.d.ts +0 -37
  112. package/dist/prompt/codegen.js +0 -97
  113. package/dist/prompt/render.d.ts +0 -35
  114. package/dist/prompt/render.js +0 -66
  115. package/dist/prompt/run.d.ts +0 -38
  116. package/dist/prompt/run.js +0 -96
  117. package/dist/prompt/types.d.ts +0 -156
  118. /package/dist/{core/schema → cascade}/types.js +0 -0
  119. /package/dist/{prompt → chain}/types.js +0 -0
@@ -0,0 +1,140 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Effect, ToolResult } from "@spendgraph/tools";
3
+ import type { Emit } from "../stream/index.js";
4
+ import type { Compaction } from "./compact.js";
5
+ import type { Hooks } from "./hooks.js";
6
+ /** One call the model wants made. */
7
+ export interface Call {
8
+ id?: string;
9
+ name: string;
10
+ args: Record<string, unknown>;
11
+ }
12
+ /** What one model call produced. */
13
+ export interface Turn {
14
+ output: string;
15
+ model: string;
16
+ inputTokens?: number;
17
+ outputTokens?: number;
18
+ status?: "completed" | "failed";
19
+ error?: string;
20
+ /** What the model wants run. Empty means it has stopped asking. */
21
+ toolCalls?: Call[];
22
+ }
23
+ /**
24
+ * What is left of the ceilings, so the model can plan inside them.
25
+ *
26
+ * `Infinity` where no ceiling was set, never zero. A missing budget read as an
27
+ * exhausted one turns the obvious `remaining.tokens < 1000` into a check that
28
+ * fires on the first turn of every unbounded run, and the agent gives up before
29
+ * starting.
30
+ */
31
+ export interface Remaining {
32
+ /** Turns left, this one included. */
33
+ turns: number;
34
+ /** Tokens left before `maxTokens`, or `Infinity` when there is none. */
35
+ tokens: number;
36
+ }
37
+ /** What the model has been told so far. */
38
+ export interface TurnState {
39
+ /** Which turn this is, from zero. */
40
+ turn: number;
41
+ /** Every tool result so far, in call order. */
42
+ results: ToolResult[];
43
+ /** What the last turn said, or null on the first. */
44
+ last: Turn | null;
45
+ /**
46
+ * What is left to spend.
47
+ *
48
+ * Put it in the prompt yourself. A loop that knows it has one turn left and
49
+ * does not say so gets a five-step plan and cuts it off after the first —
50
+ * paid for in full. Which wording works is task-specific, and injecting a
51
+ * sentence into your messages would break your cached prefix without asking.
52
+ */
53
+ remaining: Remaining;
54
+ /** Where this turn's tokens go. A no-op unless the loop was given an `emit`. */
55
+ emit: Emit;
56
+ }
57
+ /** Anything that can run a tool by name. A `toolbus()` turn satisfies it. */
58
+ export interface Invoker {
59
+ invoke(name: string, args: Record<string, unknown>): Promise<ToolResult>;
60
+ /**
61
+ * What a tool declares it does, handed to `beforeTool` so a guard can gate on
62
+ * the effect rather than on the name.
63
+ *
64
+ * Optional, so anything with an `invoke` is still an `Invoker`. Where it is
65
+ * missing every effect reads as unknown, which is what it is.
66
+ */
67
+ effectOf?(name: string): Effect | undefined;
68
+ }
69
+ export interface LoopOptions {
70
+ /** One model call. Hand it whatever your provider needs. */
71
+ turn(state: TurnState): Promise<Turn> | Turn;
72
+ /**
73
+ * Where the tokens go while this runs.
74
+ *
75
+ * Passed down to whatever makes the model call; omit it and nothing is
76
+ * emitted. `streamed()` supplies one.
77
+ */
78
+ emit?: Emit;
79
+ /** Runs what the model asked for. */
80
+ tools: Invoker;
81
+ /** Most model calls. Default 8. */
82
+ maxTurns?: number;
83
+ /**
84
+ * Stop once the turns have consumed this many tokens.
85
+ *
86
+ * Tokens rather than money because this package cannot price a call — that
87
+ * happens on the server, after the fact. Checked before each turn, so the
88
+ * ceiling is never crossed rather than merely noticed.
89
+ */
90
+ maxTokens?: number;
91
+ /**
92
+ * Summarise the older tool results once the resent context gets large.
93
+ *
94
+ * A long run gets expensive because the transcript grows, not because the
95
+ * work got harder. Without this the token ceiling is reached by history
96
+ * rather than by progress.
97
+ */
98
+ compact?: Compaction;
99
+ /**
100
+ * Deterministic code at fixed points, so a guardrail does not depend on the
101
+ * model having read the prompt.
102
+ */
103
+ hooks?: Hooks;
104
+ /** Called after each turn, so a long loop is not silent. */
105
+ onTurn?: (record: TurnRecord) => void;
106
+ }
107
+ export interface TurnRecord {
108
+ turn: number;
109
+ outcome: Turn;
110
+ results: ToolResult[];
111
+ tokens: number;
112
+ }
113
+ /**
114
+ * Why the loop ended.
115
+ *
116
+ * `finished` and `quiet` are the pair worth telling apart: the first is the
117
+ * model saying it is done, the second is it simply not asking for anything
118
+ * more — which is also what being confused looks like.
119
+ */
120
+ export type LoopEnd = "finished" | "quiet" | "turns" | "tokens" | "blocked" | "failed";
121
+ export interface LoopResult {
122
+ status: "completed" | "failed";
123
+ output: string;
124
+ error?: string;
125
+ steps: RolloutStep[];
126
+ outputs: Record<string, unknown>;
127
+ latencyMs: number;
128
+ inputTokens: number;
129
+ outputTokens: number;
130
+ turns: number;
131
+ stoppedBy: LoopEnd;
132
+ /** How many times the history was summarised, and how many results that replaced. */
133
+ compactions: {
134
+ count: number;
135
+ replaced: number;
136
+ };
137
+ /** The answer `finish` carried, when the model used it. */
138
+ answer?: string;
139
+ history: TurnRecord[];
140
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export { orchestrate } from "./orchestrate.js";
2
+ export { capped, planStep } from "./plan.js";
3
+ export type { OrchestrateEnd, OrchestrateOptions, OrchestrateResult, Outcome, Plan, Subtask, } from "./types.js";
4
+ export { synthesisStep, work } from "./work.js";
@@ -0,0 +1,3 @@
1
+ export { orchestrate } from "./orchestrate.js";
2
+ export { capped, planStep } from "./plan.js";
3
+ export { synthesisStep, work } from "./work.js";
@@ -0,0 +1,12 @@
1
+ import type { OrchestrateOptions, OrchestrateResult } from "./types.js";
2
+ /**
3
+ * A lead decomposes, workers do the pieces, the lead puts it back together.
4
+ *
5
+ * The workflow for work whose shape is not known until the input is read — the
6
+ * difference from `chain`, where the stages are named in advance.
7
+ *
8
+ * It is also the one that can spend the most: the lead decides how many calls
9
+ * to make, so `maxWorkers` is a cap on something a model chose rather than on
10
+ * something you did.
11
+ */
12
+ export declare function orchestrate(values: Record<string, unknown>, opts: OrchestrateOptions): Promise<OrchestrateResult>;
@@ -0,0 +1,68 @@
1
+ import { capped, planStep } from "./plan.js";
2
+ import { synthesisStep, work } from "./work.js";
3
+ function tokens(steps) {
4
+ let inputTokens = 0;
5
+ let outputTokens = 0;
6
+ for (const step of steps) {
7
+ inputTokens += step.inputTokens ?? 0;
8
+ outputTokens += step.outputTokens ?? 0;
9
+ }
10
+ return { inputTokens, outputTokens };
11
+ }
12
+ /**
13
+ * A lead decomposes, workers do the pieces, the lead puts it back together.
14
+ *
15
+ * The workflow for work whose shape is not known until the input is read — the
16
+ * difference from `chain`, where the stages are named in advance.
17
+ *
18
+ * It is also the one that can spend the most: the lead decides how many calls
19
+ * to make, so `maxWorkers` is a cap on something a model chose rather than on
20
+ * something you did.
21
+ */
22
+ export async function orchestrate(values, opts) {
23
+ const startedAt = Date.now();
24
+ const steps = [];
25
+ let subtasks = [];
26
+ let dropped = [];
27
+ let results = [];
28
+ const finish = (status, stoppedBy, output, error) => ({
29
+ status,
30
+ output,
31
+ error,
32
+ steps,
33
+ outputs: { subtasks, results },
34
+ latencyMs: Date.now() - startedAt,
35
+ ...tokens(steps),
36
+ subtasks,
37
+ dropped,
38
+ ran: results.filter((r) => r.status === "completed").map((r) => r.name),
39
+ failed: results.filter((r) => r.status === "failed").map((r) => r.name),
40
+ stoppedBy,
41
+ });
42
+ const plan = await opts.plan(values);
43
+ ({ subtasks, dropped } = capped(plan, opts.maxWorkers));
44
+ steps.push(planStep(steps.length, plan, dropped));
45
+ opts.onPlan?.(plan, dropped);
46
+ if (subtasks.length === 0) {
47
+ // Nothing to delegate. Handing back the lead's own words beats synthesising
48
+ // over an empty list, which invents an answer from nothing.
49
+ return finish("completed", "planned-nothing", plan.output ?? "");
50
+ }
51
+ const spentOnPlan = (plan.inputTokens ?? 0) + (plan.outputTokens ?? 0);
52
+ if (opts.maxTokens !== undefined && spentOnPlan >= opts.maxTokens) {
53
+ return finish("failed", "tokens", "", "The plan alone reached the token ceiling.");
54
+ }
55
+ const done = await work(subtasks, values, opts);
56
+ steps.push(...done.steps.map((step, at) => ({ ...step, index: steps.length + at })));
57
+ results = done.results;
58
+ if (done.status === "failed") {
59
+ return finish("failed", "workers-failed", "", done.error);
60
+ }
61
+ const { inputTokens, outputTokens } = tokens(steps);
62
+ if (opts.maxTokens !== undefined && inputTokens + outputTokens >= opts.maxTokens) {
63
+ return finish("failed", "tokens", "", "The workers reached the token ceiling before synthesis.");
64
+ }
65
+ const synthesis = await opts.synthesise(results.filter((r) => r.status === "completed"), values, (text, stage) => opts.emit?.(text, stage ?? "synthesis"));
66
+ steps.push(synthesisStep(steps.length, synthesis));
67
+ return finish("completed", "completed", synthesis.output);
68
+ }
@@ -0,0 +1,18 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Plan, Subtask } from "./types.js";
3
+ /**
4
+ * The subtasks that will run, and the ones the cap refused.
5
+ *
6
+ * Dropping the tail rather than the head: a lead asked for the most important
7
+ * piece first usually gives it first, and truncating from the front would throw
8
+ * away exactly what it thought mattered.
9
+ *
10
+ * Duplicated names are made unique rather than rejected — a lead naming two
11
+ * sections "summary" is a wording problem, not a reason to lose the run.
12
+ */
13
+ export declare function capped(plan: Plan, max?: number): {
14
+ subtasks: Subtask[];
15
+ dropped: Subtask[];
16
+ };
17
+ /** The lead's own call as a step, because deciding is not free. */
18
+ export declare function planStep(index: number, plan: Plan, dropped: Subtask[]): RolloutStep;
@@ -0,0 +1,38 @@
1
+ const DEFAULT_MAX_WORKERS = 5;
2
+ /**
3
+ * The subtasks that will run, and the ones the cap refused.
4
+ *
5
+ * Dropping the tail rather than the head: a lead asked for the most important
6
+ * piece first usually gives it first, and truncating from the front would throw
7
+ * away exactly what it thought mattered.
8
+ *
9
+ * Duplicated names are made unique rather than rejected — a lead naming two
10
+ * sections "summary" is a wording problem, not a reason to lose the run.
11
+ */
12
+ export function capped(plan, max = DEFAULT_MAX_WORKERS) {
13
+ const limit = Math.max(1, max);
14
+ const seen = new Map();
15
+ const named = plan.subtasks.map((subtask) => {
16
+ const count = seen.get(subtask.name) ?? 0;
17
+ seen.set(subtask.name, count + 1);
18
+ return count === 0 ? subtask : { ...subtask, name: `${subtask.name}_${count}` };
19
+ });
20
+ return { subtasks: named.slice(0, limit), dropped: named.slice(limit) };
21
+ }
22
+ /** The lead's own call as a step, because deciding is not free. */
23
+ export function planStep(index, plan, dropped) {
24
+ return {
25
+ index,
26
+ source: "plan",
27
+ model: plan.model,
28
+ output: plan.output ?? JSON.stringify(plan.subtasks.map((s) => s.name)),
29
+ status: "completed",
30
+ inputTokens: plan.inputTokens,
31
+ outputTokens: plan.outputTokens,
32
+ ...(dropped.length > 0
33
+ ? {
34
+ error: `${dropped.length} subtask(s) past the worker cap were dropped: ${dropped.map((s) => s.name).join(", ")}`,
35
+ }
36
+ : {}),
37
+ };
38
+ }
@@ -0,0 +1,75 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { TaskResult } from "../parallel/index.js";
3
+ import type { Emit } from "../stream/index.js";
4
+ /** One piece of work the lead invented. Anything else on it is yours. */
5
+ export interface Subtask {
6
+ /** Unique in the run, and the name its step is recorded under. */
7
+ name: string;
8
+ [key: string]: unknown;
9
+ }
10
+ /** What the lead decided to do, and what deciding cost. */
11
+ export interface Plan {
12
+ subtasks: Subtask[];
13
+ output?: string;
14
+ model?: string;
15
+ inputTokens?: number;
16
+ outputTokens?: number;
17
+ }
18
+ /** What a worker or the synthesis produced. */
19
+ export interface Outcome {
20
+ output: string;
21
+ model?: string;
22
+ inputTokens?: number;
23
+ outputTokens?: number;
24
+ }
25
+ export interface OrchestrateOptions {
26
+ /** The lead reads the input and says what the pieces are. */
27
+ plan(values: Record<string, unknown>): Promise<Plan> | Plan;
28
+ /** One piece of work. */
29
+ work(subtask: Subtask, values: Record<string, unknown>, emit: Emit): Promise<Outcome> | Outcome;
30
+ /** The lead turns what came back into the answer. */
31
+ synthesise(results: TaskResult[], values: Record<string, unknown>, emit: Emit): Promise<Outcome> | Outcome;
32
+ /**
33
+ * Where the tokens go while this runs.
34
+ *
35
+ * Passed down to whatever makes the model call; omit it and nothing is
36
+ * emitted. `streamed()` supplies one.
37
+ */
38
+ emit?: Emit;
39
+ /**
40
+ * Most workers to run. Default 5.
41
+ *
42
+ * The failure this exists for: a lead asked to decompose a large input
43
+ * invents twelve subtasks, and twelve model calls leave before anyone has
44
+ * decided that was reasonable. Anything past the cap is dropped and **said**,
45
+ * never dropped quietly.
46
+ */
47
+ maxWorkers?: number;
48
+ /** Most workers in flight at once. Default 4. */
49
+ concurrency?: number;
50
+ /** How many must succeed before synthesising. Defaults to all that ran. */
51
+ minSuccess?: number;
52
+ /** Checked before the workers and before the synthesis. */
53
+ maxTokens?: number;
54
+ onPlan?: (plan: Plan, dropped: Subtask[]) => void;
55
+ onWork?: (result: TaskResult) => void;
56
+ }
57
+ /** Why it ended. `planned-nothing` is the lead finding no work to do. */
58
+ export type OrchestrateEnd = "completed" | "planned-nothing" | "workers-failed" | "tokens" | "failed";
59
+ export interface OrchestrateResult {
60
+ status: "completed" | "failed";
61
+ output: string;
62
+ error?: string;
63
+ steps: RolloutStep[];
64
+ outputs: Record<string, unknown>;
65
+ latencyMs: number;
66
+ inputTokens: number;
67
+ outputTokens: number;
68
+ /** What the lead asked for, after the cap. */
69
+ subtasks: Subtask[];
70
+ /** What the cap refused. Empty when the lead stayed within it. */
71
+ dropped: Subtask[];
72
+ ran: string[];
73
+ failed: string[];
74
+ stoppedBy: OrchestrateEnd;
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import { type ParallelResult } from "../parallel/index.js";
3
+ import type { OrchestrateOptions, Outcome, Subtask } from "./types.js";
4
+ /**
5
+ * Every worker at once.
6
+ *
7
+ * Delegated to `parallel` rather than fanned out again here: it already caps the
8
+ * concurrency, already turns a thrown worker into a result so the others land,
9
+ * and already numbers the steps by declaration so two runs can be compared.
10
+ */
11
+ export declare function work(subtasks: Subtask[], values: Record<string, unknown>, opts: OrchestrateOptions): Promise<ParallelResult>;
12
+ /** The lead's second call as a step. */
13
+ export declare function synthesisStep(index: number, outcome: Outcome): RolloutStep;
@@ -0,0 +1,35 @@
1
+ import { parallel } from "../parallel/index.js";
2
+ /**
3
+ * Every worker at once.
4
+ *
5
+ * Delegated to `parallel` rather than fanned out again here: it already caps the
6
+ * concurrency, already turns a thrown worker into a result so the others land,
7
+ * and already numbers the steps by declaration so two runs can be compared.
8
+ */
9
+ export function work(subtasks, values, opts) {
10
+ return parallel(values, {
11
+ tasks: subtasks.map((subtask) => ({
12
+ name: subtask.name,
13
+ run: (_values, emit) => opts.work(subtask, values, emit),
14
+ })),
15
+ emit: opts.emit,
16
+ concurrency: opts.concurrency,
17
+ minSuccess: opts.minSuccess ?? subtasks.length,
18
+ onTask: opts.onWork,
19
+ // `orchestrate` synthesises with the lead rather than merging here, so this
20
+ // only has to hand the results back untouched.
21
+ merge: (results) => results,
22
+ });
23
+ }
24
+ /** The lead's second call as a step. */
25
+ export function synthesisStep(index, outcome) {
26
+ return {
27
+ index,
28
+ source: "synthesise",
29
+ model: outcome.model,
30
+ output: outcome.output,
31
+ status: "completed",
32
+ inputTokens: outcome.inputTokens,
33
+ outputTokens: outcome.outputTokens,
34
+ };
35
+ }
@@ -0,0 +1,4 @@
1
+ export { majority, repeat, type Vote } from "./merge.js";
2
+ export { parallel } from "./parallel.js";
3
+ export { runTask, taskStep } from "./task.js";
4
+ export type { ParallelOptions, ParallelResult, Task, TaskResult } from "./types.js";
@@ -0,0 +1,3 @@
1
+ export { majority, repeat } from "./merge.js";
2
+ export { parallel } from "./parallel.js";
3
+ export { runTask, taskStep } from "./task.js";
@@ -0,0 +1,26 @@
1
+ import type { Task, TaskResult } from "./types.js";
2
+ /**
3
+ * The same task, N times over, for voting.
4
+ *
5
+ * An odd N on purpose — an even one ties, and a tie has no answer that is not
6
+ * arbitrary. Ask for an even number and you get the next one up.
7
+ */
8
+ export declare function repeat(name: string, times: number, run: (values: Record<string, unknown>, at: number) => Promise<unknown> | unknown): Task[];
9
+ export interface Vote<T = unknown> {
10
+ value: T;
11
+ /** How many of the tasks agreed on it. */
12
+ count: number;
13
+ /** Of the ones that succeeded. */
14
+ of: number;
15
+ }
16
+ /**
17
+ * The answer most of them gave.
18
+ *
19
+ * Voting is worth the tokens where a model is right most of the time and wrong
20
+ * differently each time — the wrong answers scatter and the right one repeats.
21
+ * It buys nothing where the model is confidently wrong the same way every time,
22
+ * which is the failure it is easiest to mistake it for a fix for.
23
+ *
24
+ * Ties go to the first declared, so the result is the same on every run.
25
+ */
26
+ export declare function majority<T = unknown>(results: TaskResult[]): Vote<T> | undefined;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The same task, N times over, for voting.
3
+ *
4
+ * An odd N on purpose — an even one ties, and a tie has no answer that is not
5
+ * arbitrary. Ask for an even number and you get the next one up.
6
+ */
7
+ export function repeat(name, times, run) {
8
+ const odd = Math.max(1, times % 2 === 0 ? times + 1 : times);
9
+ return Array.from({ length: odd }, (_, at) => ({
10
+ name: `${name}_${at}`,
11
+ run: (values) => run(values, at),
12
+ }));
13
+ }
14
+ /** How a value is compared when voting. Its own text, or the whole thing. */
15
+ function ballot(value) {
16
+ if (value &&
17
+ typeof value === "object" &&
18
+ typeof value.output === "string") {
19
+ return value.output;
20
+ }
21
+ return typeof value === "string" ? value : JSON.stringify(value);
22
+ }
23
+ /**
24
+ * The answer most of them gave.
25
+ *
26
+ * Voting is worth the tokens where a model is right most of the time and wrong
27
+ * differently each time — the wrong answers scatter and the right one repeats.
28
+ * It buys nothing where the model is confidently wrong the same way every time,
29
+ * which is the failure it is easiest to mistake it for a fix for.
30
+ *
31
+ * Ties go to the first declared, so the result is the same on every run.
32
+ */
33
+ export function majority(results) {
34
+ const done = results.filter((r) => r.status === "completed");
35
+ if (done.length === 0)
36
+ return undefined;
37
+ const tally = new Map();
38
+ for (const result of done) {
39
+ const key = ballot(result.value);
40
+ const seen = tally.get(key);
41
+ if (seen)
42
+ seen.count++;
43
+ else
44
+ tally.set(key, { value: result.value, count: 1 });
45
+ }
46
+ let best = { value: done[0].value, count: 0 };
47
+ for (const entry of tally.values()) {
48
+ if (entry.count > best.count)
49
+ best = entry;
50
+ }
51
+ return { value: best.value, count: best.count, of: done.length };
52
+ }
@@ -0,0 +1,12 @@
1
+ import type { ParallelOptions, ParallelResult } from "./types.js";
2
+ /**
3
+ * Every task at once, then one answer out of what came back.
4
+ *
5
+ * Not compiled to a graph like `route` and `chain`: `@spendgraph/graph` walks one
6
+ * node at a time and picks a single edge, so a fan-out is not a shape it can
7
+ * express. The concurrency comes from `mapLimit`, which already exists.
8
+ *
9
+ * A task that throws is a result, not an exception — the point of running five
10
+ * is that the other four still land.
11
+ */
12
+ export declare function parallel(values: Record<string, unknown>, opts: ParallelOptions): Promise<ParallelResult>;
@@ -0,0 +1,66 @@
1
+ import { mapLimit } from "@spendgraph/prompt/internals";
2
+ import { runTask, taskStep } from "./task.js";
3
+ const DEFAULT_CONCURRENCY = 4;
4
+ function totals(results) {
5
+ let inputTokens = 0;
6
+ let outputTokens = 0;
7
+ for (const result of results) {
8
+ inputTokens += result.inputTokens;
9
+ outputTokens += result.outputTokens;
10
+ }
11
+ return { inputTokens, outputTokens };
12
+ }
13
+ function stringify(value) {
14
+ if (value === undefined || value === null)
15
+ return "";
16
+ return typeof value === "string" ? value : JSON.stringify(value);
17
+ }
18
+ /**
19
+ * Every task at once, then one answer out of what came back.
20
+ *
21
+ * Not compiled to a graph like `route` and `chain`: `@spendgraph/graph` walks one
22
+ * node at a time and picks a single edge, so a fan-out is not a shape it can
23
+ * express. The concurrency comes from `mapLimit`, which already exists.
24
+ *
25
+ * A task that throws is a result, not an exception — the point of running five
26
+ * is that the other four still land.
27
+ */
28
+ export async function parallel(values, opts) {
29
+ if (opts.tasks.length === 0)
30
+ throw new Error("A parallel run needs at least one task.");
31
+ const names = new Set();
32
+ for (const task of opts.tasks) {
33
+ if (names.has(task.name))
34
+ throw new Error(`Two tasks are called "${task.name}".`);
35
+ names.add(task.name);
36
+ }
37
+ const startedAt = Date.now();
38
+ const results = await mapLimit(opts.tasks, opts.concurrency ?? DEFAULT_CONCURRENCY, async (task) => {
39
+ const result = await runTask(task, values, opts.emit);
40
+ opts.onTask?.(result);
41
+ return result;
42
+ });
43
+ const steps = results.map((result, at) => taskStep(at, result));
44
+ const succeeded = results.filter((r) => r.status === "completed");
45
+ const needed = opts.minSuccess ?? opts.tasks.length;
46
+ const outputs = Object.fromEntries(results.map((r) => [r.name, r.value]));
47
+ const base = {
48
+ steps,
49
+ outputs,
50
+ latencyMs: Date.now() - startedAt,
51
+ ...totals(results),
52
+ results,
53
+ succeeded: succeeded.map((r) => r.name),
54
+ failed: results.filter((r) => r.status === "failed").map((r) => r.name),
55
+ };
56
+ if (succeeded.length < needed) {
57
+ return {
58
+ ...base,
59
+ status: "failed",
60
+ output: "",
61
+ error: `${succeeded.length} of ${opts.tasks.length} tasks succeeded, and ${needed} were needed.`,
62
+ };
63
+ }
64
+ const merged = opts.merge(succeeded, values);
65
+ return { ...base, status: "completed", output: stringify(merged), merged };
66
+ }
@@ -0,0 +1,13 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Emit } from "../stream/index.js";
3
+ import type { Task, TaskResult } from "./types.js";
4
+ /** Runs one task. Never throws — a failure is a result, so the others still land. */
5
+ export declare function runTask(task: Task, values: Record<string, unknown>, emit?: Emit): Promise<TaskResult>;
6
+ /**
7
+ * One task as a step.
8
+ *
9
+ * Indexed by where it was declared rather than when it landed: a fan-out that
10
+ * numbered its steps by completion order would record a different run every
11
+ * time for the same work, which makes two runs impossible to compare.
12
+ */
13
+ export declare function taskStep(index: number, result: TaskResult): RolloutStep;
@@ -0,0 +1,45 @@
1
+ function counted(value) {
2
+ return value && typeof value === "object" ? value : {};
3
+ }
4
+ /** Runs one task. Never throws — a failure is a result, so the others still land. */
5
+ export async function runTask(task, values, emit) {
6
+ try {
7
+ const value = await task.run(values, (text, stage) => emit?.(text, stage ?? task.name));
8
+ const { inputTokens = 0, outputTokens = 0 } = counted(value);
9
+ return { name: task.name, status: "completed", value, inputTokens, outputTokens };
10
+ }
11
+ catch (err) {
12
+ return {
13
+ name: task.name,
14
+ status: "failed",
15
+ error: err instanceof Error ? err.message : String(err),
16
+ inputTokens: 0,
17
+ outputTokens: 0,
18
+ };
19
+ }
20
+ }
21
+ /**
22
+ * One task as a step.
23
+ *
24
+ * Indexed by where it was declared rather than when it landed: a fan-out that
25
+ * numbered its steps by completion order would record a different run every
26
+ * time for the same work, which makes two runs impossible to compare.
27
+ */
28
+ export function taskStep(index, result) {
29
+ const { output, model } = counted(result.value);
30
+ return {
31
+ index,
32
+ source: result.name,
33
+ model,
34
+ output: result.status === "completed" ? (output ?? stringify(result.value)) : "",
35
+ status: result.status,
36
+ error: result.error,
37
+ inputTokens: result.inputTokens,
38
+ outputTokens: result.outputTokens,
39
+ };
40
+ }
41
+ function stringify(value) {
42
+ if (value === undefined || value === null)
43
+ return "";
44
+ return typeof value === "string" ? value : JSON.stringify(value);
45
+ }