@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,211 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { copyFile, mkdir, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { isRecord } from "@agentswf/contract";
7
+ import type { HarnessSandboxNeeds } from "@agentswf/sandbox";
8
+ import { messageOf } from "./errors";
9
+
10
+ /** A seeded home's credentials, and a way to hand a refreshed one back to the operator. */
11
+ export type SeededHome = {
12
+ /**
13
+ * Replaces the operator's credential with the home's when a turn refreshed it: only if the copy
14
+ * differs from what was seeded in the fields a refresh rewrites alone, so an account the file
15
+ * names stays the operator's, and only if the operator's file is still what was copied, so a
16
+ * login the operator made meanwhile is never overwritten. The written value becomes the new
17
+ * baseline; a copy refused is said once, as a rejection.
18
+ */
19
+ writeBack(): Promise<void>;
20
+ };
21
+
22
+ /** One run's write-backs, serialized per operator file: two agents may share a credential. */
23
+ export type CredentialLocks = Map<string, Promise<void>>;
24
+
25
+ /**
26
+ * Makes an agent's harness home, private to it, holding copies of its credentials and nothing
27
+ * else of the operator's (story 004, "A fresh harness home"). It is built in `staging`, which no
28
+ * agent writes, and moved into place whole: the directory `home` lands in is writable by the
29
+ * sandbox's agents, which could plant a link there for the engine to write through.
30
+ */
31
+ export async function seedHome(
32
+ home: string,
33
+ staging: string,
34
+ needs: Pick<HarnessSandboxNeeds, "seed" | "defaults">,
35
+ /** The agent's working directory, which a harness's first-run answers name. */
36
+ cwd: string,
37
+ locks: CredentialLocks,
38
+ /** Fills the home, staged at the path it is given, before it moves into place: its skills. */
39
+ populate?: (staged: string) => Promise<void>,
40
+ ): Promise<SeededHome> {
41
+ const staged = (path: string) => {
42
+ const inside = relative(home, path);
43
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
44
+ throw new Error(`${path} is not in the agent's home ${home}`);
45
+ }
46
+ return join(staging, inside);
47
+ };
48
+ await mkdir(dirname(staging), { recursive: true, mode: 0o700 });
49
+ await mkdir(staging, { mode: 0o700 });
50
+ const seeds: {
51
+ from: string;
52
+ to: string;
53
+ refreshes: string[][];
54
+ baseline: Buffer;
55
+ refused?: Buffer;
56
+ }[] = [];
57
+ try {
58
+ for (const { from, to, refreshes } of needs.seed) {
59
+ let bytes: Buffer;
60
+ try {
61
+ bytes = await readFile(from);
62
+ } catch {
63
+ throw new Error(`cannot seed ${to}: ${from} is missing; log the harness in first`);
64
+ }
65
+ await mkdir(dirname(staged(to)), { recursive: true, mode: 0o700 });
66
+ await writeFile(staged(to), bytes, { mode: 0o600 });
67
+ seeds.push({
68
+ from,
69
+ to,
70
+ refreshes: refreshes.map((field) => field.split(".")),
71
+ baseline: bytes,
72
+ });
73
+ }
74
+ for (const { path, contents } of needs.defaults(cwd)) {
75
+ await mkdir(dirname(staged(path)), { recursive: true, mode: 0o700 });
76
+ await writeFile(staged(path), contents, { mode: 0o600 });
77
+ }
78
+ await populate?.(staging);
79
+ await rename(staging, home);
80
+ } catch (error) {
81
+ // Its credentials go with it.
82
+ await rm(staging, { recursive: true, force: true });
83
+ throw error;
84
+ }
85
+ return {
86
+ async writeBack() {
87
+ // Every seed is tried: one refused leaves the others' refreshes to go back.
88
+ const failures: unknown[] = [];
89
+ for (const seed of seeds) {
90
+ await serialized(locks, seed.from, async () => {
91
+ const copied = await readOwnFile(seed.to);
92
+ if (!copied || copied.equals(seed.baseline) || seed.refused?.equals(copied)) return;
93
+ const beyond = beyondRefresh(parse(seed.baseline), parse(copied), seed.refreshes);
94
+ if (beyond !== undefined) {
95
+ // Said once for each such copy: if a refresh rotated the token, the operator's may
96
+ // stop working, and this is why.
97
+ seed.refused = copied;
98
+ throw new Error(
99
+ `${seed.to} changed at ${beyond || "its top"}, which no refresh does: not written back to ${seed.from}`,
100
+ );
101
+ }
102
+ const operator = await readFile(seed.from).catch(() => undefined);
103
+ if (!operator?.equals(seed.baseline)) return;
104
+ // Atomic, so the operator's harness never reads half a credential.
105
+ const temporary = join(dirname(seed.from), `.awf-${randomUUID()}.tmp`);
106
+ try {
107
+ await writeFile(temporary, copied, { mode: 0o600 });
108
+ await rename(temporary, seed.from);
109
+ } catch (error) {
110
+ await rm(temporary, { force: true });
111
+ throw error;
112
+ }
113
+ seed.baseline = copied;
114
+ }).catch((error: unknown) => failures.push(error));
115
+ }
116
+ if (failures.length === 1) throw failures[0];
117
+ if (failures.length > 1)
118
+ throw new AggregateError(failures, failures.map(messageOf).join("; "));
119
+ },
120
+ };
121
+ }
122
+
123
+ async function serialized(
124
+ locks: CredentialLocks,
125
+ key: string,
126
+ work: () => Promise<void>,
127
+ ): Promise<void> {
128
+ const previous = locks.get(key) ?? Promise.resolve();
129
+ const next = previous.then(work, work);
130
+ const settled = next.catch(() => undefined);
131
+ locks.set(key, settled);
132
+ await next;
133
+ }
134
+
135
+ /**
136
+ * A regular file's bytes, never a link's target: the agent owns its home, and a link there would
137
+ * have the engine read what it points at. One handle, so nothing swaps the file between checks.
138
+ */
139
+ async function readOwnFile(path: string): Promise<Buffer | undefined> {
140
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => undefined);
141
+ if (!handle) return undefined;
142
+ try {
143
+ if (!(await handle.stat()).isFile()) return undefined;
144
+ return await handle.readFile();
145
+ } finally {
146
+ await handle.close();
147
+ }
148
+ }
149
+
150
+ function parse(bytes: Buffer): unknown {
151
+ try {
152
+ return JSON.parse(bytes.toString("utf8"));
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Where `after` differs from `before` beyond the fields a refresh rewrites, as a dotted path, or
160
+ * undefined where it does not: at a path `refreshes` names, a scalar may change, appear or go;
161
+ * everywhere else the two are equal. Undefined JSON differs at its top.
162
+ */
163
+ function beyondRefresh(
164
+ before: unknown,
165
+ after: unknown,
166
+ refreshes: readonly (readonly string[])[],
167
+ at: readonly string[] = [],
168
+ ): string | undefined {
169
+ const here = at.join(".");
170
+ if (before === undefined || after === undefined) return here;
171
+ if (!isRecord(before) || !isRecord(after)) {
172
+ return isDeepStrictEqual(before, after) ? undefined : here;
173
+ }
174
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
175
+ for (const key of keys) {
176
+ const path = [...at, key];
177
+ const own = { before: Object.hasOwn(before, key), after: Object.hasOwn(after, key) };
178
+ const refreshed = refreshes.some(
179
+ (field) =>
180
+ field.length === path.length &&
181
+ field.every((part, index) => part === "*" || part === path[index]),
182
+ );
183
+ if (refreshed) {
184
+ if (!isScalar(own.before ? before[key] : undefined)) return path.join(".");
185
+ if (!isScalar(own.after ? after[key] : undefined)) return path.join(".");
186
+ continue;
187
+ }
188
+ if (!own.before || !own.after) return path.join(".");
189
+ const beyond = beyondRefresh(before[key], after[key], refreshes, path);
190
+ if (beyond !== undefined) return beyond;
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ function isScalar(value: unknown): boolean {
196
+ return value === undefined || value === null || ["string", "number"].includes(typeof value);
197
+ }
198
+
199
+ /**
200
+ * Places a fork's carried session files in a staged home, each at its path, and never over a file
201
+ * the home was seeded with: a session's files are only its own.
202
+ */
203
+ export async function placeCarried(carry: string, staged: string): Promise<void> {
204
+ for (const entry of await readdir(carry, { recursive: true, withFileTypes: true })) {
205
+ if (!entry.isFile()) continue;
206
+ const from = join(entry.parentPath, entry.name);
207
+ const to = join(staged, relative(carry, from));
208
+ await mkdir(dirname(to), { recursive: true, mode: 0o700 });
209
+ await copyFile(from, to, constants.COPYFILE_EXCL);
210
+ }
211
+ }