@agentswf/engine 0.1.0-beta.1 → 0.1.0-beta.3

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 (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -1,6 +1,8 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import manifest from "../package.json" with { type: "json" };
4
+ import { messageOf } from "./errors";
5
+ import type { Out } from "./run-commands";
4
6
 
5
7
  export const initUsage = [
6
8
  "usage: awf init [dir] [--no-install]",
@@ -16,6 +18,31 @@ export const initUsage = [
16
18
 
17
19
  export type InitCommand = { dir: string; install: boolean };
18
20
 
21
+ /** `awf init`: a folder set up for writing workflows. */
22
+ export async function initCommand(
23
+ argv: readonly string[],
24
+ cwd: string,
25
+ io: Out & { install?: (dir: string) => Promise<number> },
26
+ ): Promise<number> {
27
+ let command: InitCommand | "help";
28
+ try {
29
+ command = parseInitCommand(argv, cwd);
30
+ } catch (error) {
31
+ io.stderr(`awf: ${messageOf(error)}\n\n${initUsage}`);
32
+ return 2;
33
+ }
34
+ if (command === "help") {
35
+ io.stdout(initUsage);
36
+ return 0;
37
+ }
38
+ try {
39
+ return await runInit(command, cwd, io, io.install);
40
+ } catch (error) {
41
+ io.stderr(`awf: ${messageOf(error)}`);
42
+ return 1;
43
+ }
44
+ }
45
+
19
46
  export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand | "help" {
20
47
  let dir: string | undefined;
21
48
  let install = true;
@@ -4,7 +4,7 @@ This folder holds [agents.wf](https://github.com/romanlv/agentswf) workflows: Ty
4
4
  that run coding agents, started with `awf`.
5
5
 
6
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.
7
+ `defineWorkflow(…)` from `agentswf/workflow`, and a `workflow.test.ts` beside it.
8
8
  `hello/` is the smallest one; copy it to start another.
9
9
  - The API, with every option and outcome:
10
10
  https://github.com/romanlv/agentswf/blob/main/docs/workflow-api.md
@@ -3,7 +3,7 @@ import { answer, reply, testWorkflow } from "agentswf/testing";
3
3
  import hello, { ANSWER } from "./workflow";
4
4
 
5
5
  test("returns the agent's answer", async () => {
6
- const run = await testWorkflow(hello, {}, {
6
+ const run = await testWorkflow(hello, null, {
7
7
  agents: { hello: answer(ANSWER, { answer: 391 }) },
8
8
  });
9
9
  expect(run.value).toEqual({ answer: 391 });
@@ -11,7 +11,7 @@ test("returns the agent's answer", async () => {
11
11
  });
12
12
 
13
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() } });
14
+ const run = await testWorkflow(hello, null, { agents: { hello: reply.silent() } });
15
15
  expect(run.value).toHaveProperty("unanswered");
16
16
  expect(run.turnsOf("hello").map((turn) => turn.nudge)).toEqual([false, true]);
17
17
  });
@@ -1,26 +1,22 @@
1
- import { defineExecutableWorkflow, isAnswered } from "agentswf/workflow";
1
+ import { defineWorkflow, isAnswered } from "agentswf/workflow";
2
2
  import Type from "typebox";
3
3
 
4
4
  export const ANSWER = Type.Object({ answer: Type.Integer() }, { additionalProperties: false });
5
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
- },
6
+ export default defineWorkflow({
7
+ meta: { name: "hello", description: "Ask one agent a question with a known answer." },
8
+ async run(workflow) {
9
+ // A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
10
+ // account has: "claude" in a pane, the default placement, runs on a subscription.
11
+ const agent = await workflow.agents.open({
12
+ key: "hello",
13
+ runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
14
+ });
15
+ const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
16
+ prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
17
+ schema: ANSWER,
18
+ timeoutMs: 3 * 60_000,
19
+ });
20
+ return isAnswered(outcome) ? outcome.value : { unanswered: outcome.reason };
23
21
  },
24
- // Turns the command line into args: `awf run hello/workflow.ts -- ...`.
25
- prepare: () => ({}),
26
22
  });
@@ -0,0 +1,40 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { EventAgentOpenSpec } from "@agentswf/contract/workflow";
3
+
4
+ export type KeepAlive = NonNullable<EventAgentOpenSpec["keepAlive"]>;
5
+
6
+ /** Claude's prompt cache lives an hour for the main conversation on a subscription. */
7
+ export const DEFAULT_WINDOW_MS = 60 * 60_000;
8
+
9
+ /**
10
+ * Set while an `onTimeout` handler runs: a turn begun in it opens no window, so a wrap-up can't keep
11
+ * its own agent live. A message turn opens one whatever the context: it is the peer's.
12
+ */
13
+ const inHandler = new AsyncLocalStorage<boolean>();
14
+
15
+ export function inTimeoutHandler(): boolean {
16
+ return inHandler.getStore() === true;
17
+ }
18
+
19
+ export function asTimeoutHandler<T>(fn: () => T): T {
20
+ return inHandler.run(true, fn);
21
+ }
22
+
23
+ /** `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can. */
24
+ export function checkedKeepAlive(key: string, given: unknown): KeepAlive {
25
+ if (typeof given !== "object" || given === null)
26
+ throw new Error(`agent ${key}: keepAlive is { timeoutMs?, onTimeout? }`);
27
+ const { timeoutMs, onTimeout } = given as Record<string, unknown>;
28
+ if (
29
+ timeoutMs !== undefined &&
30
+ (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
31
+ )
32
+ throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
33
+ if (onTimeout !== undefined && typeof onTimeout !== "function")
34
+ throw new Error(`agent ${key}: keepAlive's onTimeout is a function`);
35
+ // A copy: a later change to the caller's object would skip this check.
36
+ return {
37
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
38
+ ...(onTimeout === undefined ? {} : { onTimeout: onTimeout as KeepAlive["onTimeout"] }),
39
+ } as KeepAlive;
40
+ }
@@ -1,4 +1,7 @@
1
+ import { homedir } from "node:os";
1
2
  import type { LogEventJson, LogsJson, RouteEnds, RunLogLine } from "@agentswf/contract/records";
3
+ import type { JsonObject } from "@agentswf/contract/workflow";
4
+ import { duration } from "./duration";
2
5
  import { messageOf } from "./errors";
3
6
  import {
4
7
  clockTime,
@@ -28,8 +31,8 @@ import {
28
31
  readRun,
29
32
  timeOf,
30
33
  } from "./run-read";
31
- import { findRun, type RunStatus } from "./run-state";
32
- import { describeActivity, duration } from "./run-view";
34
+ import { findRun, findRunByRef, type RunStatus } from "./run-state";
35
+ import { describeActivity } from "./run-view";
33
36
 
34
37
  export const logsUsage = [
35
38
  "usage: awf logs [<run>] [@<handle>] [--agent <key>] [--stage <name>] [--after <cursor>]",
@@ -82,6 +85,7 @@ export async function logsCommand(
82
85
  shellCwd: string,
83
86
  out: Out,
84
87
  clock: LogsClock = REAL_CLOCK,
88
+ home: string = homedir(),
85
89
  ): Promise<number> {
86
90
  if (argv.includes("-h") || argv.includes("--help")) {
87
91
  out.stdout(logsUsage);
@@ -101,7 +105,10 @@ export async function logsCommand(
101
105
  return 2;
102
106
  }
103
107
  try {
104
- const { status, alsoRunning } = await findRun(place.runRoot, options.ref);
108
+ const { status, alsoRunning } =
109
+ options.ref === undefined
110
+ ? await findRun(place.runRoot, undefined)
111
+ : { status: await findRunByRef(options.ref, place, home), alsoRunning: [] };
105
112
  const again = shellLine(command(place, "awf", "logs", status.ref));
106
113
  let after: Cursor | undefined;
107
114
  if (options.after !== undefined) {
@@ -200,20 +207,31 @@ class Reader {
200
207
  });
201
208
  }
202
209
 
203
- async refresh(runRoot: string): Promise<RunStatus> {
204
- this.status = (await findRun(runRoot, this.status.ref)).status;
210
+ /** The run read again where it was found, which may be another project's. */
211
+ async refresh(): Promise<RunStatus> {
212
+ this.status = (await findRun(this.status.run.root, this.status.ref)).status;
205
213
  return this.status;
206
214
  }
207
215
  }
208
216
 
209
217
  type Read = Awaited<ReturnType<Reader["next"]>>;
210
218
 
211
- /** Each operation's stage and agent, from the lines that name them. */
212
- type Places = { stage: Map<string, string>; agent: Map<string, string> };
219
+ /** Where each operation ran, and each tool call's input, by its id, so its end shows it. */
220
+ type Places = {
221
+ stage: Map<string, string>;
222
+ agent: Map<string, string>;
223
+ calls: Map<string, { input: JsonObject; ended: boolean }>;
224
+ };
213
225
 
214
226
  function operationPlaces(lines: readonly { line: RunLogLine }[]): Places {
215
- const places: Places = { stage: new Map(), agent: new Map() };
227
+ const places: Places = { stage: new Map(), agent: new Map(), calls: new Map() };
216
228
  for (const { line } of lines) {
229
+ if (line.kind === "tool-called")
230
+ places.calls.set(line.call, { input: line.input, ended: false });
231
+ if (line.kind === "tool-ended") {
232
+ const call = places.calls.get(line.call);
233
+ if (call) call.ended = true;
234
+ }
217
235
  if (!("operationId" in line) || !line.operationId) continue;
218
236
  if ("stage" in line && line.stage) places.stage.set(line.operationId, line.stage);
219
237
  if ("agent" in line && line.agent) places.agent.set(line.operationId, line.agent);
@@ -257,10 +275,17 @@ function sidesOf(event: ReadEvent): readonly string[] {
257
275
  }
258
276
  }
259
277
 
260
- function kept(read: Read, options: LogsOptions) {
278
+ function kept(read: Read, options: LogsOptions, json = false) {
261
279
  const stages = operationPlaces(read.log.lines);
262
280
  const events = read.events.filter(
263
281
  (event) =>
282
+ // A call that has ended shows on its end's line, with its input; `--json` keeps both.
283
+ !(
284
+ !json &&
285
+ event.source !== "activity" &&
286
+ event.line.kind === "tool-called" &&
287
+ stages.calls.get(event.line.call)?.ended
288
+ ) &&
264
289
  (options.agent === undefined ||
265
290
  agentOf(event, stages) === options.agent ||
266
291
  sidesOf(event).includes(options.agent)) &&
@@ -290,7 +315,7 @@ async function page(
290
315
  clock: LogsClock,
291
316
  ): Promise<number> {
292
317
  let read = await reader.next();
293
- let { events, stages } = kept(read, options);
318
+ let { events, stages } = kept(read, options, json);
294
319
  const deadline = clock.now() + options.waitSeconds * 1000;
295
320
  let waited = false;
296
321
  // Without a cursor, a wait reads on from its first read: nothing before it is kept.
@@ -305,14 +330,14 @@ async function page(
305
330
  waited = true;
306
331
  if (!reader.from) from = read.cursor;
307
332
  await clock.sleep(POLL_MS);
308
- await reader.refresh(place.runRoot);
333
+ await reader.refresh();
309
334
  read = await reader.next(from);
310
- ({ events, stages } = kept(read, options));
335
+ ({ events, stages } = kept(read, options, json));
311
336
  if (events.length > 0) {
312
337
  // A burst comes back as one page.
313
338
  await clock.sleep(SETTLE_MS);
314
339
  read = await reader.next(from);
315
- ({ events, stages } = kept(read, options));
340
+ ({ events, stages } = kept(read, options, json));
316
341
  }
317
342
  }
318
343
  const status = reader.status;
@@ -417,7 +442,7 @@ async function follow(
417
442
  if (reader.status.state !== "running") break;
418
443
  await clock.sleep(POLL_MS);
419
444
  }
420
- await reader.refresh(place.runRoot);
445
+ await reader.refresh();
421
446
  read = await reader.next(cursor);
422
447
  }
423
448
  out.stdout(trailer(reader.status, read, place, undefined, undefined, undefined));
@@ -532,11 +557,13 @@ function describeEvent(event: ReadEvent, stages: Places): string {
532
557
  const agent = agentOf(event, stages);
533
558
  const where = [stageOf(event, stages), agent && said(agent, 60)].filter(Boolean).join(" ");
534
559
  const what =
535
- event.source === "activity" ? describeActivity(event.activity) : describeLine(event.line);
560
+ event.source === "activity"
561
+ ? describeActivity(event.activity)
562
+ : describeLine(event.line, stages);
536
563
  return `@${event.handle} ${clockTime(event.at)} ${where ? `${where} ` : ""}${what}`;
537
564
  }
538
565
 
539
- function describeLine(line: RunLogLine): string {
566
+ function describeLine(line: RunLogLine, places: Places): string {
540
567
  const operation = (of: "turn" | "compact") => (of === "compact" ? "compaction" : "turn");
541
568
  switch (line.kind) {
542
569
  case "attempt-started":
@@ -576,15 +603,21 @@ function describeLine(line: RunLogLine): string {
576
603
  case "decision-ended":
577
604
  return `decision ${line.decision} ${line.outcome}${line.error ? ` ${said(line.error)}` : ""}`;
578
605
  case "route-opened":
579
- return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages`;
606
+ return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages${
607
+ line.timeoutMs === undefined ? "" : `, a question closes after ${duration(line.timeoutMs)}`
608
+ }`;
580
609
  case "route-closed":
581
610
  return `route ${routeEnds(line)} closed ${line.reason}`;
582
611
  case "message-sent":
583
612
  return `message ${line.message} ${said(line.from, 60)} → ${said(line.to, 60)} ${line.bytes} B${
584
- line.replyTo === undefined ? "" : `, reply to ${line.replyTo}`
585
- }${
613
+ line.via === undefined ? "" : ` via ${line.via}`
614
+ }${line.replyTo === undefined ? "" : `, reply to ${line.replyTo}`}${
586
615
  line.awaitsReply
587
- ? `, reply owed ${said(line.from, 60)} awaits it until ${clockTime(Date.parse(line.awaitsReply.until))}`
616
+ ? `, reply owed ${said(line.from, 60)} awaits it${
617
+ line.awaitsReply.until
618
+ ? ` until ${clockTime(Date.parse(line.awaitsReply.until))}`
619
+ : ""
620
+ }`
588
621
  : ""
589
622
  }`;
590
623
  case "message-refused":
@@ -595,9 +628,60 @@ function describeLine(line: RunLogLine): string {
595
628
  return `message ${line.message} undelivered ${line.reason}`;
596
629
  case "request-ended":
597
630
  return `request ${line.message} ${line.end}${line.reason ? ` ${said(line.reason)}` : ""}`;
631
+ case "participant-connected":
632
+ return `${said(line.participant, 60)} connected${line.to === "code" ? " to the code" : ""}`;
633
+ case "participant-post":
634
+ return `run → ${said(line.participant, 60)} ${said(line.text)}`;
635
+ // A call is one line, its end's: what it was given, how it ended, and what it said back.
636
+ // Shown alone only while its end isn't in the log: still running, or cut short by a crash.
637
+ case "tool-called":
638
+ return toolLine(
639
+ line.tool,
640
+ line.input,
641
+ places.calls.get(line.call)?.ended ? "called" : "no end yet",
642
+ );
643
+ case "tool-ended": {
644
+ const answered =
645
+ line.outcome !== "ok"
646
+ ? line.error
647
+ : typeof line.result === "string"
648
+ ? `→ ${line.result}`
649
+ : line.result === undefined
650
+ ? undefined
651
+ : `→ ${JSON.stringify(line.result)}`;
652
+ return toolLine(
653
+ line.tool,
654
+ places.calls.get(line.call)?.input,
655
+ // Most calls take under a second; a duration says something only past one.
656
+ line.durationMs < 1000 ? line.outcome : `${line.outcome} ${duration(line.durationMs)}`,
657
+ answered,
658
+ );
659
+ }
660
+ case "tool-refused":
661
+ return [
662
+ `tool call ${said(line.tool, 60)}`,
663
+ "refused",
664
+ line.code,
665
+ line.error && said(line.error),
666
+ ]
667
+ .filter(Boolean)
668
+ .join(" ");
598
669
  }
599
670
  }
600
671
 
672
+ /** `tool call close_item {"id":"a1"} ok → Closed a1.`, apart from the harness's own tools. */
673
+ function toolLine(
674
+ tool: string,
675
+ input: JsonObject | undefined,
676
+ how: string,
677
+ answered?: string,
678
+ ): string {
679
+ const given = input && Object.keys(input).length > 0 ? ` ${said(JSON.stringify(input), 80)}` : "";
680
+ return [`tool call ${said(tool, 60)}${given}`, how, answered && said(answered)]
681
+ .filter(Boolean)
682
+ .join(" ");
683
+ }
684
+
601
685
  function routeEnds(line: RouteEnds): string {
602
686
  return line.between
603
687
  ? `${said(line.between[0], 60)} ↔ ${said(line.between[1], 60)}`
package/src/machine.ts CHANGED
@@ -4,13 +4,15 @@ import type { RunRecord } from "@agentswf/contract/records";
4
4
  /**
5
5
  * What awf keeps per machine, under `~/.awf`, whatever a run's root: the marks of sessions runs
6
6
  * drive, each sandbox's homes, kept outside the project, whose run root a provider denies, and the
7
- * Herdr sessions it runs agents in: their config, and each run's mark on its workspace.
7
+ * Herdr sessions it runs agents in: their config, and each run's mark on its workspace; and the
8
+ * sockets holders reach live runs' coordinators through.
8
9
  */
9
10
  export function machinePaths(home: string): {
10
11
  root: string;
11
12
  callers: string;
12
13
  sandboxes: string;
13
14
  herdr: string;
15
+ coordinator: string;
14
16
  } {
15
17
  const root = join(home, ".awf");
16
18
  return {
@@ -18,6 +20,7 @@ export function machinePaths(home: string): {
18
20
  callers: join(root, "callers"),
19
21
  sandboxes: join(root, "sandboxes"),
20
22
  herdr: join(root, "herdr"),
23
+ coordinator: join(root, "coordinator"),
21
24
  };
22
25
  }
23
26
 
@@ -25,3 +28,11 @@ export function machinePaths(home: string): {
25
28
  export function sandboxesDirOf(home: string, run: Pick<RunRecord, "workflow" | "id">): string {
26
29
  return join(machinePaths(home).sandboxes, run.workflow, run.id);
27
30
  }
31
+
32
+ /** Where live runs' coordinator sockets are: `AWF_COORDINATOR_DIR`, or `~/.awf/coordinator`. */
33
+ export function coordinatorDir(
34
+ home: string,
35
+ environment: Readonly<Record<string, string | undefined>>,
36
+ ): string {
37
+ return environment.AWF_COORDINATOR_DIR || machinePaths(home).coordinator;
38
+ }