Repository navigation
Expand file tree
/
Copy pathrunner.ts
More file actions
191 lines (178 loc) · 6.02 KB
/
Copy pathrunner.ts
File metadata and controls
191 lines (178 loc) · 6.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/**
* Runner for evaluation providers.
*
* The runner owns three cross-cutting concerns so individual providers
* don't have to:
*
* 1. **Timing** — records `startedAt` (ISO-8601) and `durationMs` for
* every invocation, including ones that throw.
* 2. **Error normalization** — any error thrown by the provider (or its
* unhandled rejections during `run()`) is converted into a populated
* `EvalResult` with `passed: false`, `score: 0`, and a single
* `Finding` of severity `error`. Callers of `runProvider` therefore
* never need try/catch around the call.
* 3. **Timeout enforcement** — when `opts.timeoutMs` is set (or
* `opts.signal` is passed), the runner races the provider against
* a timeout and returns a timeout-shaped `EvalResult` on expiry.
*
* Providers that need to respect cancellation should read `opts.signal`
* themselves (the runner exposes it directly); the timeout also fires
* the same signal so well-behaved providers cancel in-flight work.
*/
import type {
EvalOpts,
EvalProvider,
EvalResult,
Finding,
SkillContext,
} from "./types";
// ─── Error → Finding ────────────────────────────────────────────────────────
/**
* Extract a human-readable message from an unknown thrown value.
* Mirrors `Error.prototype.message` extraction but falls back to
* `String(value)` for non-Error throws.
*/
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}
/**
* Build the error-shaped `EvalResult` used when a provider throws or
* times out. Kept as a tiny helper so timing fields stay consistent.
*/
function buildErrorResult(
provider: Pick<EvalProvider, "id" | "version" | "schemaVersion">,
startedAt: string,
durationMs: number,
message: string,
code: string,
): EvalResult {
const finding: Finding = {
severity: "error",
message,
code,
};
return {
providerId: provider.id,
providerVersion: provider.version,
schemaVersion: provider.schemaVersion,
score: 0,
passed: false,
categories: [],
findings: [finding],
raw: undefined,
startedAt,
durationMs,
};
}
// ─── Public entry point ─────────────────────────────────────────────────────
/**
* Run a single provider against a `SkillContext` and return a normalized
* `EvalResult`.
*
* Contract:
* - Always sets `startedAt` and `durationMs` (including on error).
* - Thrown errors become `EvalResult` with a `severity: "error"` finding
* and `passed: false`.
* - `opts.timeoutMs` > 0 → provider is raced against the timeout.
*
* The runner trusts that the provider's own `applicable()` has already
* been checked by the caller when appropriate; it does not call it here
* because some callers intentionally want to see a provider's error even
* when `applicable()` says no.
*/
export async function runProvider(
provider: EvalProvider,
ctx: SkillContext,
opts: EvalOpts = {},
): Promise<EvalResult> {
const startedAt = new Date().toISOString();
const start = performance.now();
// Stable identity snapshot — used for error-path results so we still
// report provider id/version/schemaVersion even if the provider object
// mutates mid-run.
const identity = {
id: provider.id,
version: provider.version,
schemaVersion: provider.schemaVersion,
};
// Compose timeout + external signal so both can trigger cancellation.
const controller = new AbortController();
if (opts.signal) {
if (opts.signal.aborted) controller.abort(opts.signal.reason);
else
opts.signal.addEventListener("abort", () =>
controller.abort(opts.signal?.reason),
);
}
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
if (typeof opts.timeoutMs === "number" && opts.timeoutMs > 0) {
timeoutHandle = setTimeout(
() => controller.abort(new Error("timeout")),
opts.timeoutMs,
);
}
const effectiveOpts: EvalOpts = { ...opts, signal: controller.signal };
try {
const raced = await Promise.race<
{ kind: "ok"; value: EvalResult } | { kind: "timeout" }
>([
provider
.run(ctx, effectiveOpts)
.then((value) => ({ kind: "ok", value }) as const),
new Promise<{ kind: "timeout" }>((resolve) => {
if (controller.signal.aborted) {
resolve({ kind: "timeout" });
return;
}
controller.signal.addEventListener("abort", () =>
resolve({ kind: "timeout" }),
);
}),
]);
const durationMs = Math.max(0, Math.round(performance.now() - start));
if (raced.kind === "timeout") {
const reason = controller.signal.reason;
const message =
reason instanceof Error && reason.message === "timeout"
? `provider timed out after ${opts.timeoutMs}ms`
: `provider aborted: ${errorMessage(reason)}`;
return buildErrorResult(
identity,
startedAt,
durationMs,
message,
reason instanceof Error && reason.message === "timeout"
? "timeout"
: "aborted",
);
}
// Normalize provider-returned result: stamp timing and identity so
// providers can't lie about who they are or skip timing fields.
const result = raced.value;
return {
...result,
providerId: identity.id,
providerVersion: identity.version,
schemaVersion: result.schemaVersion ?? identity.schemaVersion,
startedAt,
durationMs,
};
} catch (err) {
const durationMs = Math.max(0, Math.round(performance.now() - start));
return buildErrorResult(
identity,
startedAt,
durationMs,
errorMessage(err),
"provider-threw",
);
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
}
}