@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,240 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import manifest from "../package.json" with { type: "json" };
4
+
5
+ export const initUsage = [
6
+ "usage: awf init [dir] [--no-install]",
7
+ "",
8
+ "Sets up the current directory, or dir, for writing workflows: hello/workflow.ts, a workflow",
9
+ "with one agent, and hello/workflow.test.ts, its test; package.json, tsconfig.json, .gitignore",
10
+ "and AGENTS.md. Then runs bun install, unless --no-install, for editor types: awf runs a",
11
+ "workflow against its own copies either way.",
12
+ "",
13
+ "Never overwrites. A file that exists is skipped, except that package.json gains the",
14
+ "devDependencies and scripts it lacks and .gitignore the lines it lacks.",
15
+ ].join("\n");
16
+
17
+ export type InitCommand = { dir: string; install: boolean };
18
+
19
+ export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand | "help" {
20
+ let dir: string | undefined;
21
+ let install = true;
22
+ for (const option of argv) {
23
+ if (option === "-h" || option === "--help") return "help";
24
+ if (option === "--no-install") install = false;
25
+ else if (option.startsWith("-")) throw new Error(`unknown option: ${option}`);
26
+ else if (dir !== undefined) throw new Error(`one directory only, not ${dir} and ${option}`);
27
+ else dir = option;
28
+ }
29
+ const path = resolve(cwd, dir ?? ".");
30
+ if (existsSync(path) && !statSync(path).isDirectory()) {
31
+ throw new Error(`not a directory: ${dir}`);
32
+ }
33
+ return { dir: path, install };
34
+ }
35
+
36
+ const TEMPLATES = join(import.meta.dir, "init-template");
37
+
38
+ /** Copied as they are, each only where nothing is yet. */
39
+ const COPIED = ["hello/workflow.ts", "hello/workflow.test.ts", "tsconfig.json", "AGENTS.md"];
40
+
41
+ const SCRIPTS = { test: "awf test", check: "tsc --noEmit" };
42
+
43
+ // The versions this repository builds with; the root's catalog holds the same.
44
+ export const DEV_DEPENDENCIES = {
45
+ "@types/bun": "^1.3.4",
46
+ // agentswf is released at the engine's version; it can't be imported here, it imports the engine.
47
+ agentswf: `^${manifest.version}`,
48
+ typebox: manifest.dependencies.typebox,
49
+ typescript: "^5.9.3",
50
+ };
51
+
52
+ const IGNORED = ["node_modules/", ".awf/"];
53
+
54
+ type Change = { path: string; change: "created" | "updated" | "skipped"; detail?: string };
55
+
56
+ /** A target init may write: absent, a file it reads or skips, or a symlink it never follows. */
57
+ type Target = "missing" | "file" | "symlink";
58
+
59
+ function targetOf(dir: string, path: string): Target {
60
+ const stat = lstatSync(join(dir, path), { throwIfNoEntry: false });
61
+ if (stat === undefined) return "missing";
62
+ if (stat.isSymbolicLink()) return "symlink";
63
+ if (!stat.isFile()) throw new Error(`${path} exists and is not a file`);
64
+ return "file";
65
+ }
66
+
67
+ /**
68
+ * Writes what `awf init` writes into `dir`, and says what it did to each file. Everything that
69
+ * would stop it, such as a package.json that isn't a JSON object, is found before it writes.
70
+ */
71
+ export function initFolder(dir: string): Change[] {
72
+ for (const parent of new Set(COPIED.map(dirname))) {
73
+ const stat = lstatSync(join(dir, parent), { throwIfNoEntry: false });
74
+ if (stat !== undefined && !stat.isDirectory()) {
75
+ throw new Error(`${parent} exists and is not a directory`);
76
+ }
77
+ }
78
+ const targets = new Map(
79
+ ["package.json", ".gitignore", ...COPIED].map((path) => [path, targetOf(dir, path)]),
80
+ );
81
+ const pkg = targets.get("package.json") === "file" ? readPackageJson(dir) : undefined;
82
+
83
+ const changes: Change[] = [packageJson(dir, targets.get("package.json")!, pkg)];
84
+ for (const path of COPIED) {
85
+ const target = targets.get(path)!;
86
+ if (target !== "missing") {
87
+ changes.push({ path, change: "skipped", detail: skippedBecause(target) });
88
+ continue;
89
+ }
90
+ mkdirSync(join(dir, dirname(path)), { recursive: true });
91
+ const template = readFileSync(join(TEMPLATES, `${path}.template`));
92
+ writeFileSync(join(dir, path), template, { flag: "wx" });
93
+ changes.push({ path, change: "created" });
94
+ }
95
+ changes.push(gitignore(dir, targets.get(".gitignore")!));
96
+ return changes;
97
+ }
98
+
99
+ const skippedBecause = (target: Target) => (target === "symlink" ? "a symlink" : "exists");
100
+
101
+ function readPackageJson(dir: string): { text: string; value: Record<string, unknown> } {
102
+ const text = readFileSync(join(dir, "package.json"), "utf8");
103
+ let value: unknown;
104
+ try {
105
+ value = JSON.parse(text);
106
+ } catch (error) {
107
+ throw new Error(`package.json is not JSON: ${(error as Error).message}`);
108
+ }
109
+ if (!isRecord(value)) throw new Error("package.json is not a JSON object");
110
+ return { text, value };
111
+ }
112
+
113
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
114
+ typeof value === "object" && value !== null && !Array.isArray(value);
115
+
116
+ function packageJson(
117
+ dir: string,
118
+ target: Target,
119
+ pkg: { text: string; value: Record<string, unknown> } | undefined,
120
+ ): Change {
121
+ const path = join(dir, "package.json");
122
+ if (target === "symlink") return { path: "package.json", change: "skipped", detail: "a symlink" };
123
+ if (pkg === undefined) {
124
+ const created = {
125
+ name: packageName(dir),
126
+ private: true,
127
+ type: "module",
128
+ scripts: SCRIPTS,
129
+ devDependencies: DEV_DEPENDENCIES,
130
+ };
131
+ writeFileSync(path, `${JSON.stringify(created, null, 2)}\n`, { flag: "wx" });
132
+ return { path: "package.json", change: "created" };
133
+ }
134
+ const { text, value: existing } = pkg;
135
+ const record = (key: string): Record<string, unknown> => {
136
+ const value = existing[key];
137
+ return isRecord(value) ? value : {};
138
+ };
139
+ const scripts = record("scripts");
140
+ const declared = {
141
+ ...record("dependencies"),
142
+ ...record("devDependencies"),
143
+ ...record("peerDependencies"),
144
+ ...record("optionalDependencies"),
145
+ };
146
+ const addedScripts = Object.entries(SCRIPTS).filter(([name]) => !(name in scripts));
147
+ const addedDependencies = Object.entries(DEV_DEPENDENCIES).filter(
148
+ ([name]) => !(name in declared),
149
+ );
150
+ if (addedScripts.length === 0 && addedDependencies.length === 0) {
151
+ return { path: "package.json", change: "skipped", detail: "has what init adds" };
152
+ }
153
+ const updated = {
154
+ ...existing,
155
+ scripts: { ...scripts, ...Object.fromEntries(addedScripts) },
156
+ devDependencies: { ...record("devDependencies"), ...Object.fromEntries(addedDependencies) },
157
+ };
158
+ const json = `${JSON.stringify(updated, null, indentOf(text))}\n`;
159
+ writeFileSync(path, text.includes("\r\n") ? json.replaceAll("\n", "\r\n") : json);
160
+ const added = [
161
+ ...addedDependencies.map(([name]) => name),
162
+ ...addedScripts.map(([name]) => `the ${name} script`),
163
+ ];
164
+ return { path: "package.json", change: "updated", detail: `added ${added.join(", ")}` };
165
+ }
166
+
167
+ function gitignore(dir: string, target: Target): Change {
168
+ const path = join(dir, ".gitignore");
169
+ if (target === "symlink") return { path: ".gitignore", change: "skipped", detail: "a symlink" };
170
+ if (target === "missing") {
171
+ writeFileSync(path, `${IGNORED.join("\n")}\n`, { flag: "wx" });
172
+ return { path: ".gitignore", change: "created" };
173
+ }
174
+ const text = readFileSync(path, "utf8");
175
+ const lines = new Set(text.split("\n").map((line) => line.trim().replace(/^\/|\/$/g, "")));
176
+ const missing = IGNORED.filter((line) => !lines.has(line.replace(/\/$/, "")));
177
+ if (missing.length === 0) return { path: ".gitignore", change: "skipped", detail: "has both" };
178
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
179
+ const separator = text === "" || text.endsWith("\n") ? "" : newline;
180
+ writeFileSync(path, `${text}${separator}${missing.join(newline)}${newline}`);
181
+ return { path: ".gitignore", change: "updated", detail: `added ${missing.join(", ")}` };
182
+ }
183
+
184
+ /** A valid npm name from the folder's, which is all a private package's name has to be. */
185
+ function packageName(dir: string): string {
186
+ const name = basename(dir)
187
+ .toLowerCase()
188
+ .replace(/[^a-z0-9._-]+/g, "-")
189
+ .slice(0, 214)
190
+ .replace(/^[._-]+|-+$/g, "");
191
+ return name || "workflows";
192
+ }
193
+
194
+ function indentOf(text: string): string | number {
195
+ return /^\{\s*\r?\n([ \t]+)"/.exec(text)?.[1] ?? 2;
196
+ }
197
+
198
+ /** As a shell reads it: bare when that's safe, otherwise single-quoted. */
199
+ function shellWord(word: string): string {
200
+ return /^[\w./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
201
+ }
202
+
203
+ /** `awf init`: writes the folder, installs unless told not to, and says what to run next. */
204
+ export async function runInit(
205
+ command: InitCommand,
206
+ cwd: string,
207
+ output: { stdout(text: string): void; stderr(text: string): void },
208
+ install: (dir: string) => Promise<number> = bunInstall,
209
+ ): Promise<number> {
210
+ mkdirSync(command.dir, { recursive: true });
211
+ const changes = initFolder(command.dir);
212
+ const installed = command.install ? (await install(command.dir)) === 0 : undefined;
213
+ const width = Math.max(...changes.map(({ change }) => change.length));
214
+ const lines = changes.map(
215
+ ({ path, change, detail }) =>
216
+ `${change.padEnd(width)} ${path}${detail === undefined ? "" : ` (${detail})`}`,
217
+ );
218
+ const into = relative(cwd, command.dir);
219
+ const next = [
220
+ ...(into === "" ? [] : [`cd ${shellWord(into)}`]),
221
+ ...(installed ? [] : ["bun install"]),
222
+ "awf test # scripted agents: free, no agent starts",
223
+ "awf run hello/workflow.ts # one real codex agent",
224
+ ];
225
+ output.stdout([...lines, "", "next:", ...next.map((line) => ` ${line}`)].join("\n"));
226
+ if (installed === false) {
227
+ output.stderr("awf: bun install failed; the files are written, run it again there");
228
+ return 1;
229
+ }
230
+ return 0;
231
+ }
232
+
233
+ async function bunInstall(dir: string): Promise<number> {
234
+ const child = Bun.spawn([process.execPath, "install"], {
235
+ cwd: dir,
236
+ stdout: "inherit",
237
+ stderr: "inherit",
238
+ });
239
+ return child.exited;
240
+ }
@@ -0,0 +1,16 @@
1
+ # Workflows
2
+
3
+ This folder holds [agents.wf](https://github.com/romanlv/agentswf) workflows: TypeScript files
4
+ that run coding agents, started with `awf`.
5
+
6
+ - A workflow is a folder with a `workflow.ts`, which default-exports
7
+ `defineExecutableWorkflow(…)` from `agentswf/workflow`, and a `workflow.test.ts` beside it.
8
+ `hello/` is the smallest one; copy it to start another.
9
+ - The API, with every option and outcome:
10
+ https://github.com/romanlv/agentswf/blob/main/docs/workflow-api.md
11
+ - `awf test` runs the tests with each agent replaced by scripted answers: no agent starts, and it
12
+ costs nothing. After a change, run it and `bun run check`, which typechecks.
13
+ - `awf run hello/workflow.ts` starts real agents, on the operator's accounts. Don't run a workflow
14
+ unless asked.
15
+ - Each run is kept in `.awf/runs/{workflow}/{id}`. `awf runs` lists them; `awf logs {run}` shows
16
+ what one did.
@@ -0,0 +1,17 @@
1
+ import { expect, test } from "bun:test";
2
+ import { answer, reply, testWorkflow } from "agentswf/testing";
3
+ import hello, { ANSWER } from "./workflow";
4
+
5
+ test("returns the agent's answer", async () => {
6
+ const run = await testWorkflow(hello, {}, {
7
+ agents: { hello: answer(ANSWER, { answer: 391 }) },
8
+ });
9
+ expect(run.value).toEqual({ answer: 391 });
10
+ expect(run.turnsOf("hello")[0]?.prompt).toContain("17 multiplied by 23");
11
+ });
12
+
13
+ test("an agent that never answers is nudged once, then the result says why", async () => {
14
+ const run = await testWorkflow(hello, {}, { agents: { hello: reply.silent() } });
15
+ expect(run.value).toHaveProperty("unanswered");
16
+ expect(run.turnsOf("hello").map((turn) => turn.nudge)).toEqual([false, true]);
17
+ });
@@ -0,0 +1,26 @@
1
+ import { defineExecutableWorkflow, isAnswered } from "agentswf/workflow";
2
+ import Type from "typebox";
3
+
4
+ export const ANSWER = Type.Object({ answer: Type.Integer() }, { additionalProperties: false });
5
+
6
+ export default defineExecutableWorkflow({
7
+ definition: {
8
+ meta: { name: "hello", description: "Ask one agent a question with a known answer." },
9
+ async run(workflow) {
10
+ // A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
11
+ // account has: "claude" in a pane, the default placement, runs on a subscription.
12
+ const agent = await workflow.agents.open({
13
+ key: "hello",
14
+ runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
15
+ });
16
+ const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
17
+ prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
18
+ schema: ANSWER,
19
+ timeoutMs: 3 * 60_000,
20
+ });
21
+ return isAnswered(outcome) ? outcome.value : { unanswered: outcome.reason };
22
+ },
23
+ },
24
+ // Turns the command line into args: `awf run hello/workflow.ts -- ...`.
25
+ prepare: () => ({}),
26
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "noEmit": true,
5
+ "module": "Preserve",
6
+ "moduleResolution": "Bundler",
7
+ "target": "ESNext",
8
+ "types": ["bun"]
9
+ },
10
+ "exclude": ["node_modules", ".awf"]
11
+ }
package/src/jsonl.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { open, readFile } from "node:fs/promises";
2
+ import { isCode } from "./files";
3
+
4
+ const appendQueues = new Map<string, Promise<void>>();
5
+
6
+ export async function appendLine(path: string, line: string): Promise<void> {
7
+ const previous = appendQueues.get(path) ?? Promise.resolve();
8
+ const current = previous.catch(() => undefined).then(() => appendCompleteLine(path, line));
9
+ appendQueues.set(path, current);
10
+ try {
11
+ await current;
12
+ } finally {
13
+ if (appendQueues.get(path) === current) appendQueues.delete(path);
14
+ }
15
+ }
16
+
17
+ async function appendCompleteLine(path: string, line: string): Promise<void> {
18
+ const handle = await open(path, "a");
19
+ try {
20
+ await writeAll(handle, new TextEncoder().encode(`${line}\n`));
21
+ } finally {
22
+ await handle.close();
23
+ }
24
+ }
25
+
26
+ type AppendWriter = {
27
+ write(bytes: Uint8Array, offset: number, length: number): Promise<{ bytesWritten: number }>;
28
+ };
29
+
30
+ export async function writeAll(handle: AppendWriter, bytes: Uint8Array): Promise<void> {
31
+ let offset = 0;
32
+ while (offset < bytes.byteLength) {
33
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset);
34
+ if (bytesWritten === 0) throw new Error("append made no progress");
35
+ offset += bytesWritten;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Each line that parses as a JSON object, in order. One that doesn't is skipped wherever it is: a
41
+ * crash can tear the last, and a later writer appends after it.
42
+ */
43
+ export async function readLines<T extends object>(path: string): Promise<T[]> {
44
+ const text = await readFile(path, "utf8").catch((error) => {
45
+ if (isCode(error, "ENOENT")) return "";
46
+ throw error;
47
+ });
48
+ return text.split("\n").flatMap((line) => {
49
+ try {
50
+ const value: unknown = JSON.parse(line);
51
+ return typeof value === "object" && value !== null ? [value as T] : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ });
56
+ }
57
+
58
+ /** Ends a line a crash left torn, so the next line appended starts a line of its own. */
59
+ export async function endTornLine(path: string): Promise<void> {
60
+ const handle = await open(path, "r").catch((error) => {
61
+ if (isCode(error, "ENOENT")) return undefined;
62
+ throw error;
63
+ });
64
+ if (!handle) return;
65
+ let torn: boolean;
66
+ try {
67
+ const { size } = await handle.stat();
68
+ const last = Buffer.alloc(1);
69
+ torn = size > 0 && (await handle.read(last, 0, 1, size - 1)).bytesRead === 1 && last[0] !== 10;
70
+ } finally {
71
+ await handle.close();
72
+ }
73
+ if (torn) await appendLine(path, "");
74
+ }