@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/spend.ts ADDED
@@ -0,0 +1,333 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ type Billing,
5
+ type ClaimedAttempt,
6
+ type DecisionArtifact,
7
+ OP_FILE_VERSIONS,
8
+ type OpRecord,
9
+ type RunLine,
10
+ type SettledOperation,
11
+ type SpendRecord,
12
+ } from "@agentswf/contract/records";
13
+ import type { AgentExecution } from "@agentswf/contract/workflow";
14
+ import type { SessionAccounting, UsageRecord } from "@agentswf/harness";
15
+ import { PUBLISHED_PRICES } from "./accounting/prices";
16
+ import { type AttemptSpent, attemptsSpent, type RecordedOp } from "./accounting/spent";
17
+ import { spendOf } from "./accounting/tokens";
18
+ import { runUntilStopped } from "./deadlines";
19
+ import { messageOf } from "./errors";
20
+ import { isCode, linkNew } from "./files";
21
+ import { opFile, readOpFile, spendFile } from "./ops";
22
+ import { unfenced } from "./run-policy/waits";
23
+ import { ACCOUNTING_GRACE_MILLISECONDS, STATUS_SLACK_MILLISECONDS } from "./run-usage";
24
+
25
+ /**
26
+ * Each agent operation's `spend.json`: its usage as measured once, while its sessions are there to
27
+ * read, and never priced. Written with `link`, so only one write lands: by the attempt that ran it,
28
+ * as it ends, or by the next attempt, as it starts, for one that attempt left without one.
29
+ */
30
+
31
+ type AgentOpRecord = Extract<OpRecord, { agent: string }>;
32
+
33
+ /** A `spend.json` as its writer gives it: its version is added as it is written. */
34
+ type SpendFields = Omit<SpendRecord, "version">;
35
+
36
+ /** A settling past its deadline, or stopped. */
37
+ const STOPPED: unique symbol = Symbol("stopped");
38
+
39
+ /** Writes an operation's `spend.json`, once: false when one is there already. */
40
+ export async function writeSpend(
41
+ runDir: string,
42
+ op: string,
43
+ record: SpendFields,
44
+ ): Promise<boolean> {
45
+ return linkNew(spendFile(runDir, op), { version: OP_FILE_VERSIONS.spend, ...record });
46
+ }
47
+
48
+ /**
49
+ * What an ending attempt keeps of the operations it drew: each settled one's usage, and nothing
50
+ * spent for one that never reached its agent, such as a message withdrawn before it opened. One
51
+ * whose sessions went unread, its read cut short by a stop or its deadline, is left to the next
52
+ * attempt, which reads them again, unless nothing could ever read its harness's sessions. Returns what failed to
53
+ * write.
54
+ */
55
+ export async function keepSpend(
56
+ runDir: string,
57
+ attempt: number,
58
+ usage: readonly SettledOperation[],
59
+ options: {
60
+ /** Whether a later attempt could read this operation's sessions: none, and unknown is final. */
61
+ reads(execution: AgentExecution): boolean;
62
+ },
63
+ ): Promise<string[]> {
64
+ const ops = await readOps(runDir, { decisions: false }).catch(() => []);
65
+ const settled = new Map(usage.map((record) => [record.operationId, record]));
66
+ const failed: string[] = [];
67
+ await Promise.all(
68
+ ops.flatMap(({ op, spend }) => {
69
+ if (op.kind === "decision" || op.attempt !== attempt || spend) return [];
70
+ const record = settled.get(op.id);
71
+ if (record && record.spend === undefined && options.reads(record.execution)) return [];
72
+ const spent: SpendFields = record
73
+ ? recordOf(record)
74
+ : {
75
+ billing: usage.find((each) => each.agent === op.agent)?.billing ?? "unknown",
76
+ spend: [],
77
+ };
78
+ return [
79
+ writeSpend(runDir, op.id, spent).catch((error: unknown) => {
80
+ failed.push(`${op.id}: ${messageOf(error)}`);
81
+ }),
82
+ ];
83
+ }),
84
+ );
85
+ return failed;
86
+ }
87
+
88
+ function recordOf(record: SettledOperation): SpendFields {
89
+ return {
90
+ billing: record.billing,
91
+ ...(record.spend === undefined ? {} : { spend: record.spend }),
92
+ ...(record.reportedCost === undefined ? {} : { reportedCost: record.reportedCost }),
93
+ ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
94
+ ...(record.settledAt === undefined ? {} : { settledAt: record.settledAt }),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Every operation in `ops/` whose `op.json` reads, with its `spend.json` and a decision's record.
100
+ * A folder whose `op.json` is lost is passed over: its operation failed as it was drawn.
101
+ * `decisions: false` leaves out what decisions used, which an agents' estimate doesn't count.
102
+ * `refuse` throws on an `op.json` or `decision.json` of a version this awf doesn't know, whose
103
+ * operation would otherwise go uncounted; an unreadable `spend.json` is usage unknown either way.
104
+ */
105
+ export async function readOps(
106
+ runDir: string,
107
+ options: { decisions?: boolean; refuse?: boolean } = {},
108
+ ): Promise<RecordedOp[]> {
109
+ const refuse = { refuse: options.refuse === true };
110
+ let names: string[];
111
+ try {
112
+ names = await readdir(join(runDir, "ops"));
113
+ } catch (error) {
114
+ if (isCode(error, "ENOENT")) return [];
115
+ throw error;
116
+ }
117
+ const read = await Promise.all(
118
+ names.map(async (name): Promise<RecordedOp | undefined> => {
119
+ const op = await readOpFile<OpRecord>(opFile(runDir, name), "op", refuse);
120
+ if (!op) return undefined;
121
+ if (op.kind === "decision") {
122
+ if (options.decisions === false) return { op };
123
+ const artifact = await readOpFile<DecisionArtifact>(
124
+ join(runDir, "ops", name, "decision.json"),
125
+ "decision",
126
+ refuse,
127
+ );
128
+ return artifact ? { op, decision: artifact.record } : { op };
129
+ }
130
+ const spend = await readOpFile<SpendRecord>(spendFile(runDir, name), "spend");
131
+ return spend ? { op, spend } : { op };
132
+ }),
133
+ );
134
+ return read.filter((each) => each !== undefined);
135
+ }
136
+
137
+ /**
138
+ * Each claimed attempt's accounting from its operations, priced with today's table. Rejects when an
139
+ * operation's file is of a version this awf doesn't know: its callers show the cost as unknown.
140
+ */
141
+ export async function readSpent(
142
+ runDir: string,
143
+ attempts: readonly ClaimedAttempt[],
144
+ options: { decisions?: boolean } = {},
145
+ ): Promise<AttemptSpent[]> {
146
+ const ops = await readOps(runDir, { ...options, refuse: true });
147
+ return attemptsSpent(ops, attempts, PUBLISHED_PRICES);
148
+ }
149
+
150
+ /**
151
+ * Settles every agent operation an earlier attempt left without `spend.json`: one interrupted, or
152
+ * whose read was cut short. Called as an attempt starts, once its claim has waited for the
153
+ * previous one and ended its turns, and before any agent opens, while the sessions are still
154
+ * there. Per attempt, the agents with an operation to settle are read in the order they first
155
+ * started one, each request counted once, by the first to log it, as a run's end does. An agent's
156
+ * sessions are its `session` lines of that attempt and the one before it; what they logged from
157
+ * its first operation's start, which keeps a fork's copy of its parent's rows out, until the
158
+ * attempt ended, the next claimed, or now, is shared out by when each operation started. A calling
159
+ * session isn't read: its turns before and after the run are the operator's, and nothing on record
160
+ * says where the run's ended. Bounded as a run's end is: past its grace, or once `signal` aborts,
161
+ * what isn't settled yet is left for a later attempt. What fails is returned, and left too.
162
+ */
163
+ export async function settleEarlier(
164
+ runDir: string,
165
+ options: {
166
+ /** The attempt settling them. */
167
+ attempt: number;
168
+ /** The claimed attempts before it. */
169
+ attempts: readonly ClaimedAttempt[];
170
+ /** `run.jsonl`'s lines, as `readRunLines` read them. */
171
+ lines: readonly RunLine[];
172
+ accounting: SessionAccounting | undefined;
173
+ now: number;
174
+ signal?: AbortSignal;
175
+ },
176
+ ): Promise<{ settled: number; failed: string[] }> {
177
+ const ops = await readOps(runDir, { decisions: false });
178
+ const agentOps = ops.filter(
179
+ (each): each is RecordedOp & { op: AgentOpRecord } => each.op.kind !== "decision",
180
+ );
181
+ const pending = agentOps.filter(
182
+ ({ op, spend }) => op.attempt < options.attempt && spend === undefined,
183
+ );
184
+ const failed: string[] = [];
185
+ let settled = 0;
186
+ if (pending.length === 0) return { settled, failed };
187
+ const { accounting } = options;
188
+ const deadline = {
189
+ unixMilliseconds:
190
+ options.now +
191
+ ACCOUNTING_GRACE_MILLISECONDS +
192
+ (accounting?.statusMs ?? 0) +
193
+ STATUS_SLACK_MILLISECONDS,
194
+ };
195
+ const bounded = <T>(call: () => Promise<T>): Promise<T | typeof STOPPED> =>
196
+ runUntilStopped(call, options.signal, deadline).catch(() => STOPPED);
197
+ const lines = unfenced(options.lines).filter(
198
+ (line): line is SessionLine => line.kind === "session",
199
+ );
200
+ const write = async (op: AgentOpRecord, record: SpendFields) => {
201
+ await writeSpend(runDir, op.id, { ...record, settledBy: options.attempt }).then(
202
+ (wrote) => {
203
+ if (wrote) settled += 1;
204
+ },
205
+ (error: unknown) => failed.push(`${op.id}: ${messageOf(error)}`),
206
+ );
207
+ };
208
+ for (const attempt of [...new Set(pending.map(({ op }) => op.attempt))].sort((a, b) => a - b)) {
209
+ const until = untilOf(attempt, options);
210
+ const own = agentOps
211
+ .filter(({ op }) => op.attempt === attempt)
212
+ .sort((a, b) => Date.parse(a.op.at) - Date.parse(b.op.at));
213
+ const claimed = new Set<string>();
214
+ for (const agent of [...new Set(own.map(({ op }) => op.agent))]) {
215
+ const theirs = own.filter(({ op }) => op.agent === agent);
216
+ const unsettled = theirs.filter(({ spend }) => spend === undefined);
217
+ // A settled agent isn't read again: the window keeps a fork's copy of its rows out.
218
+ if (unsettled.length === 0) continue;
219
+ if (theirs.some(({ op }) => op.execution.caller)) {
220
+ for (const { op } of unsettled) await write(op, { billing: "unknown" });
221
+ continue;
222
+ }
223
+ const from = Date.parse(theirs[0]!.op.at);
224
+ const read = accounting
225
+ ? await bounded(() => readAgent(agent, attempt, theirs, lines, accounting))
226
+ : undefined;
227
+ if (read === STOPPED) return { settled, failed };
228
+ const records = read?.filter((record) => {
229
+ const at = Date.parse(record.at);
230
+ const claim = `${record.harness}\u0000${record.key}`;
231
+ if (claimed.has(claim) || at < from || at > until) return false;
232
+ claimed.add(claim);
233
+ return true;
234
+ });
235
+ const shares = records && share(theirs, records);
236
+ const billings = new Map<string, Billing>();
237
+ for (const { op } of unsettled) {
238
+ const key = JSON.stringify(op.execution);
239
+ let billing = billings.get(key);
240
+ if (billing === undefined) {
241
+ const asked: Billing | typeof STOPPED = accounting
242
+ ? await bounded(() => accounting.billing(op.execution, records))
243
+ : "unknown";
244
+ if (asked === STOPPED) return { settled, failed };
245
+ billing = asked;
246
+ billings.set(key, billing);
247
+ }
248
+ await write(op, {
249
+ billing,
250
+ ...(shares ? { spend: spendOf(shares.get(op.id) ?? []) } : {}),
251
+ });
252
+ }
253
+ }
254
+ }
255
+ return { settled, failed };
256
+ }
257
+
258
+ type SessionLine = Extract<RunLine, { kind: "session" }>;
259
+
260
+ /**
261
+ * Until when an attempt's agents could have logged: it ended, the next attempt claimed, or now,
262
+ * whichever came first. Now is safe: nothing of the attempt settling them has opened yet.
263
+ */
264
+ function untilOf(
265
+ attempt: number,
266
+ options: { attempts: readonly ClaimedAttempt[]; now: number },
267
+ ): number {
268
+ const record = options.attempts.find((each) => each.attempt === attempt);
269
+ const next = options.attempts.find((each) => each.attempt > attempt);
270
+ return Math.min(
271
+ options.now,
272
+ ...(record?.ended ? [Date.parse(record.ended)] : []),
273
+ ...(next ? [Date.parse(next.started)] : []),
274
+ );
275
+ }
276
+
277
+ /**
278
+ * An agent's usage records in one attempt, read by its `session` lines with each of its
279
+ * operations' own settings and folder; undefined when none could be read. Each record carries the
280
+ * harness it was read for, which a request's key is unique within.
281
+ */
282
+ async function readAgent(
283
+ agent: string,
284
+ attempt: number,
285
+ ops: readonly { op: AgentOpRecord }[],
286
+ lines: readonly SessionLine[],
287
+ accounting: SessionAccounting,
288
+ ): Promise<(UsageRecord & { harness: string })[] | undefined> {
289
+ const found = new Map<string, UsageRecord & { harness: string }>();
290
+ let read = false;
291
+ const places = new Map(
292
+ ops.map(({ op }) => [JSON.stringify([op.execution, op.cwd]), op] as const),
293
+ );
294
+ for (const op of places.values()) {
295
+ const { harness } = op.execution;
296
+ const sessions = lines.filter(
297
+ (line) => line.agent === agent && line.harness === harness && line.attempt <= attempt,
298
+ );
299
+ const during = sessions.filter((line) => line.attempt === attempt);
300
+ // The session it was on as the attempt began, which a crash may have kept it from writing again.
301
+ const before = sessions.findLast((line) => line.attempt < attempt);
302
+ const seen = [...(before ? [before] : []), ...during];
303
+ const ids = [...new Set(seen.map((line) => line.session))];
304
+ const home = seen.findLast((line) => line.home !== undefined)?.home;
305
+ if (ids.length === 0 && home === undefined) continue;
306
+ const records = await accounting
307
+ .read(op.execution, ids, op.cwd, home)
308
+ .then((each) => each?.records)
309
+ .catch(() => undefined);
310
+ if (!records) continue;
311
+ read = true;
312
+ for (const record of records)
313
+ found.set(`${harness}\u0000${record.key}`, { ...record, harness });
314
+ }
315
+ return read ? [...found.values()] : undefined;
316
+ }
317
+
318
+ /**
319
+ * An operation owns what its agent logged from its start until the next one's; what came before
320
+ * the first is the first's, so a share never drops a record.
321
+ */
322
+ function share(
323
+ ops: readonly { op: AgentOpRecord }[],
324
+ records: readonly UsageRecord[],
325
+ ): Map<string, UsageRecord[]> {
326
+ const shares = new Map<string, UsageRecord[]>();
327
+ for (const record of records) {
328
+ const at = Date.parse(record.at);
329
+ const owner = (ops.findLast(({ op }) => Date.parse(op.at) <= at) ?? ops[0]!).op.id;
330
+ shares.set(owner, [...(shares.get(owner) ?? []), record]);
331
+ }
332
+ return shares;
333
+ }