@agentswf/engine 0.0.0-stage → 0.1.0-beta.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 (82) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,432 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { DecisionArtifact, Money, SettledDecision } from "@agentswf/contract/records";
5
+ import {
6
+ type AbsoluteDeadline,
7
+ type Answers,
8
+ DeadlineExceededError,
9
+ DecisionError,
10
+ type DecisionRecord,
11
+ type DecisionSpec,
12
+ type JsonObject,
13
+ type JsonValue,
14
+ type Question,
15
+ } from "@agentswf/contract/workflow";
16
+ import { canonical } from "../canonical-json";
17
+ import {
18
+ abortableSleep,
19
+ assertDeadline,
20
+ assertDeadlineValue,
21
+ earlierDeadline,
22
+ scheduleAt,
23
+ } from "../deadlines";
24
+ import { messageOf } from "../errors";
25
+ import type { RunEventBody } from "../run-event-log";
26
+ import {
27
+ type DecisionInstallation,
28
+ type DecisionProvider,
29
+ DecisionProviderError,
30
+ type ProviderAnswer,
31
+ type ProviderResponse,
32
+ } from "./seam";
33
+
34
+ const MAX_ATTEMPTS = 3;
35
+ const FIRST_BACKOFF_MILLISECONDS = 500;
36
+
37
+ /** Where a call runs: its scope's deadline, and the scope's way of cancelling what it owns. */
38
+ export type DecisionScope = {
39
+ deadline: AbsoluteDeadline;
40
+ /** Returns the removal. */
41
+ add?(cancel: (reason: string) => Promise<unknown>): () => void;
42
+ /** The workflow stage it is asked in. */
43
+ stage?: string;
44
+ };
45
+
46
+ type PreparedCall = {
47
+ spec: DecisionSpec;
48
+ ids: string[];
49
+ resolved: { provider: string; model: string };
50
+ provider: DecisionProvider;
51
+ deadline: AbsoluteDeadline;
52
+ slot: number;
53
+ };
54
+
55
+ type Ending =
56
+ | { outcome: "timed-out"; deadline: AbsoluteDeadline }
57
+ | { outcome: "cancelled"; reason: string };
58
+
59
+ /**
60
+ * Every decision one run asks: it resolves aliases, bounds each call by its deadline, retries what
61
+ * the provider says is retryable, and keeps a record and an artifact of each, answered or not.
62
+ */
63
+ export class RunDecisions {
64
+ readonly #slots: (SettledDecision | undefined)[] = [];
65
+ readonly #cancellers = new Set<(reason: string) => void>();
66
+ readonly #inFlight = new Set<Promise<unknown>>();
67
+ #closed = false;
68
+
69
+ constructor(
70
+ private readonly options: {
71
+ installation?: DecisionInstallation;
72
+ runDir: string;
73
+ sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
74
+ /** Writes the run's event log. */
75
+ event?(body: RunEventBody): void;
76
+ },
77
+ ) {}
78
+
79
+ decide<Q extends Record<string, Question>>(
80
+ spec: DecisionSpec<Q>,
81
+ scope: DecisionScope,
82
+ ): Promise<{ answers: Answers<Q>; record: DecisionRecord }> {
83
+ try {
84
+ if (this.#closed) throw new Error("workflow context is closed");
85
+ const call = this.#prepare(spec, scope);
86
+ const running = this.#run(call, scope);
87
+ this.#inFlight.add(running);
88
+ running.finally(() => this.#inFlight.delete(running)).catch(() => undefined);
89
+ return running as Promise<{ answers: Answers<Q>; record: DecisionRecord }>;
90
+ } catch (error) {
91
+ return Promise.reject(error);
92
+ }
93
+ }
94
+
95
+ /** Every call that has settled, in the order they were asked. */
96
+ records(): SettledDecision[] {
97
+ return this.#slots.filter((record): record is SettledDecision => record !== undefined);
98
+ }
99
+
100
+ /** Cancels every call still in flight, and waits for each to be recorded. */
101
+ async close(): Promise<void> {
102
+ this.#closed = true;
103
+ for (const cancel of this.#cancellers) cancel("workflow closed");
104
+ await Promise.allSettled([...this.#inFlight]);
105
+ }
106
+
107
+ #prepare(spec: DecisionSpec, scope: DecisionScope): PreparedCall {
108
+ if (typeof spec.key !== "string" || spec.key === "") {
109
+ throw new Error("a decision needs a key");
110
+ }
111
+ const ids = Object.keys(spec.questions ?? {});
112
+ if (ids.length === 0) throw new Error(`decision ${spec.key} asks no questions`);
113
+ for (const id of ids) assertQuestion(spec.key, id, spec.questions[id]!);
114
+ if (typeof spec.state !== "string" && (typeof spec.state !== "object" || spec.state === null)) {
115
+ throw new Error(`decision ${spec.key}: a state is text, an object or an array`);
116
+ }
117
+ const installed = this.options.installation;
118
+ const resolved =
119
+ installed && Object.hasOwn(installed.aliases, spec.model)
120
+ ? installed.aliases[spec.model]
121
+ : undefined;
122
+ const unavailable = installed?.unavailable;
123
+ if (!resolved && unavailable && Object.hasOwn(unavailable, spec.model)) {
124
+ throw new Error(`decision model "${spec.model}" is unavailable: ${unavailable[spec.model]}`);
125
+ }
126
+ if (!installed || !resolved) {
127
+ const known = Object.keys(installed?.aliases ?? {});
128
+ throw new Error(
129
+ `no decision model alias "${spec.model}"; ${known.length === 0 ? "none is installed" : `installed: ${known.join(", ")}`}`,
130
+ );
131
+ }
132
+ const provider = Object.hasOwn(installed.providers, resolved.provider)
133
+ ? installed.providers[resolved.provider]
134
+ : undefined;
135
+ if (!provider) throw new Error(`decision provider "${resolved.provider}" is not installed`);
136
+ if (spec.deadline) assertDeadlineValue(spec.deadline);
137
+ const deadline = spec.deadline
138
+ ? earlierDeadline(spec.deadline, scope.deadline)
139
+ : scope.deadline;
140
+ assertDeadline(deadline);
141
+ const slot = this.#slots.length;
142
+ this.#slots.push(undefined);
143
+ this.options.event?.({
144
+ kind: "decision-asked",
145
+ decision: slot + 1,
146
+ ...(scope.stage === undefined ? {} : { stage: scope.stage }),
147
+ key: spec.key,
148
+ alias: spec.model,
149
+ provider: resolved.provider,
150
+ model: resolved.model,
151
+ });
152
+ return { spec, ids, resolved, provider, deadline, slot };
153
+ }
154
+
155
+ async #run(
156
+ call: PreparedCall,
157
+ scope: DecisionScope,
158
+ ): Promise<{ answers: Record<string, unknown>; record: DecisionRecord }> {
159
+ const { spec, ids, resolved, provider, deadline, slot } = call;
160
+ const startedAt = new Date().toISOString();
161
+ const controller = new AbortController();
162
+ let ending: Ending | undefined;
163
+ const end = (next: Ending) => {
164
+ ending ??= next;
165
+ controller.abort(next.outcome);
166
+ };
167
+ const cancel = (reason: string) => end({ outcome: "cancelled", reason });
168
+ const cancelTimer = scheduleAt(deadline, () => end({ outcome: "timed-out", deadline }));
169
+ this.#cancellers.add(cancel);
170
+ const removeFromScope = scope.add?.(async (reason) => cancel(reason));
171
+
172
+ let attempts = 0;
173
+ let tokens: { input: number; output: number } | undefined;
174
+ let charged: Money[] = [];
175
+ let requestId: string | undefined;
176
+ let response: ProviderResponse | undefined;
177
+ let answers: Record<string, unknown> | undefined;
178
+ let error: string | undefined;
179
+ const count = (spent: {
180
+ tokens?: ProviderResponse["tokens"];
181
+ charged?: Money;
182
+ requestId?: string;
183
+ }) => {
184
+ if (spent.tokens) {
185
+ tokens = {
186
+ input: (tokens?.input ?? 0) + spent.tokens.input,
187
+ output: (tokens?.output ?? 0) + spent.tokens.output,
188
+ };
189
+ }
190
+ if (spent.charged) charged = [...charged, spent.charged];
191
+ if (spent.requestId) requestId = spent.requestId;
192
+ };
193
+ const request = {
194
+ key: spec.key,
195
+ model: resolved.model,
196
+ state: spec.state,
197
+ questions: spec.questions,
198
+ };
199
+ try {
200
+ while (true) {
201
+ attempts += 1;
202
+ try {
203
+ // A provider that ignores the signal still cannot hold the call past its end.
204
+ response = await untilAborted(
205
+ provider.decide(structuredClone(request), controller.signal),
206
+ controller.signal,
207
+ );
208
+ count(response);
209
+ answers = answersOf(spec.questions, response.answers);
210
+ break;
211
+ } catch (thrown) {
212
+ if (thrown instanceof DecisionProviderError) count(thrown);
213
+ if (ending) break;
214
+ error = messageOf(thrown);
215
+ const backoff = FIRST_BACKOFF_MILLISECONDS * 2 ** (attempts - 1);
216
+ const retry =
217
+ thrown instanceof DecisionProviderError &&
218
+ thrown.retryable &&
219
+ attempts < MAX_ATTEMPTS &&
220
+ Date.now() + backoff < deadline.unixMilliseconds;
221
+ if (!retry) break;
222
+ await (this.options.sleep ?? abortableSleep)(backoff, controller.signal);
223
+ if (ending) break;
224
+ error = undefined;
225
+ }
226
+ }
227
+ } finally {
228
+ cancelTimer();
229
+ removeFromScope?.();
230
+ this.#cancellers.delete(cancel);
231
+ }
232
+
233
+ const artifact = join("decisions", `${slot + 1}.json`);
234
+ const outcome: DecisionRecord["outcome"] = ending
235
+ ? ending.outcome
236
+ : answers
237
+ ? "answered"
238
+ : "failed";
239
+ const failure =
240
+ ending?.outcome === "timed-out"
241
+ ? `deadline exceeded after ${attempts} attempt${attempts === 1 ? "" : "s"}`
242
+ : ending?.outcome === "cancelled"
243
+ ? `cancelled: ${ending.reason}`
244
+ : error;
245
+ const record: DecisionRecord = {
246
+ callPath: [],
247
+ key: spec.key,
248
+ ...(scope.stage === undefined ? {} : { stage: scope.stage }),
249
+ alias: spec.model,
250
+ provider: resolved.provider,
251
+ model: resolved.model,
252
+ ...(response ? { snapshot: response.snapshot } : {}),
253
+ startedAt,
254
+ settledAt: new Date().toISOString(),
255
+ questions: ids.map((id) => ({ id, type: spec.questions[id]!.type })),
256
+ questionsDigest: digestOf(spec.questions),
257
+ outcome,
258
+ ...(outcome === "answered" || failure === undefined ? {} : { error: failure }),
259
+ attempts,
260
+ ...(tokens ? { tokens } : {}),
261
+ ...(requestId ? { requestId } : {}),
262
+ artifact,
263
+ };
264
+ const total = sumMoney(charged);
265
+ let settled: SettledDecision = { ...record, ...(total ? { charged: total } : {}) };
266
+ const written: DecisionArtifact = {
267
+ record: settled,
268
+ request: { state: spec.state, questions: spec.questions },
269
+ ...(answers ? { answers: answers as JsonObject } : {}),
270
+ };
271
+ try {
272
+ await mkdir(join(this.options.runDir, "decisions"), { recursive: true });
273
+ await writeFile(join(this.options.runDir, artifact), `${JSON.stringify(written, null, 2)}\n`);
274
+ } catch (thrown) {
275
+ const lost = `artifact not written: ${messageOf(thrown)}`;
276
+ // Without its artifact an answer cannot be fitted again, so it does not count as one; any
277
+ // other outcome stands.
278
+ settled =
279
+ settled.outcome === "answered"
280
+ ? { ...settled, outcome: "failed", error: lost }
281
+ : { ...settled, error: `${settled.error ?? settled.outcome}; ${lost}` };
282
+ }
283
+ this.#slots[slot] = settled;
284
+ this.options.event?.({
285
+ kind: "decision-ended",
286
+ decision: slot + 1,
287
+ ...(settled.stage === undefined ? {} : { stage: settled.stage }),
288
+ outcome: settled.outcome,
289
+ ...(settled.error === undefined ? {} : { error: settled.error }),
290
+ });
291
+ const { charged: _charged, ...returned } = settled;
292
+
293
+ if (returned.outcome === "answered") return { answers: answers!, record: returned };
294
+ if (returned.outcome === "timed-out") throw new DeadlineExceededError(deadline);
295
+ throw new DecisionError(`decision ${spec.key}: ${returned.error}`, returned);
296
+ }
297
+ }
298
+
299
+ function assertQuestion(key: string, id: string, question: Question): void {
300
+ const where = `decision ${key}, question ${id}`;
301
+ if (!question || typeof question !== "object") throw new Error(`${where} is not a question`);
302
+ switch (question.type) {
303
+ case "choice":
304
+ if (Object.keys(question.options ?? {}).length === 0)
305
+ throw new Error(`${where} has no options`);
306
+ return;
307
+ case "score":
308
+ if (!Array.isArray(question.levels) || question.levels.length === 0) {
309
+ throw new Error(`${where} has no levels`);
310
+ }
311
+ return;
312
+ case "yes-no":
313
+ return;
314
+ default:
315
+ throw new Error(
316
+ `${where} has unknown type ${JSON.stringify((question as { type?: unknown }).type)}`,
317
+ );
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Every question answered with its own type and a whole distribution, or the call failed: a
323
+ * partial answer is never passed on.
324
+ */
325
+ export function answersOf(
326
+ questions: Record<string, Question>,
327
+ provided: Record<string, ProviderAnswer>,
328
+ ): Record<string, unknown> {
329
+ const answers: Record<string, unknown> = {};
330
+ for (const [id, question] of Object.entries(questions)) {
331
+ const answer = Object.hasOwn(provided, id) ? provided[id] : undefined;
332
+ const invalid = (detail: string) => new Error(`question ${id}: ${detail}`);
333
+ if (!answer) throw invalid("not answered");
334
+ if (answer.type !== question.type) throw invalid(`answered as ${answer.type}`);
335
+ if (question.type === "choice" && answer.type === "choice") {
336
+ const names = Object.keys(question.options);
337
+ const given = answer.probabilities ?? {};
338
+ const extra = Object.keys(given).filter((name) => !names.includes(name));
339
+ if (extra.length > 0) throw invalid(`unknown option ${extra.join(", ")}`);
340
+ const probabilities = names.map((name) =>
341
+ Object.hasOwn(given, name) ? given[name] : undefined,
342
+ );
343
+ const missing = names.find((_, index) => !isProbability(probabilities[index]));
344
+ if (missing !== undefined) throw invalid(`no probability for option ${missing}`);
345
+ const index = assertDistribution(probabilities as number[], invalid);
346
+ answers[id] = {
347
+ type: "choice",
348
+ choice: names[index]!,
349
+ // Built from entries, so an option named like a prototype key is still an own key.
350
+ probabilities: Object.fromEntries(names.map((name, at) => [name, probabilities[at]!])),
351
+ };
352
+ } else if (question.type === "score" && answer.type === "score") {
353
+ const probabilities = answer.probabilities ?? [];
354
+ if (probabilities.length !== question.levels.length || !probabilities.every(isProbability)) {
355
+ throw invalid(`expected a probability for each of ${question.levels.length} levels`);
356
+ }
357
+ const level = assertDistribution(probabilities, invalid);
358
+ const total = probabilities.reduce((sum, probability) => sum + probability, 0);
359
+ const expected =
360
+ probabilities.reduce((sum, probability, at) => sum + probability * at, 0) / total;
361
+ answers[id] = { type: "score", level, expected, probabilities: [...probabilities] };
362
+ } else if (answer.type === "yes-no") {
363
+ if (!isProbability(answer.yes)) throw invalid("no probability of yes");
364
+ answers[id] = { type: "yes-no", yes: answer.yes };
365
+ }
366
+ }
367
+ return answers;
368
+ }
369
+
370
+ /**
371
+ * A distribution sums to 1, give or take each probability's rounding to two decimals (Jev's sum to
372
+ * 0.99–1.00, S10). Returns the most likely index, the first of them on a tie.
373
+ */
374
+ function assertDistribution(
375
+ probabilities: readonly number[],
376
+ invalid: (detail: string) => Error,
377
+ ): number {
378
+ const total = probabilities.reduce((sum, probability) => sum + probability, 0);
379
+ const slack = Math.max(0.02, probabilities.length * 0.005);
380
+ if (Math.abs(total - 1) > slack) throw invalid(`probabilities sum to ${total}, not 1`);
381
+ return probabilities.reduce(
382
+ (best, probability, at) => (probability > probabilities[best]! ? at : best),
383
+ 0,
384
+ );
385
+ }
386
+
387
+ /**
388
+ * SHA-256 of the questions as JSON with every object's keys sorted, except a choice's options:
389
+ * their order breaks ties and may sway the model, so it counts.
390
+ */
391
+ export function digestOf(questions: Record<string, Question>): string {
392
+ const ordered = Object.fromEntries(
393
+ Object.entries(questions).map(([id, question]) => [
394
+ id,
395
+ question.type === "choice"
396
+ ? { ...question, options: Object.entries(question.options) }
397
+ : question,
398
+ ]),
399
+ );
400
+ return createHash("sha256")
401
+ .update(JSON.stringify(canonical(ordered as unknown as JsonValue)))
402
+ .digest("hex");
403
+ }
404
+
405
+ function isProbability(value: unknown): value is number {
406
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
407
+ }
408
+
409
+ /** Charges in more than one currency cannot be added without converting, so their sum is unknown. */
410
+ function sumMoney(charges: readonly Money[]): Money | undefined {
411
+ const currency = charges[0]?.currency;
412
+ if (!currency || charges.some((charge) => charge.currency !== currency)) return undefined;
413
+ return { amount: charges.reduce((sum, charge) => sum + charge.amount, 0), currency };
414
+ }
415
+
416
+ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
417
+ if (signal.aborted) return Promise.reject(signal.reason);
418
+ return new Promise<T>((resolve, reject) => {
419
+ const abort = () => reject(signal.reason);
420
+ signal.addEventListener("abort", abort, { once: true });
421
+ promise.then(
422
+ (value) => {
423
+ signal.removeEventListener("abort", abort);
424
+ resolve(value);
425
+ },
426
+ (error: unknown) => {
427
+ signal.removeEventListener("abort", abort);
428
+ reject(error);
429
+ },
430
+ );
431
+ });
432
+ }
@@ -0,0 +1,60 @@
1
+ import type { Question } from "@agentswf/contract/workflow";
2
+ import type { DecisionProvider, ProviderAnswer, ProviderRequest, ProviderResponse } from "./seam";
3
+
4
+ /** One scripted reply: a response, an error to throw, or a function of the request. */
5
+ export type FakeReply =
6
+ | ProviderResponse
7
+ | Error
8
+ | ((request: ProviderRequest, signal: AbortSignal) => Promise<ProviderResponse>);
9
+
10
+ /**
11
+ * A provider that answers from a script, one reply per request, and then with `fallback`. For the
12
+ * engine's own tests; the default answers every question with its first option or level at 0.9.
13
+ */
14
+ export function createFakeDecisionProvider(
15
+ script: FakeReply[] = [],
16
+ fallback: FakeReply = (request) => Promise.resolve(confidentResponse(request)),
17
+ ): DecisionProvider & { requests: ProviderRequest[] } {
18
+ const requests: ProviderRequest[] = [];
19
+ return {
20
+ requests,
21
+ async decide(request, signal) {
22
+ requests.push(request);
23
+ const reply = script.shift() ?? fallback;
24
+ if (reply instanceof Error) throw reply;
25
+ if (typeof reply === "function") return reply(request, signal);
26
+ return reply;
27
+ },
28
+ };
29
+ }
30
+
31
+ /** The first option or level at 0.9, the rest sharing 0.1; yes at 0.9. */
32
+ export function confidentResponse(request: ProviderRequest): ProviderResponse {
33
+ const answers: Record<string, ProviderAnswer> = {};
34
+ for (const [id, question] of Object.entries(request.questions)) answers[id] = confident(question);
35
+ return {
36
+ snapshot: `${request.model}-20260926`,
37
+ answers,
38
+ tokens: { input: 1_000, output: 10 },
39
+ charged: { amount: 0.000042, currency: "USD" },
40
+ requestId: `request-${Object.keys(request.questions).join("-")}`,
41
+ };
42
+ }
43
+
44
+ function confident(question: Question): ProviderAnswer {
45
+ if (question.type === "yes-no") return { type: "yes-no", yes: 0.9 };
46
+ const names =
47
+ question.type === "choice" ? Object.keys(question.options) : question.levels.map(String);
48
+ const rest = names.length > 1 ? 0.1 / (names.length - 1) : 0;
49
+ const probabilities = names.map((_, index) =>
50
+ names.length === 1 ? 1 : index === 0 ? 0.9 : rest,
51
+ );
52
+ return question.type === "choice"
53
+ ? {
54
+ type: "choice",
55
+ probabilities: Object.fromEntries(
56
+ names.map((name, index) => [name, probabilities[index]!]),
57
+ ),
58
+ }
59
+ : { type: "score", probabilities };
60
+ }
@@ -0,0 +1,161 @@
1
+ import { isRecord } from "@agentswf/contract";
2
+ import type { Question } from "@agentswf/contract/workflow";
3
+ import { messageOf } from "../errors";
4
+ import {
5
+ type DecisionProvider,
6
+ DecisionProviderError,
7
+ type ProviderAnswer,
8
+ type ProviderRequest,
9
+ type ProviderResponse,
10
+ } from "./seam";
11
+
12
+ export const OPENROUTER_DECISIONS = "https://openrouter.ai/api/alpha/decisions";
13
+
14
+ /**
15
+ * OpenRouter's Decisions API, which serves Jev (docs/research/system-one-models.md §2). It speaks
16
+ * Jev's words: a yes-no is a `noul`, and `criteria` carries a choice's options, a score's levels
17
+ * and a yes-no's criteria alike.
18
+ */
19
+ export function createOpenRouterProvider(options: {
20
+ apiKey: string;
21
+ endpoint?: string;
22
+ fetch?: typeof globalThis.fetch;
23
+ }): DecisionProvider {
24
+ const { apiKey, endpoint = OPENROUTER_DECISIONS, fetch = globalThis.fetch } = options;
25
+ // An error from the transport can quote the request's headers; the key never leaves in one.
26
+ const redacted = (error: unknown) => messageOf(error).replaceAll(apiKey, "[OPENROUTER_API_KEY]");
27
+ return {
28
+ async decide(request, signal) {
29
+ let text: string;
30
+ let status: number;
31
+ try {
32
+ const response = await fetch(endpoint, {
33
+ method: "POST",
34
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
35
+ body: JSON.stringify(wireRequest(request)),
36
+ signal,
37
+ });
38
+ status = response.status;
39
+ text = await response.text();
40
+ } catch (error) {
41
+ if (signal.aborted) throw error;
42
+ // The connection failed before or while answering; it may come back.
43
+ throw new DecisionProviderError(`openrouter unreachable: ${redacted(error)}`, {
44
+ retryable: true,
45
+ });
46
+ }
47
+ const body = parse(text);
48
+ const failure = errorOf(body);
49
+ if (status < 200 || status >= 300 || failure) {
50
+ const code = failure?.code ?? status;
51
+ const message = failure?.message ?? (text.slice(0, 200) || `status ${status}`);
52
+ throw new DecisionProviderError(`openrouter ${code}: ${message}`, {
53
+ retryable: code === 429 || code >= 500,
54
+ ...spendOf(body),
55
+ });
56
+ }
57
+ return fromWire(request.questions, body);
58
+ },
59
+ };
60
+ }
61
+
62
+ function wireRequest(request: ProviderRequest): Record<string, unknown> {
63
+ return {
64
+ model: request.model,
65
+ state: request.state,
66
+ questions: Object.fromEntries(
67
+ Object.entries(request.questions).map(([id, question]) => [id, wireQuestion(question)]),
68
+ ),
69
+ };
70
+ }
71
+
72
+ function wireQuestion(question: Question): Record<string, unknown> {
73
+ switch (question.type) {
74
+ case "choice":
75
+ return { type: "choice", instructions: question.instructions, criteria: question.options };
76
+ case "score":
77
+ return { type: "score", instructions: question.instructions, criteria: question.levels };
78
+ case "yes-no":
79
+ return {
80
+ type: "noul",
81
+ instructions: question.instructions,
82
+ ...(question.criteria
83
+ ? { criteria: { true: question.criteria.yes, false: question.criteria.no } }
84
+ : {}),
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Only the distributions are kept: the pick and `score` are derived again from them, and Jev's
91
+ * `confidence` cannot be (findings S10). What is missing is left missing, for the directory to
92
+ * reject.
93
+ */
94
+ function fromWire(questions: Record<string, Question>, body: unknown): ProviderResponse {
95
+ if (!isRecord(body) || typeof body.model !== "string" || !isRecord(body.answers)) {
96
+ throw new DecisionProviderError("openrouter answered without a model or answers", {
97
+ retryable: false,
98
+ ...spendOf(body),
99
+ });
100
+ }
101
+ const answers: Record<string, ProviderAnswer> = {};
102
+ for (const [id, question] of Object.entries(questions)) {
103
+ const answer = Object.hasOwn(body.answers, id) ? body.answers[id] : undefined;
104
+ if (!isRecord(answer)) continue;
105
+ const probabilities = isRecord(answer.probabilities) ? answer.probabilities : {};
106
+ if (answer.type === "noul") {
107
+ answers[id] = { type: "yes-no", yes: answer.noul as number };
108
+ } else if (answer.type === "choice") {
109
+ answers[id] = { type: "choice", probabilities: probabilities as Record<string, number> };
110
+ } else if (answer.type === "score") {
111
+ answers[id] = { type: "score", probabilities: levelsOf(question, probabilities) };
112
+ }
113
+ }
114
+ return { snapshot: body.model, answers, ...spendOf(body) };
115
+ }
116
+
117
+ /**
118
+ * A score's probabilities, keyed "0" to "n-1", as a list. Any other key makes the list the wrong
119
+ * length, for the directory to reject, as it rejects an option nobody asked about.
120
+ */
121
+ function levelsOf(question: Question, probabilities: Record<string, unknown>): number[] {
122
+ const count = question.type === "score" ? question.levels.length : 0;
123
+ const listed = Array.from(
124
+ { length: count },
125
+ (_, index) => probabilities[String(index)] as number,
126
+ );
127
+ const expected = new Set(listed.map((_, index) => String(index)));
128
+ const extra = Object.keys(probabilities).filter((key) => !expected.has(key));
129
+ return extra.length === 0 ? listed : [...listed, ...extra.map(() => Number.NaN)];
130
+ }
131
+
132
+ /** What a body says was spent, as far as it says: tokens only when both counts are reported. */
133
+ function spendOf(body: unknown): Pick<ProviderResponse, "tokens" | "charged" | "requestId"> {
134
+ if (!isRecord(body)) return {};
135
+ const usage = isRecord(body.usage) ? body.usage : {};
136
+ const counted = typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number";
137
+ return {
138
+ ...(counted
139
+ ? { tokens: { input: usage.input_tokens as number, output: usage.output_tokens as number } }
140
+ : {}),
141
+ ...(typeof usage.cost === "number" ? { charged: { amount: usage.cost, currency: "USD" } } : {}),
142
+ ...(typeof body.id === "string" ? { requestId: body.id } : {}),
143
+ };
144
+ }
145
+
146
+ function errorOf(body: unknown): { code: number; message: string } | undefined {
147
+ if (!isRecord(body) || !isRecord(body.error)) return undefined;
148
+ const { code, message } = body.error;
149
+ return {
150
+ code: typeof code === "number" ? code : 500,
151
+ message: typeof message === "string" ? message : JSON.stringify(body.error),
152
+ };
153
+ }
154
+
155
+ function parse(text: string): unknown {
156
+ try {
157
+ return JSON.parse(text);
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }