@spendgraph/harness 0.1.0 → 0.2.1

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 +141 -111
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +80 -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 +32 -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 +50 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +25 -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 +7 -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 +29 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +41 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +31 -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 +99 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +15 -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 +56 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +27 -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 +25 -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 +35 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +56 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +37 -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 +24 -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 +14 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +59 -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 +33 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +25 -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 +22 -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 +10 -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 +48 -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 +19 -15
  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,25 @@
1
+ import { node } from "@spendgraph/graph";
2
+ export function gateName(step) {
3
+ return `gate_${step}`;
4
+ }
5
+ export class GateRefused extends Error {
6
+ step;
7
+ reason;
8
+ constructor(step, reason) {
9
+ super(`"${step}" did not pass its gate: ${reason}`);
10
+ this.step = step;
11
+ this.reason = reason;
12
+ this.name = "GateRefused";
13
+ }
14
+ }
15
+ export function gateNode(step) {
16
+ return node({
17
+ name: gateName(step.name),
18
+ run: async (_values, ctx) => {
19
+ const verdict = await step.gate?.(ctx.outputs[step.name], ctx);
20
+ if (!verdict || verdict.ok)
21
+ return verdict ?? { ok: true };
22
+ throw new GateRefused(step.name, verdict.reason ?? "no reason given");
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,3 @@
1
+ export { chain } from "./chain.js";
2
+ export { GateRefused, gateName } from "./gate.js";
3
+ export type { ChainOptions, ChainResult, Gate, Step } from "./types.js";
@@ -0,0 +1,2 @@
1
+ export { chain } from "./chain.js";
2
+ export { GateRefused, gateName } from "./gate.js";
@@ -0,0 +1,9 @@
1
+ import type { Step } from "./types.js";
2
+ /**
3
+ * A stage as a node.
4
+ *
5
+ * `previous` is read from the context rather than threaded through the input,
6
+ * because a gate may sit between two stages and its return is not what the next
7
+ * stage wants — the stage before it is.
8
+ */
9
+ export declare function stepNode(step: Step, previous: string | null): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>;
@@ -0,0 +1,7 @@
1
+ import { node } from "@spendgraph/graph";
2
+ export function stepNode(step, previous) {
3
+ return node({
4
+ name: step.name,
5
+ run: (values, ctx) => step.run(previous ? ctx.outputs[previous] : values, ctx),
6
+ });
7
+ }
@@ -0,0 +1,50 @@
1
+ import type { GraphContext, GraphResult } from "@spendgraph/graph";
2
+ import type { Emit } from "../stream/index.js";
3
+ /** Whether the chain may continue, and why not when it may not. */
4
+ export interface Gate {
5
+ ok: boolean;
6
+ /** Said in the record and in the error. A failed gate with no reason is a mystery. */
7
+ reason?: string;
8
+ }
9
+ /**
10
+ * One stage.
11
+ *
12
+ * `run` is handed whatever the previous stage returned — the values the chain
13
+ * was called with, for the first one.
14
+ */
15
+ export interface Step {
16
+ /** Unique in the chain, and the key its output is stored under. */
17
+ name: string;
18
+ run(previous: unknown, ctx: GraphContext): Promise<unknown> | unknown;
19
+ /**
20
+ * Whether that will do to continue on.
21
+ *
22
+ * The reason this pattern is worth naming: without a gate, a stage that
23
+ * produced nothing useful is passed to the next one, which confidently works
24
+ * on it, and you get a polished answer three calls later that is about
25
+ * nothing. A gate stops at the stage that went wrong and says which.
26
+ */
27
+ gate?(output: unknown, ctx: GraphContext): Promise<Gate> | Gate;
28
+ }
29
+ export interface ChainOptions {
30
+ steps: Step[];
31
+ /**
32
+ * Where the tokens go while this runs.
33
+ *
34
+ * Passed down to whatever makes the model call; omit it and nothing is
35
+ * emitted. `streamed()` supplies one.
36
+ */
37
+ emit?: Emit;
38
+ /** Ceiling on total steps, nested ones included. Default 25. */
39
+ maxSteps?: number;
40
+ /** Injectable clock, so tests do not measure real time. */
41
+ now?: () => number;
42
+ }
43
+ export interface ChainResult extends GraphResult {
44
+ /** The stages that ran, in order. */
45
+ ran: string[];
46
+ /** The stage whose gate refused, or which threw. Null when the chain finished. */
47
+ stoppedAt: string | null;
48
+ /** What the gate said. */
49
+ reason?: string;
50
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,16 @@
1
- export { Harness, type HarnessOptions } from "./harness.js";
2
- export { Budget, BudgetExceededError, Client, HarnessError, PullCache, pullWithCache, type CacheHit, type CacheOptions, type ClientOptions, } from "./core/index.js";
3
- export type { FieldSpec, FieldType } from "./core/schema/types.js";
4
- export { FieldValidationError, validateFields, type FieldError, } from "./core/schema/validate.js";
5
- export { serializeFields } from "./core/schema/serialize.js";
6
- export { applyVariables, compileSystemPrompt, findVariables, renderMessages, type Block, type Message, } from "./prompt/render.js";
7
- export { mapLimit, newRolloutId } from "./prompt/run.js";
8
- export { emitPromptTypes, typeOf, valuesTypeFor, type CodegenPrompt, type EmitOptions, } from "./prompt/codegen.js";
9
- export type { DatasetCase, DatasetSummary, Prompt, PromptPayload, ReportInput, Rollout, RunOptions, PromptVersion, Prompts, RunResult, Split, ValuesFor, } from "./prompt/types.js";
1
+ export type { Answer, CascadeOptions, CascadeResult, Escalation, Tier, TierRecord, } from "./cascade/index.js";
2
+ export { cascade } from "./cascade/index.js";
3
+ export type { ChainOptions, ChainResult, Gate, Step } from "./chain/index.js";
4
+ export { chain, GateRefused } from "./chain/index.js";
5
+ export type { Call, Compaction, Hooks, Invoker, LoopEnd, LoopOptions, LoopResult, Refusal, Remaining, Summary, Turn, TurnRecord, TurnState, } from "./loop/index.js";
6
+ export { loop } from "./loop/index.js";
7
+ export type { OrchestrateEnd, OrchestrateOptions, OrchestrateResult, Outcome, Plan, Subtask, } from "./orchestrate/index.js";
8
+ export { orchestrate } from "./orchestrate/index.js";
9
+ export type { ParallelOptions, ParallelResult, Task, TaskResult } from "./parallel/index.js";
10
+ export { majority, parallel, repeat, type Vote } from "./parallel/index.js";
11
+ export type { Attempt, RefineOptions, RefineResult, RoundRecord, Verdict } from "./refine/index.js";
12
+ export { refine } from "./refine/index.js";
13
+ export type { Classification, Handler, RouteOptions, RouteResult } from "./route/index.js";
14
+ export { route } from "./route/index.js";
15
+ export type { Emit, HarnessEvent, HarnessStream } from "./stream/index.js";
16
+ export { streamed } from "./stream/index.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- export { Harness } from "./harness.js";
2
- export { Budget, BudgetExceededError, Client, HarnessError, PullCache, pullWithCache, } from "./core/index.js";
3
- export { FieldValidationError, validateFields, } from "./core/schema/validate.js";
4
- export { serializeFields } from "./core/schema/serialize.js";
5
- export { applyVariables, compileSystemPrompt, findVariables, renderMessages, } from "./prompt/render.js";
6
- export { mapLimit, newRolloutId } from "./prompt/run.js";
7
- export { emitPromptTypes, typeOf, valuesTypeFor, } from "./prompt/codegen.js";
1
+ export { cascade } from "./cascade/index.js";
2
+ export { chain, GateRefused } from "./chain/index.js";
3
+ export { loop } from "./loop/index.js";
4
+ export { orchestrate } from "./orchestrate/index.js";
5
+ export { majority, parallel, repeat } from "./parallel/index.js";
6
+ export { refine } from "./refine/index.js";
7
+ export { route } from "./route/index.js";
8
+ export { streamed } from "./stream/index.js";
@@ -0,0 +1,21 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import { type ToolResult } from "@spendgraph/tools";
3
+ import { type Hooks } from "./hooks.js";
4
+ import type { Call, Invoker } from "./types.js";
5
+ /** One tool call as a step, failures included. */
6
+ export declare function callStep(index: number, result: ToolResult): RolloutStep;
7
+ /**
8
+ * Runs what the model asked for, in the order it asked.
9
+ *
10
+ * Never throws — `invoke` returns a failed result rather than raising, so a
11
+ * tool that fell over is something the model can read and try around, not
12
+ * something that ends the run.
13
+ */
14
+ export declare function act(tools: Invoker, calls: Call[], hooks?: Hooks): Promise<ToolResult[]>;
15
+ /**
16
+ * The answer, when the model said it was done.
17
+ *
18
+ * Reading `finish` here is what separates "it is done" from "it stopped asking",
19
+ * which otherwise look identical from outside and are opposite problems.
20
+ */
21
+ export declare function finished(results: ToolResult[]): string | null;
@@ -0,0 +1,29 @@
1
+ import { answerOf, isFinished } from "@spendgraph/tools";
2
+ import { ask, blockedResult, tell } from "./hooks.js";
3
+ export function callStep(index, result) {
4
+ return {
5
+ index,
6
+ source: result.name,
7
+ input: result.args,
8
+ output: result.output,
9
+ status: result.status,
10
+ error: result.error,
11
+ latencyMs: result.latencyMs,
12
+ };
13
+ }
14
+ export async function act(tools, calls, hooks = {}) {
15
+ const results = [];
16
+ for (const call of calls) {
17
+ const refused = await ask(hooks.beforeTool, call, tools.effectOf?.(call.name));
18
+ const result = refused
19
+ ? blockedResult(call, refused)
20
+ : await tools.invoke(call.name, call.args);
21
+ await tell(hooks.afterTool, result);
22
+ results.push(result);
23
+ }
24
+ return results;
25
+ }
26
+ export function finished(results) {
27
+ const done = results.find((result) => isFinished(result));
28
+ return done ? answerOf(done) : null;
29
+ }
@@ -0,0 +1,49 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { ToolResult } from "@spendgraph/tools";
3
+ import type { Turn } from "./types.js";
4
+ /** What the summariser produced, and what producing it cost. */
5
+ export interface Summary {
6
+ output: string;
7
+ model?: string;
8
+ inputTokens?: number;
9
+ outputTokens?: number;
10
+ }
11
+ export interface Compaction {
12
+ /**
13
+ * Fraction of `maxTokens` the resent context may reach before compacting.
14
+ * Default 0.7.
15
+ */
16
+ at?: number;
17
+ /** Compact once there are more results than this, whatever the tokens say. */
18
+ after?: number;
19
+ /** Most recent results kept verbatim. Default 4. */
20
+ keep?: number;
21
+ /** Turns the older results into one. */
22
+ summarise(older: ToolResult[]): Promise<Summary> | Summary;
23
+ }
24
+ declare const DEFAULT_KEEP = 4;
25
+ /**
26
+ * Whether the context is big enough to be worth summarising.
27
+ *
28
+ * Measured on the **last turn's input tokens**, not on what the run has spent.
29
+ * Spend only ever rises, so a threshold on it fires once and then every turn
30
+ * after; the input count is the size of what is actually being resent, and it
31
+ * falls the moment a compaction lands — which is what stops this firing twice
32
+ * for the same reason.
33
+ */
34
+ export declare function shouldCompact(compact: Compaction, last: Turn | null, results: ToolResult[], maxTokens?: number): boolean;
35
+ /** The summary as a tool result, so the next turn reads it like any other. */
36
+ export declare function summaryResult(summary: Summary, replaced: number): ToolResult;
37
+ /** The summarising call as a step, because deciding what to forget is not free. */
38
+ export declare function compactStep(index: number, summary: Summary, replaced: number): RolloutStep;
39
+ /**
40
+ * Replaces the older results with one summary, in place.
41
+ *
42
+ * The most recent `keep` survive verbatim: summarising what just happened is
43
+ * how a loop forgets what it was in the middle of doing.
44
+ */
45
+ export declare function applyCompaction(results: ToolResult[], summary: Summary, keep: number): {
46
+ replaced: number;
47
+ summarised: ToolResult;
48
+ };
49
+ export { DEFAULT_KEEP };
@@ -0,0 +1,41 @@
1
+ const DEFAULT_AT = 0.7;
2
+ const DEFAULT_KEEP = 4;
3
+ export function shouldCompact(compact, last, results, maxTokens) {
4
+ const keep = compact.keep ?? DEFAULT_KEEP;
5
+ if (results.length <= keep)
6
+ return false;
7
+ if (compact.after !== undefined && results.length > compact.after)
8
+ return true;
9
+ if (maxTokens === undefined || last === null)
10
+ return false;
11
+ const context = last.inputTokens ?? 0;
12
+ return context >= (compact.at ?? DEFAULT_AT) * maxTokens;
13
+ }
14
+ export function summaryResult(summary, replaced) {
15
+ return {
16
+ name: "compacted",
17
+ args: { replaced },
18
+ status: "completed",
19
+ output: summary.output,
20
+ latencyMs: 0,
21
+ };
22
+ }
23
+ export function compactStep(index, summary, replaced) {
24
+ return {
25
+ index,
26
+ source: "compact",
27
+ input: { replaced },
28
+ model: summary.model,
29
+ output: summary.output,
30
+ status: "completed",
31
+ inputTokens: summary.inputTokens,
32
+ outputTokens: summary.outputTokens,
33
+ };
34
+ }
35
+ export function applyCompaction(results, summary, keep) {
36
+ const older = results.splice(0, results.length - keep);
37
+ const summarised = summaryResult(summary, older.length);
38
+ results.unshift(summarised);
39
+ return { replaced: older.length, summarised };
40
+ }
41
+ export { DEFAULT_KEEP };
@@ -0,0 +1,43 @@
1
+ import type { Effect, ToolResult } from "@spendgraph/tools";
2
+ import type { Call, Turn, TurnState } from "./types.js";
3
+ /** Return a reason to refuse; return nothing to allow. */
4
+ export type Refusal = {
5
+ block: string;
6
+ } | undefined | void;
7
+ /**
8
+ * Deterministic code at fixed points in the loop.
9
+ *
10
+ * A guardrail written into the prompt is a request. This is enforcement: it
11
+ * runs whether or not the model read the instruction, and it cannot be argued
12
+ * out of by the next turn's wording.
13
+ */
14
+ export interface Hooks {
15
+ /** Before a model call. Blocking here ends the run — a turn has no way around it. */
16
+ beforeTurn?(state: TurnState): Promise<Refusal> | Refusal;
17
+ /**
18
+ * Before a tool runs. Blocking here is a failed result the model can work
19
+ * around.
20
+ *
21
+ * `effect` is what the tool declared, and `undefined` when it declared
22
+ * nothing. Gate on it rather than on `call.name`: a rule written against a
23
+ * name stops firing the day the tool is renamed, silently and in the
24
+ * permissive direction.
25
+ */
26
+ beforeTool?(call: Call, effect: Effect | undefined): Promise<Refusal> | Refusal;
27
+ /** After a tool ran. Observation only — the audit trail's natural home. */
28
+ afterTool?(result: ToolResult): Promise<void> | void;
29
+ /** After a model call. Observation only. */
30
+ afterTurn?(turn: Turn): Promise<void> | void;
31
+ }
32
+ /**
33
+ * Runs a hook that can refuse, treating its own failure as permission.
34
+ *
35
+ * A hook that throws is a bug in the guardrail, and taking the run down with it
36
+ * is worse than the thing it was guarding against — the call it was checking
37
+ * has not happened yet, and the next hook still gets its say.
38
+ */
39
+ export declare function ask(hook: ((...args: never[]) => Promise<Refusal> | Refusal) | undefined, ...args: unknown[]): Promise<string | null>;
40
+ /** Runs an observing hook. Its failure changes nothing; it was only watching. */
41
+ export declare function tell(hook: ((arg: never) => Promise<void> | void) | undefined, arg: unknown): Promise<void>;
42
+ /** A refused call, in the shape the model already knows how to read. */
43
+ export declare function blockedResult(call: Call, reason: string): ToolResult;
@@ -0,0 +1,31 @@
1
+ export async function ask(hook, ...args) {
2
+ if (!hook)
3
+ return null;
4
+ try {
5
+ const call = hook;
6
+ const verdict = await call(...args);
7
+ return verdict?.block ?? null;
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export async function tell(hook, arg) {
14
+ if (!hook)
15
+ return;
16
+ try {
17
+ await hook(arg);
18
+ }
19
+ catch {
20
+ }
21
+ }
22
+ export function blockedResult(call, reason) {
23
+ return {
24
+ name: call.name,
25
+ args: call.args,
26
+ status: "failed",
27
+ output: "",
28
+ error: `blocked: ${reason}`,
29
+ latencyMs: 0,
30
+ };
31
+ }
@@ -0,0 +1,6 @@
1
+ export { act, callStep, finished } from "./act.js";
2
+ export { applyCompaction, type Compaction, type Summary, shouldCompact } from "./compact.js";
3
+ export { blockedResult, type Hooks, type Refusal } from "./hooks.js";
4
+ export { loop } from "./loop.js";
5
+ export { tokensOf, turnStep } from "./turn.js";
6
+ export type { Call, Invoker, LoopEnd, LoopOptions, LoopResult, Remaining, Turn, TurnRecord, TurnState, } from "./types.js";
@@ -0,0 +1,5 @@
1
+ export { act, callStep, finished } from "./act.js";
2
+ export { applyCompaction, shouldCompact } from "./compact.js";
3
+ export { blockedResult } from "./hooks.js";
4
+ export { loop } from "./loop.js";
5
+ export { tokensOf, turnStep } from "./turn.js";
@@ -0,0 +1,13 @@
1
+ import type { LoopOptions, LoopResult } from "./types.js";
2
+ /**
3
+ * Model, tools, model again, until it stops asking.
4
+ *
5
+ * Not compiled to a graph: this is a cycle, and `@spendgraph/graph` is a DAG
6
+ * whose step ceiling exists to stop one. Same reasoning as `refine`.
7
+ *
8
+ * What this adds over a provider SDK's own tool loop is the part that is always
9
+ * hand-rolled and always wrong the same way: a ceiling that is checked before
10
+ * the spend rather than after, every turn and every tool call recorded, and
11
+ * `finished` told apart from `quiet`.
12
+ */
13
+ export declare function loop(opts: LoopOptions): Promise<LoopResult>;
@@ -0,0 +1,99 @@
1
+ import { act, callStep, finished } from "./act.js";
2
+ import { applyCompaction, compactStep, DEFAULT_KEEP, shouldCompact } from "./compact.js";
3
+ import { ask, tell } from "./hooks.js";
4
+ import { tokensOf, turnStep } from "./turn.js";
5
+ const DEFAULT_MAX_TURNS = 8;
6
+ export async function loop(opts) {
7
+ const startedAt = Date.now();
8
+ const maxTurns = Math.max(1, opts.maxTurns ?? DEFAULT_MAX_TURNS);
9
+ const steps = [];
10
+ const history = [];
11
+ const results = [];
12
+ let last = null;
13
+ let spent = 0;
14
+ let answer = null;
15
+ let stoppedBy = "turns";
16
+ const compactions = { count: 0, replaced: 0 };
17
+ const compactIfNeeded = async () => {
18
+ if (!opts.compact || !shouldCompact(opts.compact, last, results, opts.maxTokens))
19
+ return;
20
+ try {
21
+ const keep = opts.compact.keep ?? DEFAULT_KEEP;
22
+ const older = results.slice(0, results.length - keep);
23
+ const summary = await opts.compact.summarise(older);
24
+ const { replaced } = applyCompaction(results, summary, keep);
25
+ steps.push(compactStep(steps.length, summary, replaced));
26
+ spent += (summary.inputTokens ?? 0) + (summary.outputTokens ?? 0);
27
+ compactions.count += 1;
28
+ compactions.replaced += replaced;
29
+ }
30
+ catch {
31
+ }
32
+ };
33
+ const finish = (status, error) => ({
34
+ status,
35
+ output: status === "completed" ? (answer ?? last?.output ?? "") : "",
36
+ error,
37
+ steps,
38
+ outputs: { history },
39
+ latencyMs: Date.now() - startedAt,
40
+ inputTokens: history.reduce((n, r) => n + (r.outcome.inputTokens ?? 0), 0),
41
+ outputTokens: history.reduce((n, r) => n + (r.outcome.outputTokens ?? 0), 0),
42
+ turns: history.length,
43
+ stoppedBy,
44
+ compactions,
45
+ ...(answer === null ? {} : { answer }),
46
+ history,
47
+ });
48
+ for (let at = 0; at < maxTurns; at++) {
49
+ if (opts.maxTokens !== undefined && spent >= opts.maxTokens) {
50
+ stoppedBy = "tokens";
51
+ break;
52
+ }
53
+ await compactIfNeeded();
54
+ const remaining = {
55
+ turns: maxTurns - at,
56
+ tokens: opts.maxTokens === undefined ? Infinity : Math.max(0, opts.maxTokens - spent),
57
+ };
58
+ const state = {
59
+ turn: at,
60
+ results: [...results],
61
+ last,
62
+ remaining,
63
+ emit: (text, stage) => opts.emit?.(text, stage ?? `turn ${at}`),
64
+ };
65
+ const refused = await ask(opts.hooks?.beforeTurn, state);
66
+ if (refused) {
67
+ stoppedBy = "blocked";
68
+ return finish("failed", `A hook stopped the run: ${refused}`);
69
+ }
70
+ const outcome = await opts.turn(state);
71
+ steps.push(turnStep(steps.length, at, outcome));
72
+ spent += tokensOf(outcome);
73
+ last = outcome;
74
+ if (outcome.status === "failed") {
75
+ stoppedBy = "failed";
76
+ return finish("failed", outcome.error ?? "The turn failed.");
77
+ }
78
+ await tell(opts.hooks?.afterTurn, outcome);
79
+ const calls = outcome.toolCalls ?? [];
80
+ const ran = calls.length > 0 ? await act(opts.tools, calls, opts.hooks ?? {}) : [];
81
+ for (const result of ran) {
82
+ steps.push(callStep(steps.length, result));
83
+ results.push(result);
84
+ }
85
+ const record = { turn: at, outcome, results: ran, tokens: tokensOf(outcome) };
86
+ history.push(record);
87
+ opts.onTurn?.(record);
88
+ answer = finished(ran);
89
+ if (answer !== null) {
90
+ stoppedBy = "finished";
91
+ break;
92
+ }
93
+ if (calls.length === 0) {
94
+ stoppedBy = "quiet";
95
+ break;
96
+ }
97
+ }
98
+ return finish("completed");
99
+ }
@@ -0,0 +1,6 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Turn } from "./types.js";
3
+ /** What one turn consumed. Both halves, since the history is resent every time. */
4
+ export declare function tokensOf(turn: Turn): number;
5
+ /** One model call as a step. */
6
+ export declare function turnStep(index: number, at: number, turn: Turn): RolloutStep;
@@ -0,0 +1,15 @@
1
+ export function tokensOf(turn) {
2
+ return (turn.inputTokens ?? 0) + (turn.outputTokens ?? 0);
3
+ }
4
+ export function turnStep(index, at, turn) {
5
+ return {
6
+ index,
7
+ source: `turn_${at}`,
8
+ model: turn.model,
9
+ output: turn.output,
10
+ status: turn.status ?? "completed",
11
+ error: turn.error,
12
+ inputTokens: turn.inputTokens,
13
+ outputTokens: turn.outputTokens,
14
+ };
15
+ }
@@ -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
+ }