@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.
- package/README.md +141 -111
- package/dist/cascade/cascade.d.ts +13 -0
- package/dist/cascade/cascade.js +80 -0
- package/dist/cascade/index.d.ts +3 -0
- package/dist/cascade/index.js +2 -0
- package/dist/cascade/tier.d.ts +31 -0
- package/dist/cascade/tier.js +32 -0
- package/dist/cascade/types.d.ts +98 -0
- package/dist/chain/chain.d.ts +9 -0
- package/dist/chain/chain.js +50 -0
- package/dist/chain/gate.d.ts +16 -0
- package/dist/chain/gate.js +25 -0
- package/dist/chain/index.d.ts +3 -0
- package/dist/chain/index.js +2 -0
- package/dist/chain/step.d.ts +9 -0
- package/dist/chain/step.js +7 -0
- package/dist/chain/types.d.ts +50 -0
- package/dist/index.d.ts +16 -9
- package/dist/index.js +8 -7
- package/dist/loop/act.d.ts +21 -0
- package/dist/loop/act.js +29 -0
- package/dist/loop/compact.d.ts +49 -0
- package/dist/loop/compact.js +41 -0
- package/dist/loop/hooks.d.ts +43 -0
- package/dist/loop/hooks.js +31 -0
- package/dist/loop/index.d.ts +6 -0
- package/dist/loop/index.js +5 -0
- package/dist/loop/loop.d.ts +13 -0
- package/dist/loop/loop.js +99 -0
- package/dist/loop/turn.d.ts +6 -0
- package/dist/loop/turn.js +15 -0
- package/dist/loop/types.d.ts +140 -0
- package/dist/loop/types.js +1 -0
- package/dist/orchestrate/index.d.ts +4 -0
- package/dist/orchestrate/index.js +3 -0
- package/dist/orchestrate/orchestrate.d.ts +12 -0
- package/dist/orchestrate/orchestrate.js +56 -0
- package/dist/orchestrate/plan.d.ts +18 -0
- package/dist/orchestrate/plan.js +27 -0
- package/dist/orchestrate/types.d.ts +75 -0
- package/dist/orchestrate/types.js +1 -0
- package/dist/orchestrate/work.d.ts +13 -0
- package/dist/orchestrate/work.js +25 -0
- package/dist/parallel/index.d.ts +4 -0
- package/dist/parallel/index.js +3 -0
- package/dist/parallel/merge.d.ts +26 -0
- package/dist/parallel/merge.js +35 -0
- package/dist/parallel/parallel.d.ts +12 -0
- package/dist/parallel/parallel.js +56 -0
- package/dist/parallel/task.d.ts +13 -0
- package/dist/parallel/task.js +37 -0
- package/dist/parallel/types.d.ts +61 -0
- package/dist/parallel/types.js +1 -0
- package/dist/refine/attempt.d.ts +22 -0
- package/dist/refine/attempt.js +24 -0
- package/dist/refine/index.d.ts +4 -0
- package/dist/refine/index.js +3 -0
- package/dist/refine/judge.d.ts +15 -0
- package/dist/refine/judge.js +14 -0
- package/dist/refine/refine.d.ts +10 -0
- package/dist/refine/refine.js +59 -0
- package/dist/refine/types.d.ts +90 -0
- package/dist/refine/types.js +1 -0
- package/dist/route/classify.d.ts +52 -0
- package/dist/route/classify.js +33 -0
- package/dist/route/dispatch.d.ts +12 -0
- package/dist/route/dispatch.js +25 -0
- package/dist/route/index.d.ts +3 -0
- package/dist/route/index.js +2 -0
- package/dist/route/route.d.ts +11 -0
- package/dist/route/route.js +22 -0
- package/dist/route/types.d.ts +53 -0
- package/dist/route/types.js +1 -0
- package/dist/stream/graph.d.ts +9 -0
- package/dist/stream/graph.js +10 -0
- package/dist/stream/index.d.ts +2 -0
- package/dist/stream/index.js +1 -0
- package/dist/stream/stream.d.ts +19 -0
- package/dist/stream/stream.js +48 -0
- package/dist/stream/types.d.ts +31 -0
- package/dist/stream/types.js +1 -0
- package/docs/README.md +41 -0
- package/docs/cascade.md +165 -0
- package/docs/chain.md +134 -0
- package/docs/loop.md +318 -0
- package/docs/orchestrate.md +128 -0
- package/docs/parallel.md +139 -0
- package/docs/refine.md +206 -0
- package/docs/route.md +212 -0
- package/package.json +19 -15
- package/bin/harness.mjs +0 -76
- package/dist/core/budget.d.ts +0 -46
- package/dist/core/budget.js +0 -72
- package/dist/core/cache.d.ts +0 -119
- package/dist/core/cache.js +0 -206
- package/dist/core/client.d.ts +0 -33
- package/dist/core/client.js +0 -98
- package/dist/core/errors.d.ts +0 -22
- package/dist/core/errors.js +0 -28
- package/dist/core/index.d.ts +0 -5
- package/dist/core/index.js +0 -5
- package/dist/core/pull.d.ts +0 -17
- package/dist/core/pull.js +0 -38
- package/dist/core/schema/serialize.d.ts +0 -12
- package/dist/core/schema/serialize.js +0 -42
- package/dist/core/schema/types.d.ts +0 -58
- package/dist/core/schema/validate.d.ts +0 -28
- package/dist/core/schema/validate.js +0 -98
- package/dist/harness.d.ts +0 -128
- package/dist/harness.js +0 -187
- package/dist/prompt/codegen.d.ts +0 -37
- package/dist/prompt/codegen.js +0 -97
- package/dist/prompt/render.d.ts +0 -35
- package/dist/prompt/render.js +0 -66
- package/dist/prompt/run.d.ts +0 -38
- package/dist/prompt/run.js +0 -96
- package/dist/prompt/types.d.ts +0 -156
- /package/dist/{core/schema → cascade}/types.js +0 -0
- /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,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,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 {
|
|
2
|
-
export {
|
|
3
|
-
export type {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export type {
|
|
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 {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
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;
|
package/dist/loop/act.js
ADDED
|
@@ -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,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
|
+
}
|