@agentswf/engine 0.1.0-beta.4 → 0.1.0
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/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, join
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
decodeHolderRequest,
|
|
7
|
+
type HolderWireAnswer,
|
|
8
|
+
type NudgeWireResult,
|
|
9
|
+
WIRE_VERSION,
|
|
10
|
+
} from "@agentswf/contract/wire";
|
|
6
11
|
import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
|
|
7
|
-
import {
|
|
12
|
+
import { messageOf } from "./errors";
|
|
13
|
+
import { isInside, realPath } from "./files";
|
|
8
14
|
import type { MessagingEventBody } from "./messaging/messaging";
|
|
9
15
|
import type { RunStatus } from "./run-state";
|
|
10
16
|
|
|
@@ -51,9 +57,8 @@ export async function prepareCoordinatorDir(
|
|
|
51
57
|
return parent === path ? path : join(await real(parent), basename(path));
|
|
52
58
|
}
|
|
53
59
|
};
|
|
54
|
-
const
|
|
55
|
-
const within =
|
|
56
|
-
inside === "" || !(inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside));
|
|
60
|
+
const [root, realDir] = [await real(workflowDir), await real(dir)];
|
|
61
|
+
const within = realDir === root || isInside(root, realDir);
|
|
57
62
|
if (moved && within)
|
|
58
63
|
throw new Error(
|
|
59
64
|
`AWF_COORDINATOR_DIR ${dir} is inside the workflow's directory, where its agents work; move it out`,
|
|
@@ -65,7 +70,7 @@ export async function prepareCoordinatorDir(
|
|
|
65
70
|
/**
|
|
66
71
|
* The socket `awf reply` and `awf send` reach a live run through. It opens once the workflow
|
|
67
72
|
* connects an outside participant and goes when the run ends; each connection is one request
|
|
68
|
-
* line and one `
|
|
73
|
+
* line and one `HolderWireAnswer` line back.
|
|
69
74
|
*/
|
|
70
75
|
export function coordinatorSocket(options: {
|
|
71
76
|
dir: string;
|
|
@@ -73,7 +78,7 @@ export function coordinatorSocket(options: {
|
|
|
73
78
|
attempt: number;
|
|
74
79
|
/** Why the socket did not open: the run goes on, and no holder's command reaches it. */
|
|
75
80
|
failed(reason: string): void;
|
|
76
|
-
/** A nudge: the run
|
|
81
|
+
/** A nudge: the run has a new event. Given, the socket opens at once, for awf's nudge. */
|
|
77
82
|
nudged?(): void;
|
|
78
83
|
}): CoordinatorPort & { close(): Promise<void>; readonly path: string } {
|
|
79
84
|
const path = coordinatorSocketPath(options.dir, options.runDir);
|
|
@@ -83,10 +88,11 @@ export function coordinatorSocket(options: {
|
|
|
83
88
|
let closed = false;
|
|
84
89
|
const id = (n: number) => `${options.attempt}.${n}`;
|
|
85
90
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Undefined for a request no `awf` command makes: the connection closes unanswered. One of
|
|
93
|
+
* another wire version is refused naming both, which an older `awf send` or `awf reply` prints.
|
|
94
|
+
*/
|
|
95
|
+
const answer = async (frame: string): Promise<HolderWireAnswer | NudgeWireResult | undefined> => {
|
|
90
96
|
let parsed: unknown;
|
|
91
97
|
try {
|
|
92
98
|
parsed = JSON.parse(frame);
|
|
@@ -94,7 +100,13 @@ export function coordinatorSocket(options: {
|
|
|
94
100
|
return undefined;
|
|
95
101
|
}
|
|
96
102
|
const decoded = decodeHolderRequest(parsed);
|
|
97
|
-
if (!decoded.ok)
|
|
103
|
+
if (!decoded.ok)
|
|
104
|
+
return decoded.code === "unsupported-version"
|
|
105
|
+
? refused(
|
|
106
|
+
"unsupported-version",
|
|
107
|
+
`this awf speaks wire version ${(parsed as { version: number }).version} and the run's engine ${WIRE_VERSION}: awf was installed anew while the run was live. Reach it with the awf it started with, or let it end and continue it under this one.`,
|
|
108
|
+
)
|
|
109
|
+
: undefined;
|
|
98
110
|
const request = decoded.value;
|
|
99
111
|
if (request.command === "nudge") {
|
|
100
112
|
if (!options.nudged) return undefined;
|
|
@@ -102,13 +114,14 @@ export function coordinatorSocket(options: {
|
|
|
102
114
|
return { v: 1, code: "nudged" };
|
|
103
115
|
}
|
|
104
116
|
if (!holder) return refused("no-participant", "the run has no participant connected");
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
const [attempt, n] = request.to?.split(".") ?? [];
|
|
118
|
+
// Another attempt's question ended with it.
|
|
119
|
+
if (request.to !== undefined && attempt !== String(options.attempt))
|
|
120
|
+
return refused(
|
|
121
|
+
"expired",
|
|
122
|
+
`${request.to} is a question of attempt ${attempt}, which has ended; the run is on attempt ${options.attempt}.`,
|
|
123
|
+
);
|
|
124
|
+
const to = n === undefined ? undefined : Number(n);
|
|
112
125
|
const asked = {
|
|
113
126
|
...(request.participant === undefined ? {} : { coordinator: request.participant }),
|
|
114
127
|
agent: request.agent,
|
|
@@ -119,9 +132,9 @@ export function coordinatorSocket(options: {
|
|
|
119
132
|
const result =
|
|
120
133
|
request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
|
|
121
134
|
return result.code === "delivered"
|
|
122
|
-
? { v:
|
|
135
|
+
? { v: 2, code: "delivered", id: id(result.message) }
|
|
123
136
|
: {
|
|
124
|
-
v:
|
|
137
|
+
v: 2,
|
|
125
138
|
code: result.code,
|
|
126
139
|
error: result.error,
|
|
127
140
|
...(result.choices ? { choices: result.choices } : {}),
|
|
@@ -185,10 +198,7 @@ export function coordinatorSocket(options: {
|
|
|
185
198
|
.then((result) => socket.end(result ? `${JSON.stringify(result)}\n` : undefined));
|
|
186
199
|
};
|
|
187
200
|
|
|
188
|
-
if (options.nudged)
|
|
189
|
-
opening = open().catch((error: unknown) =>
|
|
190
|
-
options.failed(error instanceof Error ? error.message : String(error)),
|
|
191
|
-
);
|
|
201
|
+
if (options.nudged) opening = open().catch((error: unknown) => options.failed(messageOf(error)));
|
|
192
202
|
return {
|
|
193
203
|
path,
|
|
194
204
|
attach(given) {
|
|
@@ -196,9 +206,7 @@ export function coordinatorSocket(options: {
|
|
|
196
206
|
},
|
|
197
207
|
recorded(event: MessagingEventBody) {
|
|
198
208
|
if (event.kind === "participant-connected" && !opening) {
|
|
199
|
-
opening = open().catch((error: unknown) =>
|
|
200
|
-
options.failed(error instanceof Error ? error.message : String(error)),
|
|
201
|
-
);
|
|
209
|
+
opening = open().catch((error: unknown) => options.failed(messageOf(error)));
|
|
202
210
|
}
|
|
203
211
|
},
|
|
204
212
|
async close() {
|
|
@@ -211,10 +219,10 @@ export function coordinatorSocket(options: {
|
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
function refused(
|
|
214
|
-
code: Exclude<
|
|
222
|
+
code: Exclude<HolderWireAnswer["code"], "delivered">,
|
|
215
223
|
error: string,
|
|
216
|
-
):
|
|
217
|
-
return { v:
|
|
224
|
+
): HolderWireAnswer {
|
|
225
|
+
return { v: 2, code, error };
|
|
218
226
|
}
|
|
219
227
|
|
|
220
228
|
/**
|
package/src/deadlines.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AbsoluteDeadline,
|
|
3
|
+
DeadlineExceededError,
|
|
4
|
+
positiveDurationMilliseconds,
|
|
5
|
+
type Timeout,
|
|
6
|
+
} from "@agentswf/contract/workflow";
|
|
2
7
|
|
|
3
8
|
export class WorkflowCancelledError extends Error {
|
|
4
9
|
constructor(readonly reason: unknown) {
|
|
@@ -87,20 +92,48 @@ export function earlierDeadline(
|
|
|
87
92
|
return left.unixMilliseconds <= right.unixMilliseconds ? left : right;
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
export function laterDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline): AbsoluteDeadline {
|
|
91
|
-
return left.unixMilliseconds >= right.unixMilliseconds ? left : right;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
95
|
export function deadlineWithin(
|
|
95
96
|
milliseconds: number,
|
|
96
97
|
ceiling: AbsoluteDeadline | undefined,
|
|
97
98
|
): AbsoluteDeadline {
|
|
98
99
|
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
|
|
99
|
-
throw new Error(
|
|
100
|
+
throw new Error(`timeoutMs is a positive duration, in whole milliseconds: not ${milliseconds}`);
|
|
100
101
|
}
|
|
101
102
|
return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The deadline a call's `timeout` sets, never past `ceiling`, the deadlines around it; `ceiling`
|
|
107
|
+
* when it sets none of its own. `null` from an untyped caller is no bound.
|
|
108
|
+
*/
|
|
109
|
+
export function boundedDeadline(
|
|
110
|
+
timeout: Timeout | null | undefined,
|
|
111
|
+
ceiling: AbsoluteDeadline | undefined,
|
|
112
|
+
): AbsoluteDeadline | undefined {
|
|
113
|
+
return timeout == null ? ceiling : timeoutDeadline(timeout, ceiling);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The deadline `timeout` sets from now, never past `ceiling`; `ceiling` for `false`. */
|
|
117
|
+
function timeoutDeadline(
|
|
118
|
+
timeout: Timeout,
|
|
119
|
+
ceiling: AbsoluteDeadline | undefined,
|
|
120
|
+
): AbsoluteDeadline | undefined {
|
|
121
|
+
if (timeout === false) return ceiling;
|
|
122
|
+
if (timeout instanceof Date) {
|
|
123
|
+
const at = timeout.getTime();
|
|
124
|
+
if (!Number.isSafeInteger(at) || at < 0) throw new Error("a timeout's Date is a valid time");
|
|
125
|
+
return earlierDeadline({ unixMilliseconds: at }, ceiling);
|
|
126
|
+
}
|
|
127
|
+
if (typeof timeout !== "string")
|
|
128
|
+
throw new Error(
|
|
129
|
+
`a timeout is a Duration such as "10m", a Date, or false; not ${JSON.stringify(timeout)}`,
|
|
130
|
+
);
|
|
131
|
+
return earlierDeadline(
|
|
132
|
+
{ unixMilliseconds: Date.now() + positiveDurationMilliseconds(timeout, "a timeout") },
|
|
133
|
+
ceiling,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
104
137
|
export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
|
|
105
138
|
if (!deadline) return;
|
|
106
139
|
assertDeadlineValue(deadline);
|
|
@@ -109,7 +142,7 @@ export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
|
|
|
109
142
|
}
|
|
110
143
|
}
|
|
111
144
|
|
|
112
|
-
|
|
145
|
+
function assertDeadlineValue(deadline: AbsoluteDeadline): void {
|
|
113
146
|
if (!Number.isSafeInteger(deadline.unixMilliseconds) || deadline.unixMilliseconds < 0) {
|
|
114
147
|
throw new Error("deadline.unixMilliseconds must be a non-negative safe integer");
|
|
115
148
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
type DecisionArtifact,
|
|
5
|
+
type Money,
|
|
6
|
+
OP_FILE_VERSIONS,
|
|
7
|
+
type SettledDecision,
|
|
8
|
+
} from "@agentswf/contract/records";
|
|
5
9
|
import {
|
|
6
10
|
type AbsoluteDeadline,
|
|
7
11
|
type Answers,
|
|
@@ -14,14 +18,10 @@ import {
|
|
|
14
18
|
type Question,
|
|
15
19
|
} from "@agentswf/contract/workflow";
|
|
16
20
|
import { canonical } from "../canonical-json";
|
|
17
|
-
import {
|
|
18
|
-
abortableSleep,
|
|
19
|
-
assertDeadline,
|
|
20
|
-
assertDeadlineValue,
|
|
21
|
-
earlierDeadline,
|
|
22
|
-
scheduleAt,
|
|
23
|
-
} from "../deadlines";
|
|
21
|
+
import { abortableSleep, assertDeadline, boundedDeadline, scheduleAt } from "../deadlines";
|
|
24
22
|
import { messageOf } from "../errors";
|
|
23
|
+
import { writeJson } from "../files";
|
|
24
|
+
import { decisionArtifact, type OpIds } from "../ops";
|
|
25
25
|
import type { RunEventBody } from "../run-event-log";
|
|
26
26
|
import {
|
|
27
27
|
type DecisionInstallation,
|
|
@@ -70,6 +70,8 @@ export class RunDecisions {
|
|
|
70
70
|
private readonly options: {
|
|
71
71
|
installation?: DecisionInstallation;
|
|
72
72
|
runDir: string;
|
|
73
|
+
/** Numbers each asking, `{key}.{n}`, and keeps the keys apart from agents'. */
|
|
74
|
+
ops: OpIds;
|
|
73
75
|
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
74
76
|
/** Writes the run's event log. */
|
|
75
77
|
event?(body: RunEventBody): void;
|
|
@@ -133,22 +135,12 @@ export class RunDecisions {
|
|
|
133
135
|
? installed.providers[resolved.provider]
|
|
134
136
|
: undefined;
|
|
135
137
|
if (!provider) throw new Error(`decision provider "${resolved.provider}" is not installed`);
|
|
136
|
-
|
|
137
|
-
const deadline = spec.deadline
|
|
138
|
-
? earlierDeadline(spec.deadline, scope.deadline)
|
|
139
|
-
: scope.deadline;
|
|
138
|
+
const deadline = boundedDeadline(spec.timeout, scope.deadline);
|
|
140
139
|
assertDeadline(deadline);
|
|
140
|
+
// Last: a decision refused for anything else holds no key.
|
|
141
|
+
this.options.ops.claim(spec.key, "decision");
|
|
141
142
|
const slot = this.#slots.length;
|
|
142
143
|
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
144
|
return { spec, ids, resolved, provider, deadline, slot };
|
|
153
145
|
}
|
|
154
146
|
|
|
@@ -157,6 +149,12 @@ export class RunDecisions {
|
|
|
157
149
|
scope: DecisionScope,
|
|
158
150
|
): Promise<{ answers: Record<string, unknown>; record: DecisionRecord }> {
|
|
159
151
|
const { spec, ids, resolved, provider, deadline, slot } = call;
|
|
152
|
+
const { runDir, ops } = this.options;
|
|
153
|
+
// Its folder and op.json are written as its id is drawn, before any line names it.
|
|
154
|
+
const { id: operation, lost: opLost } = ops.draw(spec.key, {
|
|
155
|
+
kind: "decision",
|
|
156
|
+
...(scope.stage === undefined ? {} : { stage: scope.stage }),
|
|
157
|
+
});
|
|
160
158
|
const startedAt = new Date().toISOString();
|
|
161
159
|
const controller = new AbortController();
|
|
162
160
|
let ending: Ending | undefined;
|
|
@@ -170,17 +168,27 @@ export class RunDecisions {
|
|
|
170
168
|
: () => undefined;
|
|
171
169
|
this.#cancellers.add(cancel);
|
|
172
170
|
const removeFromScope = scope.add?.(async (reason) => cancel(reason));
|
|
171
|
+
this.options.event?.({
|
|
172
|
+
kind: "decision-asked",
|
|
173
|
+
decision: slot + 1,
|
|
174
|
+
operation,
|
|
175
|
+
...(scope.stage === undefined ? {} : { stage: scope.stage }),
|
|
176
|
+
key: spec.key,
|
|
177
|
+
alias: spec.model,
|
|
178
|
+
provider: resolved.provider,
|
|
179
|
+
model: resolved.model,
|
|
180
|
+
});
|
|
173
181
|
|
|
174
182
|
let attempts = 0;
|
|
175
183
|
let tokens: { input: number; output: number } | undefined;
|
|
176
|
-
let
|
|
184
|
+
let reportedCosts: Money[] = [];
|
|
177
185
|
let requestId: string | undefined;
|
|
178
186
|
let response: ProviderResponse | undefined;
|
|
179
187
|
let answers: Record<string, unknown> | undefined;
|
|
180
188
|
let error: string | undefined;
|
|
181
189
|
const count = (spent: {
|
|
182
190
|
tokens?: ProviderResponse["tokens"];
|
|
183
|
-
|
|
191
|
+
reportedCost?: Money;
|
|
184
192
|
requestId?: string;
|
|
185
193
|
}) => {
|
|
186
194
|
if (spent.tokens) {
|
|
@@ -189,7 +197,7 @@ export class RunDecisions {
|
|
|
189
197
|
output: (tokens?.output ?? 0) + spent.tokens.output,
|
|
190
198
|
};
|
|
191
199
|
}
|
|
192
|
-
if (spent.
|
|
200
|
+
if (spent.reportedCost) reportedCosts = [...reportedCosts, spent.reportedCost];
|
|
193
201
|
if (spent.requestId) requestId = spent.requestId;
|
|
194
202
|
};
|
|
195
203
|
const request = {
|
|
@@ -232,7 +240,7 @@ export class RunDecisions {
|
|
|
232
240
|
this.#cancellers.delete(cancel);
|
|
233
241
|
}
|
|
234
242
|
|
|
235
|
-
const artifact =
|
|
243
|
+
const artifact = decisionArtifact(operation);
|
|
236
244
|
const outcome: DecisionRecord["outcome"] = ending
|
|
237
245
|
? ending.outcome
|
|
238
246
|
: answers
|
|
@@ -263,19 +271,21 @@ export class RunDecisions {
|
|
|
263
271
|
...(requestId ? { requestId } : {}),
|
|
264
272
|
artifact,
|
|
265
273
|
};
|
|
266
|
-
const total = sumMoney(
|
|
267
|
-
let settled: SettledDecision = { ...record, ...(total ? {
|
|
274
|
+
const total = sumMoney(reportedCosts);
|
|
275
|
+
let settled: SettledDecision = { ...record, ...(total ? { reportedCost: total } : {}) };
|
|
268
276
|
const written: DecisionArtifact = {
|
|
277
|
+
version: OP_FILE_VERSIONS.decision,
|
|
269
278
|
record: settled,
|
|
270
279
|
request: { state: spec.state, questions: spec.questions },
|
|
271
280
|
...(answers ? { answers: answers as JsonObject } : {}),
|
|
272
281
|
};
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
282
|
+
const artifactLost = await writeJson(join(runDir, artifact), written).then(
|
|
283
|
+
() => undefined,
|
|
284
|
+
(thrown: unknown) => `artifact not written: ${messageOf(thrown)}`,
|
|
285
|
+
);
|
|
286
|
+
if (opLost !== undefined || artifactLost !== undefined) {
|
|
287
|
+
const lost = [opLost, artifactLost].filter((each) => each !== undefined).join("; ");
|
|
288
|
+
// Without its records an answer cannot be fitted again, so it does not count as one; any
|
|
279
289
|
// other outcome stands.
|
|
280
290
|
settled =
|
|
281
291
|
settled.outcome === "answered"
|
|
@@ -286,11 +296,12 @@ export class RunDecisions {
|
|
|
286
296
|
this.options.event?.({
|
|
287
297
|
kind: "decision-ended",
|
|
288
298
|
decision: slot + 1,
|
|
299
|
+
operation,
|
|
289
300
|
...(settled.stage === undefined ? {} : { stage: settled.stage }),
|
|
290
301
|
outcome: settled.outcome,
|
|
291
302
|
...(settled.error === undefined ? {} : { error: settled.error }),
|
|
292
303
|
});
|
|
293
|
-
const {
|
|
304
|
+
const { reportedCost: _reportedCost, ...returned } = settled;
|
|
294
305
|
|
|
295
306
|
if (returned.outcome === "answered") return { answers: answers!, record: returned };
|
|
296
307
|
if (returned.outcome === "timed-out") throw new DeadlineExceededError(deadline!);
|
|
@@ -408,11 +419,11 @@ function isProbability(value: unknown): value is number {
|
|
|
408
419
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
409
420
|
}
|
|
410
421
|
|
|
411
|
-
/**
|
|
412
|
-
function sumMoney(
|
|
413
|
-
const currency =
|
|
414
|
-
if (!currency ||
|
|
415
|
-
return { amount:
|
|
422
|
+
/** Costs in more than one currency cannot be added without converting, so their sum is unknown. */
|
|
423
|
+
function sumMoney(costs: readonly Money[]): Money | undefined {
|
|
424
|
+
const currency = costs[0]?.currency;
|
|
425
|
+
if (!currency || costs.some((cost) => cost.currency !== currency)) return undefined;
|
|
426
|
+
return { amount: costs.reduce((sum, cost) => sum + cost.amount, 0), currency };
|
|
416
427
|
}
|
|
417
428
|
|
|
418
429
|
function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
package/src/decisions/fake.ts
CHANGED
|
@@ -36,7 +36,7 @@ export function confidentResponse(request: ProviderRequest): ProviderResponse {
|
|
|
36
36
|
snapshot: `${request.model}-20260926`,
|
|
37
37
|
answers,
|
|
38
38
|
tokens: { input: 1_000, output: 10 },
|
|
39
|
-
|
|
39
|
+
reportedCost: { amount: 0.000042, currency: "USD" },
|
|
40
40
|
requestId: `request-${Object.keys(request.questions).join("-")}`,
|
|
41
41
|
};
|
|
42
42
|
}
|
|
@@ -130,7 +130,7 @@ function levelsOf(question: Question, probabilities: Record<string, unknown>): n
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
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" | "
|
|
133
|
+
function spendOf(body: unknown): Pick<ProviderResponse, "tokens" | "reportedCost" | "requestId"> {
|
|
134
134
|
if (!isRecord(body)) return {};
|
|
135
135
|
const usage = isRecord(body.usage) ? body.usage : {};
|
|
136
136
|
const counted = typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number";
|
|
@@ -138,7 +138,9 @@ function spendOf(body: unknown): Pick<ProviderResponse, "tokens" | "charged" | "
|
|
|
138
138
|
...(counted
|
|
139
139
|
? { tokens: { input: usage.input_tokens as number, output: usage.output_tokens as number } }
|
|
140
140
|
: {}),
|
|
141
|
-
...(typeof usage.cost === "number"
|
|
141
|
+
...(typeof usage.cost === "number"
|
|
142
|
+
? { reportedCost: { amount: usage.cost, currency: "USD" } }
|
|
143
|
+
: {}),
|
|
142
144
|
...(typeof body.id === "string" ? { requestId: body.id } : {}),
|
|
143
145
|
};
|
|
144
146
|
}
|
package/src/decisions/seam.ts
CHANGED
|
@@ -24,7 +24,7 @@ export type ProviderResponse = {
|
|
|
24
24
|
answers: Record<string, ProviderAnswer>;
|
|
25
25
|
/** Absent when the provider did not report them: unknown, not zero. */
|
|
26
26
|
tokens?: { input: number; output: number };
|
|
27
|
-
|
|
27
|
+
reportedCost?: Money;
|
|
28
28
|
requestId?: string;
|
|
29
29
|
};
|
|
30
30
|
|
|
@@ -40,7 +40,7 @@ export interface DecisionProvider {
|
|
|
40
40
|
export class DecisionProviderError extends Error {
|
|
41
41
|
readonly retryable: boolean;
|
|
42
42
|
readonly tokens?: { input: number; output: number };
|
|
43
|
-
readonly
|
|
43
|
+
readonly reportedCost?: Money;
|
|
44
44
|
readonly requestId?: string;
|
|
45
45
|
|
|
46
46
|
constructor(
|
|
@@ -48,7 +48,7 @@ export class DecisionProviderError extends Error {
|
|
|
48
48
|
options: {
|
|
49
49
|
retryable: boolean;
|
|
50
50
|
tokens?: { input: number; output: number };
|
|
51
|
-
|
|
51
|
+
reportedCost?: Money;
|
|
52
52
|
requestId?: string;
|
|
53
53
|
},
|
|
54
54
|
) {
|
|
@@ -56,7 +56,7 @@ export class DecisionProviderError extends Error {
|
|
|
56
56
|
this.name = "DecisionProviderError";
|
|
57
57
|
this.retryable = options.retryable;
|
|
58
58
|
if (options.tokens) this.tokens = options.tokens;
|
|
59
|
-
if (options.
|
|
59
|
+
if (options.reportedCost) this.reportedCost = options.reportedCost;
|
|
60
60
|
if (options.requestId) this.requestId = options.requestId;
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/display-path.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { isInside } from "./files";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* `path` as typed from `cwd`: relative under it, absolute elsewhere, so it works from anywhere; with
|
|
@@ -7,8 +8,7 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
7
8
|
*/
|
|
8
9
|
export function pathFrom(cwd: string, path: string, options: { home?: string } = {}): string {
|
|
9
10
|
const absolute = resolve(cwd, path);
|
|
10
|
-
|
|
11
|
-
if (inside && !inside.startsWith("..") && !isAbsolute(inside)) return inside;
|
|
11
|
+
if (isInside(cwd, absolute)) return relative(cwd, absolute);
|
|
12
12
|
const { home } = options;
|
|
13
13
|
return home !== undefined && absolute.startsWith(`${home}/`)
|
|
14
14
|
? `~${absolute.slice(home.length)}`
|
package/src/duration.ts
CHANGED
|
@@ -1,29 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import {
|
|
2
|
+
durationMilliseconds,
|
|
3
|
+
positiveDurationMilliseconds,
|
|
4
|
+
secondsForMilliseconds,
|
|
5
|
+
} from "@agentswf/contract/workflow";
|
|
6
|
+
import { messageOf } from "./errors";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A flag's duration in milliseconds, longer than none: the one grammar, `"90s"`, `"1.5h"`, `"1d"`.
|
|
10
|
+
* A number of `ms`, which the CLI took before 0.1.0-beta.6, is refused with its replacement.
|
|
11
|
+
*/
|
|
12
|
+
export function parseDuration(value: string, flag: string): number {
|
|
13
|
+
const seconds = secondsForMilliseconds(value);
|
|
14
|
+
if (seconds === undefined) return positiveDurationMilliseconds(value, flag);
|
|
15
|
+
throw new Error(
|
|
16
|
+
`${flag} is a duration, a decimal and s, m, h or d: give ${seconds}, not ${value}`,
|
|
17
|
+
);
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* `--wait`, in seconds from none to `maxSeconds`: a duration, `"90s"`. A bare number of seconds,
|
|
22
|
+
* which it took before 0.1.0-beta.6, is refused with its replacement.
|
|
23
|
+
*/
|
|
24
|
+
export function parseWait(value: string, maxSeconds: number): number {
|
|
25
|
+
if (/^\d+(\.\d+)?$/.test(value))
|
|
26
|
+
throw new Error(
|
|
27
|
+
`--wait takes a duration up to ${maxSeconds}s: give --wait ${Math.min(Number(value), maxSeconds)}s`,
|
|
28
|
+
);
|
|
29
|
+
let seconds: number;
|
|
30
|
+
try {
|
|
31
|
+
seconds = durationMilliseconds(value) / 1000;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`--wait takes a duration up to ${maxSeconds}s: ${messageOf(error)}`);
|
|
34
|
+
}
|
|
35
|
+
if (seconds > maxSeconds) throw new Error(`--wait takes a duration up to ${maxSeconds}s`);
|
|
36
|
+
return seconds;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
/**
|
|
24
40
|
* When a timer given `when` is due, epoch ms, from `now`, and `when` as its record keeps it: a
|
|
25
|
-
*
|
|
26
|
-
*
|
|
41
|
+
* `Duration` as given, or a `Date` as an ISO time. A workflow's grammar, apart from the CLI's
|
|
42
|
+
* `parseDuration`: a wait is days long, and `1.5h` reads as written.
|
|
27
43
|
*/
|
|
28
44
|
export function parseWhen(when: unknown, now: number): { due: number; when: string } {
|
|
29
45
|
if (when instanceof Date) {
|
|
@@ -31,11 +47,11 @@ export function parseWhen(when: unknown, now: number): { due: number; when: stri
|
|
|
31
47
|
if (!Number.isFinite(due)) throw new Error("a timer's time is a valid Date");
|
|
32
48
|
return { due, when: when.toISOString() };
|
|
33
49
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
return { due: now +
|
|
50
|
+
let milliseconds: number;
|
|
51
|
+
try {
|
|
52
|
+
milliseconds = durationMilliseconds(when);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(`a timer is due in a duration or at a Date: ${messageOf(error)}`);
|
|
55
|
+
}
|
|
56
|
+
return { due: now + milliseconds, when: when as string };
|
|
41
57
|
}
|