@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,59 @@
|
|
|
1
|
+
import { attemptStep, tokensOf, totalTokens } from "./attempt.js";
|
|
2
|
+
import { best, judgeStep } from "./judge.js";
|
|
3
|
+
const DEFAULT_ROUNDS = 3;
|
|
4
|
+
export async function refine(opts) {
|
|
5
|
+
const startedAt = Date.now();
|
|
6
|
+
const rounds = Math.max(1, opts.rounds ?? DEFAULT_ROUNDS);
|
|
7
|
+
const steps = [];
|
|
8
|
+
const history = [];
|
|
9
|
+
let feedback = null;
|
|
10
|
+
let spent = 0;
|
|
11
|
+
let stoppedBy = "rounds";
|
|
12
|
+
for (let round = 0; round < rounds; round++) {
|
|
13
|
+
if (opts.maxTokens !== undefined && spent >= opts.maxTokens) {
|
|
14
|
+
stoppedBy = "tokens";
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
const attempt = await opts.attempt(feedback, round, (text, stage) => opts.emit?.(text, stage ?? `round ${round}`));
|
|
18
|
+
steps.push(attemptStep(steps.length, round, attempt));
|
|
19
|
+
spent += tokensOf(attempt);
|
|
20
|
+
if (attempt.status === "failed") {
|
|
21
|
+
return {
|
|
22
|
+
status: "failed",
|
|
23
|
+
output: "",
|
|
24
|
+
error: attempt.error ?? "The attempt failed.",
|
|
25
|
+
steps,
|
|
26
|
+
outputs: { history },
|
|
27
|
+
latencyMs: Date.now() - startedAt,
|
|
28
|
+
...totalTokens(steps),
|
|
29
|
+
accepted: false,
|
|
30
|
+
rounds: round + 1,
|
|
31
|
+
stoppedBy: "failed",
|
|
32
|
+
history,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const verdict = await opts.judge(attempt, round);
|
|
36
|
+
steps.push(judgeStep(steps.length, round, verdict));
|
|
37
|
+
const record = { round, attempt, verdict, tokens: tokensOf(attempt) };
|
|
38
|
+
history.push(record);
|
|
39
|
+
opts.onRound?.(record);
|
|
40
|
+
if (verdict.accepted) {
|
|
41
|
+
stoppedBy = "accepted";
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
feedback = verdict.feedback ?? null;
|
|
45
|
+
}
|
|
46
|
+
const winner = best(history);
|
|
47
|
+
return {
|
|
48
|
+
status: "completed",
|
|
49
|
+
output: winner?.attempt.output ?? "",
|
|
50
|
+
steps,
|
|
51
|
+
outputs: { history },
|
|
52
|
+
latencyMs: Date.now() - startedAt,
|
|
53
|
+
...totalTokens(steps),
|
|
54
|
+
accepted: stoppedBy === "accepted",
|
|
55
|
+
rounds: history.length,
|
|
56
|
+
stoppedBy,
|
|
57
|
+
history,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
/** What one attempt produced. Structurally the `TraceOutcome` a prompt accepts. */
|
|
4
|
+
export interface Attempt {
|
|
5
|
+
output: string;
|
|
6
|
+
model: string;
|
|
7
|
+
status?: "completed" | "failed";
|
|
8
|
+
error?: string;
|
|
9
|
+
inputTokens?: number;
|
|
10
|
+
outputTokens?: number;
|
|
11
|
+
}
|
|
12
|
+
/** What the judge decided about an attempt. */
|
|
13
|
+
export interface Verdict {
|
|
14
|
+
/** True stops the loop. */
|
|
15
|
+
accepted: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* What to fix, handed to the next attempt.
|
|
18
|
+
*
|
|
19
|
+
* A rejection with no feedback asks the model to try again with nothing new,
|
|
20
|
+
* which is how a refine loop spends three rounds producing the same draft.
|
|
21
|
+
*/
|
|
22
|
+
feedback?: string;
|
|
23
|
+
/** Higher is better. Used to pick a winner when nothing was accepted. */
|
|
24
|
+
score?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface RefineOptions {
|
|
27
|
+
/**
|
|
28
|
+
* A draft. `feedback` is null on the first round and the judge's note after.
|
|
29
|
+
*
|
|
30
|
+
* One callback rather than separate generate and revise, because in practice
|
|
31
|
+
* they are the same prompt with one extra paragraph.
|
|
32
|
+
*/
|
|
33
|
+
attempt(feedback: string | null, round: number, emit: Emit): Promise<Attempt> | Attempt;
|
|
34
|
+
/** Whether that will do, and what is wrong with it if not. */
|
|
35
|
+
judge(attempt: Attempt, round: number): Promise<Verdict> | Verdict;
|
|
36
|
+
/**
|
|
37
|
+
* Where the tokens go while this runs.
|
|
38
|
+
*
|
|
39
|
+
* Passed down to whatever makes the model call; omit it and nothing is
|
|
40
|
+
* emitted. `streamed()` supplies one.
|
|
41
|
+
*/
|
|
42
|
+
emit?: Emit;
|
|
43
|
+
/** Most attempts to make. Default 3. */
|
|
44
|
+
rounds?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Stop once the attempts have consumed this many tokens.
|
|
47
|
+
*
|
|
48
|
+
* Tokens rather than money because this package cannot price a call — that
|
|
49
|
+
* happens on the server, after the fact. Checked before each round, so the
|
|
50
|
+
* ceiling is never crossed rather than merely noticed.
|
|
51
|
+
*/
|
|
52
|
+
maxTokens?: number;
|
|
53
|
+
/** Called after each verdict, so a long loop is not silent. */
|
|
54
|
+
onRound?: (round: RoundRecord) => void;
|
|
55
|
+
}
|
|
56
|
+
export interface RoundRecord {
|
|
57
|
+
round: number;
|
|
58
|
+
attempt: Attempt;
|
|
59
|
+
verdict: Verdict;
|
|
60
|
+
tokens: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What the loop produced, in the shape of a rollout.
|
|
64
|
+
*
|
|
65
|
+
* `steps` holds every attempt and every judgement, including the rejected ones.
|
|
66
|
+
* A loop that recorded only the winner could not say what the answer cost, which
|
|
67
|
+
* is the whole point of recording it here.
|
|
68
|
+
*/
|
|
69
|
+
export interface RefineResult {
|
|
70
|
+
status: "completed" | "failed";
|
|
71
|
+
output: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
steps: RolloutStep[];
|
|
74
|
+
outputs: Record<string, unknown>;
|
|
75
|
+
latencyMs: number;
|
|
76
|
+
/**
|
|
77
|
+
* Summed across **every** attempt, the rejected ones included.
|
|
78
|
+
*
|
|
79
|
+
* The only question a refine loop has to answer is whether refining paid, and
|
|
80
|
+
* a total that counted the winner alone would always say yes.
|
|
81
|
+
*/
|
|
82
|
+
inputTokens: number;
|
|
83
|
+
outputTokens: number;
|
|
84
|
+
/** False when the rounds or the token ceiling ran out first. */
|
|
85
|
+
accepted: boolean;
|
|
86
|
+
rounds: number;
|
|
87
|
+
/** Why it ended: a verdict, the round limit, or the token ceiling. */
|
|
88
|
+
stoppedBy: "accepted" | "rounds" | "tokens" | "failed";
|
|
89
|
+
history: RoundRecord[];
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Classification, RouteOptions } from "./types.js";
|
|
2
|
+
/** The classifier's node name, and the key its decision is stored under. */
|
|
3
|
+
export declare const CLASSIFY = "classify";
|
|
4
|
+
/**
|
|
5
|
+
* The branch that runs when no other one does.
|
|
6
|
+
*
|
|
7
|
+
* Declared here rather than beside the dispatch it names, because `decide` is
|
|
8
|
+
* what produces it — and the other way round leaves the two files importing
|
|
9
|
+
* each other, which is the thing a reader has to stop and untangle.
|
|
10
|
+
*/
|
|
11
|
+
export declare const FALLBACK = "fallback";
|
|
12
|
+
/** What the classifier said, once a bare string has been expanded. */
|
|
13
|
+
export type Said = Exclude<Classification, string>;
|
|
14
|
+
/**
|
|
15
|
+
* What the classifier said, and what will run because of it.
|
|
16
|
+
*
|
|
17
|
+
* The two are kept apart on purpose: a router that recorded only the branch it
|
|
18
|
+
* ran could not tell "the classifier said `legal` and we have no such branch"
|
|
19
|
+
* from "it was unsure about `billing`". The first is a missing branch, the
|
|
20
|
+
* second a miscalibrated one, and they want opposite fixes.
|
|
21
|
+
*/
|
|
22
|
+
export interface Decision {
|
|
23
|
+
said: string;
|
|
24
|
+
taken: string;
|
|
25
|
+
confidence?: number;
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A bare string is shorthand for `{ route }`. */
|
|
29
|
+
export declare function normalise(value: Classification): Said;
|
|
30
|
+
/**
|
|
31
|
+
* Which branch runs.
|
|
32
|
+
*
|
|
33
|
+
* A class no branch handles falls back, and so does one the classifier is not
|
|
34
|
+
* sure enough about — a hedged guess sent to the wrong specialist is worse than
|
|
35
|
+
* the general path.
|
|
36
|
+
*/
|
|
37
|
+
export declare function decide(said: Said, names: string[], minConfidence?: number): Decision;
|
|
38
|
+
/** A step's worth of outcome, with the decision the edges will read. */
|
|
39
|
+
export interface ClassifyOutput extends Decision {
|
|
40
|
+
output: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
inputTokens?: number;
|
|
43
|
+
outputTokens?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The entry node.
|
|
47
|
+
*
|
|
48
|
+
* Its return is what the edges read, so the decision travels through the graph's
|
|
49
|
+
* own context rather than a variable closed over from outside — which means a
|
|
50
|
+
* finished run can be read apart from `outputs.classify` alone.
|
|
51
|
+
*/
|
|
52
|
+
export declare function classifier(opts: RouteOptions, names: string[]): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { node } from "@spendgraph/graph";
|
|
2
|
+
export const CLASSIFY = "classify";
|
|
3
|
+
export const FALLBACK = "fallback";
|
|
4
|
+
export function normalise(value) {
|
|
5
|
+
return typeof value === "string" ? { route: value } : value;
|
|
6
|
+
}
|
|
7
|
+
export function decide(said, names, minConfidence) {
|
|
8
|
+
const known = names.includes(said.route);
|
|
9
|
+
const confident = minConfidence === undefined ||
|
|
10
|
+
said.confidence === undefined ||
|
|
11
|
+
said.confidence >= minConfidence;
|
|
12
|
+
return {
|
|
13
|
+
said: said.route,
|
|
14
|
+
taken: known && confident ? said.route : FALLBACK,
|
|
15
|
+
confidence: said.confidence,
|
|
16
|
+
reason: said.reason,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function classifier(opts, names) {
|
|
20
|
+
return node({
|
|
21
|
+
name: CLASSIFY,
|
|
22
|
+
run: async (input, ctx) => {
|
|
23
|
+
const said = normalise(await opts.classify(input, ctx));
|
|
24
|
+
return {
|
|
25
|
+
...decide(said, names, opts.minConfidence),
|
|
26
|
+
output: said.route,
|
|
27
|
+
model: said.model,
|
|
28
|
+
inputTokens: said.inputTokens,
|
|
29
|
+
outputTokens: said.outputTokens,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Edge } from "@spendgraph/graph";
|
|
2
|
+
import type { RouteOptions } from "./types.js";
|
|
3
|
+
/** One node per branch, plus the fallback. */
|
|
4
|
+
export declare function branches(opts: RouteOptions, names: string[]): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>[];
|
|
5
|
+
/**
|
|
6
|
+
* Classify, then exactly one branch, then stop.
|
|
7
|
+
*
|
|
8
|
+
* Conditional edges first and the fallback unconditional last, because `graph`
|
|
9
|
+
* refuses the other order — an unconditional edge ahead of a conditional one is
|
|
10
|
+
* a router whose default hides a branch, and it would never be reached.
|
|
11
|
+
*/
|
|
12
|
+
export declare function edgesTo(names: string[]): Edge[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { edge, end, node } from "@spendgraph/graph";
|
|
2
|
+
import { FALLBACK } from "./classify.js";
|
|
3
|
+
function handler(name, run) {
|
|
4
|
+
return node({
|
|
5
|
+
name,
|
|
6
|
+
run: (input, ctx) => run(input, ctx),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export function branches(opts, names) {
|
|
10
|
+
return [
|
|
11
|
+
...names.map((name) => handler(name, opts.routes[name])),
|
|
12
|
+
handler(FALLBACK, opts.fallback),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
function taken(ctx) {
|
|
16
|
+
return ctx.outputs.classify?.taken ?? FALLBACK;
|
|
17
|
+
}
|
|
18
|
+
export function edgesTo(names) {
|
|
19
|
+
return [
|
|
20
|
+
...names.map((name) => edge("classify", name, (ctx) => taken(ctx) === name)),
|
|
21
|
+
edge("classify", FALLBACK),
|
|
22
|
+
...names.map((name) => end(name)),
|
|
23
|
+
end(FALLBACK),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RouteOptions, RouteResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Classify once, then hand the work to the branch built for it.
|
|
4
|
+
*
|
|
5
|
+
* Compiled to a graph rather than looped here: `@spendgraph/graph` already
|
|
6
|
+
* validates that every branch is reachable and that no default sits in front of
|
|
7
|
+
* one, already records a step per node with its tokens, and already returns the
|
|
8
|
+
* rollout shape. A second execution engine in this package would be a second
|
|
9
|
+
* thing to keep right.
|
|
10
|
+
*/
|
|
11
|
+
export declare function route(values: Record<string, unknown>, opts: RouteOptions): Promise<RouteResult>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { graph } from "@spendgraph/graph";
|
|
2
|
+
import { runGraph } from "../stream/graph.js";
|
|
3
|
+
import { CLASSIFY, classifier, FALLBACK } from "./classify.js";
|
|
4
|
+
import { branches, edgesTo } from "./dispatch.js";
|
|
5
|
+
export async function route(values, opts) {
|
|
6
|
+
const names = Object.keys(opts.routes);
|
|
7
|
+
const flow = graph({
|
|
8
|
+
entry: CLASSIFY,
|
|
9
|
+
maxSteps: opts.maxSteps,
|
|
10
|
+
nodes: [classifier(opts, names), ...branches(opts, names)],
|
|
11
|
+
edges: edgesTo(names),
|
|
12
|
+
}, { now: opts.now });
|
|
13
|
+
const result = await runGraph(flow, values, opts.emit);
|
|
14
|
+
const decision = result.outputs[CLASSIFY];
|
|
15
|
+
return {
|
|
16
|
+
...result,
|
|
17
|
+
route: !decision || decision.taken === FALLBACK ? null : decision.taken,
|
|
18
|
+
classified: decision?.said ?? "",
|
|
19
|
+
confidence: decision?.confidence,
|
|
20
|
+
reason: decision?.reason,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { GraphContext, GraphResult } from "@spendgraph/graph";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
/** What the classifier decided. A bare string is shorthand for `{ route }`. */
|
|
4
|
+
export type Classification = string | {
|
|
5
|
+
route: string;
|
|
6
|
+
/** Below `minConfidence` the fallback runs instead. */
|
|
7
|
+
confidence?: number;
|
|
8
|
+
reason?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
inputTokens?: number;
|
|
11
|
+
outputTokens?: number;
|
|
12
|
+
};
|
|
13
|
+
/** A branch, or the fallback. Whatever it returns becomes the run's output. */
|
|
14
|
+
export type Handler = (input: Record<string, unknown>, ctx: GraphContext) => Promise<unknown> | unknown;
|
|
15
|
+
export interface RouteOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Which branch this input belongs to.
|
|
18
|
+
*
|
|
19
|
+
* Often better as plain code than a model — a regex on a subject line is free
|
|
20
|
+
* and never invents a category.
|
|
21
|
+
*/
|
|
22
|
+
classify(input: Record<string, unknown>, ctx: GraphContext): Promise<Classification> | Classification;
|
|
23
|
+
/** One handler per branch, keyed by what `classify` returns. */
|
|
24
|
+
routes: Record<string, Handler>;
|
|
25
|
+
/**
|
|
26
|
+
* Runs when the classification matches no branch, or is not confident enough.
|
|
27
|
+
*
|
|
28
|
+
* Required: a router that can fail to route fails in production at 3am, and
|
|
29
|
+
* "unknown" is a class every classifier eventually returns.
|
|
30
|
+
*/
|
|
31
|
+
fallback: Handler;
|
|
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
|
+
/** Below this the fallback runs. Ignored when the classifier reports none. */
|
|
40
|
+
minConfidence?: number;
|
|
41
|
+
/** Ceiling on total steps, nested ones included. Default 25. */
|
|
42
|
+
maxSteps?: number;
|
|
43
|
+
/** Injectable clock, so tests do not measure real time. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
export interface RouteResult extends GraphResult {
|
|
47
|
+
/** The branch that ran. `null` when the fallback did. */
|
|
48
|
+
route: string | null;
|
|
49
|
+
/** What the classifier said, whether or not it was used. */
|
|
50
|
+
classified: string;
|
|
51
|
+
confidence?: number;
|
|
52
|
+
reason?: string;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GraphResult, graph } from "@spendgraph/graph";
|
|
2
|
+
import type { Emit } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Runs a compiled graph, forwarding what its nodes emit.
|
|
5
|
+
*
|
|
6
|
+
* Streamed only when someone is listening: an unread stream is a queue nothing
|
|
7
|
+
* drains, and `execute` is the same run without one.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runGraph(flow: ReturnType<typeof graph>, values: Record<string, unknown>, emit?: Emit): Promise<GraphResult>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function runGraph(flow, values, emit) {
|
|
2
|
+
if (!emit)
|
|
3
|
+
return flow.execute(values);
|
|
4
|
+
const running = flow.stream(values);
|
|
5
|
+
for await (const event of running) {
|
|
6
|
+
if (event.type === "token")
|
|
7
|
+
emit(event.text, event.node);
|
|
8
|
+
}
|
|
9
|
+
return running.result;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { streamed } from "./stream.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Emit, HarnessStream } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Any workflow, watched while it runs.
|
|
4
|
+
*
|
|
5
|
+
* One helper rather than a streaming variant of each of the seven: every
|
|
6
|
+
* workflow already takes an `emit`, so wrapping the call is all it takes, and a
|
|
7
|
+
* `stream()` per pattern would be seven places for the same queue to go wrong.
|
|
8
|
+
*
|
|
9
|
+
* const run = streamed((emit, note) =>
|
|
10
|
+
* cascade(values, { ...opts, emit, onTier: note })
|
|
11
|
+
* );
|
|
12
|
+
* for await (const event of run) { ... }
|
|
13
|
+
* const result = await run.result;
|
|
14
|
+
*
|
|
15
|
+
* The run starts on the call, not on the first read, so a caller who wants only
|
|
16
|
+
* the result never has to iterate, and a client that connects late still sees
|
|
17
|
+
* what already happened.
|
|
18
|
+
*/
|
|
19
|
+
export declare function streamed<T>(run: (emit: Emit, note: (data: unknown) => void) => Promise<T>): HarnessStream<T>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
class Events {
|
|
2
|
+
waiting = [];
|
|
3
|
+
wake = null;
|
|
4
|
+
closed = false;
|
|
5
|
+
push(event) {
|
|
6
|
+
this.waiting.push(event);
|
|
7
|
+
this.release();
|
|
8
|
+
}
|
|
9
|
+
close() {
|
|
10
|
+
this.closed = true;
|
|
11
|
+
this.release();
|
|
12
|
+
}
|
|
13
|
+
release() {
|
|
14
|
+
const wake = this.wake;
|
|
15
|
+
this.wake = null;
|
|
16
|
+
wake?.();
|
|
17
|
+
}
|
|
18
|
+
async *drain() {
|
|
19
|
+
while (true) {
|
|
20
|
+
while (this.waiting.length > 0) {
|
|
21
|
+
yield this.waiting.shift();
|
|
22
|
+
}
|
|
23
|
+
if (this.closed)
|
|
24
|
+
return;
|
|
25
|
+
await new Promise((resolve) => {
|
|
26
|
+
this.wake = resolve;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function streamed(run) {
|
|
32
|
+
const events = new Events();
|
|
33
|
+
const result = (async () => {
|
|
34
|
+
try {
|
|
35
|
+
const finished = await run((text, stage) => events.push({ type: "token", text, stage }), (data) => events.push({ type: "note", data }));
|
|
36
|
+
events.push({ type: "result", result: finished });
|
|
37
|
+
return finished;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
events.close();
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
void result.catch(() => { });
|
|
44
|
+
return {
|
|
45
|
+
result,
|
|
46
|
+
[Symbol.asyncIterator]: () => events.drain(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a workflow's tokens go.
|
|
3
|
+
*
|
|
4
|
+
* `stage` is which part of the workflow produced them — a tier, a task, a node.
|
|
5
|
+
* A fan-out interleaves, and without it a stream of four workers reads as one
|
|
6
|
+
* confused voice. Assignable to a model client's `onText` either way, so a
|
|
7
|
+
* callback can pass it straight down.
|
|
8
|
+
*/
|
|
9
|
+
export type Emit = (text: string, stage?: string) => void;
|
|
10
|
+
/** What a streamed workflow says while it runs, and what it ends with. */
|
|
11
|
+
export type HarnessEvent<T> = {
|
|
12
|
+
type: "token";
|
|
13
|
+
text: string;
|
|
14
|
+
stage?: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "note";
|
|
17
|
+
data: unknown;
|
|
18
|
+
} | {
|
|
19
|
+
type: "result";
|
|
20
|
+
result: T;
|
|
21
|
+
};
|
|
22
|
+
/** A workflow as it happens, and the same run once it is over. */
|
|
23
|
+
export interface HarnessStream<T> extends AsyncIterable<HarnessEvent<T>> {
|
|
24
|
+
/**
|
|
25
|
+
* The finished run.
|
|
26
|
+
*
|
|
27
|
+
* Resolves whether or not anyone iterated, and rejects where the workflow
|
|
28
|
+
* itself would have — streaming changes what you can watch, not what happens.
|
|
29
|
+
*/
|
|
30
|
+
result: Promise<T>;
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Workflows
|
|
2
|
+
|
|
3
|
+
A workflow is a wiring pattern that keeps being rediscovered. Each one is a
|
|
4
|
+
graph you would otherwise hand-roll, with the pricing and the rollout already
|
|
5
|
+
attached, so a run is recorded rather than reconstructed afterwards.
|
|
6
|
+
|
|
7
|
+
| | | Built |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| [`refine`](./refine.md) | draft, judge, revise until a bar is met | ✓ |
|
|
10
|
+
| [`route`](./route.md) | classify first, then dispatch to a specialist | ✓ |
|
|
11
|
+
| [`chain`](./chain.md) | steps in sequence, each on the last's output, with a gate between | ✓ |
|
|
12
|
+
| [`parallel`](./parallel.md) | the same call N times, or N sections at once | ✓ |
|
|
13
|
+
| [`orchestrate`](./orchestrate.md) | a lead decomposes and delegates to workers | ✓ |
|
|
14
|
+
| [`loop`](./loop.md) | tools in a loop until the model stops asking | ✓ |
|
|
15
|
+
| [`cascade`](./cascade.md) | try the cheap model, escalate only when it will not do | ✓ |
|
|
16
|
+
|
|
17
|
+
## What every one of them owes you
|
|
18
|
+
|
|
19
|
+
**A rollout shape.** The result hands to `prompt.report()` without being
|
|
20
|
+
translated, because a workflow run *is* a rollout with several steps.
|
|
21
|
+
|
|
22
|
+
**Every step, including the rejected ones.** A refine loop that keeps only the
|
|
23
|
+
winner cannot say what the answer cost. A router that records only the branch it
|
|
24
|
+
ran cannot tell a missing branch from a miscalibrated one.
|
|
25
|
+
|
|
26
|
+
**A ceiling.** Each of these spends more than one call, and the interesting ones
|
|
27
|
+
spend an unbounded amount. The ceiling is in **tokens**, not money: this package
|
|
28
|
+
cannot price a call — that happens on the server, after the fact — so a budget in
|
|
29
|
+
micro-dollars here would be a guess.
|
|
30
|
+
|
|
31
|
+
## Why they are not in `graph`
|
|
32
|
+
|
|
33
|
+
`graph` is the mechanism: nodes, edges, a run. These are the arrangements of it
|
|
34
|
+
worth naming, each with its own failure mode — a chain needs a gate or one bad
|
|
35
|
+
step poisons the rest, voting needs an odd N, a loop needs a ceiling. Putting
|
|
36
|
+
them beside the mechanism would suggest the mechanism is opinionated. It is not.
|
|
37
|
+
|
|
38
|
+
`route` compiles down to a graph and adds nothing to it. `refine` does not,
|
|
39
|
+
because it is a **loop**: `graph` is a DAG whose `maxSteps` error reads "a
|
|
40
|
+
conditional edge never turns false", so a cycle there is a runaway to be stopped
|
|
41
|
+
rather than a shape to express.
|