@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { copyFile, mkdir, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
- import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import { dirname, join, relative } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { isRecord } from "@agentswf/contract";
7
7
  import type { HarnessSandboxNeeds } from "@agentswf/sandbox";
8
8
  import { messageOf } from "./errors";
9
+ import { isInside } from "./files";
9
10
 
10
11
  /** A seeded home's credentials, and a way to hand a refreshed one back to the operator. */
11
12
  export type SeededHome = {
@@ -39,21 +40,12 @@ export async function seedHome(
39
40
  populate?: (staged: string) => Promise<void>,
40
41
  ): Promise<SeededHome> {
41
42
  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);
43
+ if (!isInside(home, path)) throw new Error(`${path} is not in the agent's home ${home}`);
44
+ return join(staging, relative(home, path));
47
45
  };
48
46
  await mkdir(dirname(staging), { recursive: true, mode: 0o700 });
49
47
  await mkdir(staging, { mode: 0o700 });
50
- const seeds: {
51
- from: string;
52
- to: string;
53
- refreshes: string[][];
54
- baseline: Buffer;
55
- refused?: Buffer;
56
- }[] = [];
48
+ const seeds: Seed[] = [];
57
49
  try {
58
50
  for (const { from, to, refreshes } of needs.seed) {
59
51
  let bytes: Buffer;
@@ -82,6 +74,66 @@ export async function seedHome(
82
74
  await rm(staging, { recursive: true, force: true });
83
75
  throw error;
84
76
  }
77
+ return writingBack(seeds, locks);
78
+ }
79
+
80
+ type Seed = {
81
+ from: string;
82
+ to: string;
83
+ refreshes: string[][];
84
+ baseline: Buffer;
85
+ refused?: Buffer;
86
+ };
87
+
88
+ /**
89
+ * Refreshes the credentials of a home an agent had before, for `awf open` (story 042): each seed's
90
+ * file written over with the operator's, never through a link, and handed back as `seedHome`'s
91
+ * are. Nothing else in the home changes: its sessions and skills are the agent's.
92
+ */
93
+ export async function refreshHome(
94
+ home: string,
95
+ needs: Pick<HarnessSandboxNeeds, "seed">,
96
+ locks: CredentialLocks,
97
+ ): Promise<SeededHome> {
98
+ const seeds: Seed[] = [];
99
+ for (const { from, to, refreshes } of needs.seed) {
100
+ let bytes: Buffer;
101
+ try {
102
+ bytes = await readFile(from);
103
+ } catch {
104
+ throw new Error(`cannot refresh ${to}: ${from} is missing; log the harness in first`);
105
+ }
106
+ // Only a file at the home's top, which the engine made: never through a directory the agent
107
+ // made. Written as a new file and renamed over whatever is there, so a link, hard or soft, or
108
+ // a FIFO the agent left in its place is replaced, never written through or waited on.
109
+ if (dirname(to) !== home)
110
+ throw new Error(`${to} is not at the top of the agent's home ${home}`);
111
+ const fresh = join(home, `.awf-${randomUUID()}.tmp`);
112
+ const handle = await open(
113
+ fresh,
114
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
115
+ 0o600,
116
+ );
117
+ try {
118
+ await handle.writeFile(bytes);
119
+ } finally {
120
+ await handle.close();
121
+ }
122
+ await rename(fresh, to).catch(async (error: unknown) => {
123
+ await rm(fresh, { force: true });
124
+ throw error;
125
+ });
126
+ seeds.push({
127
+ from,
128
+ to,
129
+ refreshes: refreshes.map((field) => field.split(".")),
130
+ baseline: bytes,
131
+ });
132
+ }
133
+ return writingBack(seeds, locks);
134
+ }
135
+
136
+ function writingBack(seeds: Seed[], locks: CredentialLocks): SeededHome {
85
137
  return {
86
138
  async writeBack() {
87
139
  // Every seed is tried: one refused leaves the others' refreshes to go back.
package/src/sandboxes.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, realpath } from "node:fs/promises";
2
+ import { lstat, mkdir, realpath } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { join, relative } from "node:path";
4
+ import { dirname, join, relative } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import type { SandboxRecord } from "@agentswf/contract/records";
6
+ import type { SandboxRecord, SessionSandbox } from "@agentswf/contract/records";
7
7
  import type {
8
8
  AbsoluteDeadline,
9
9
  AgentExecution,
@@ -11,7 +11,13 @@ import type {
11
11
  SandboxEnvironmentKey,
12
12
  SandboxRef,
13
13
  } from "@agentswf/contract/workflow";
14
- import { type AgentSkills, harnessState, sandboxNeeds, skillsLayout } from "@agentswf/harness";
14
+ import {
15
+ type AgentSkills,
16
+ type HarnessHomes,
17
+ harnessState,
18
+ OPERATOR_HOMES,
19
+ skillsLayout,
20
+ } from "@agentswf/harness";
15
21
  import {
16
22
  type AgentDoor,
17
23
  type HarnessSandboxNeeds,
@@ -27,7 +33,13 @@ import { CONTROL_PLANE_ROOT } from "./control-plane";
27
33
  import { harnessDeadline } from "./deadlines";
28
34
  import { messageOf } from "./errors";
29
35
  import { machinePaths } from "./machine";
30
- import { type CredentialLocks, placeCarried, type SeededHome, seedHome } from "./sandbox-homes";
36
+ import {
37
+ type CredentialLocks,
38
+ placeCarried,
39
+ refreshHome,
40
+ type SeededHome,
41
+ seedHome,
42
+ } from "./sandbox-homes";
31
43
  import { placeSkills, type ResolvedSkill } from "./skills/run-skills";
32
44
 
33
45
  /** The providers a run may open sandboxes with, and where their directories are made. */
@@ -47,6 +59,8 @@ export type RunSandboxOptions = {
47
59
  * agent's harness is found on and claude's token. Defaults to the engine's.
48
60
  */
49
61
  environment?: Readonly<Record<string, string | undefined>>;
62
+ /** Where an agent's home and its harness's needs come from; the operator's installs and logins by default. */
63
+ homes?: HarnessHomes;
50
64
  /**
51
65
  * The operator's sandbox for the whole run (`awf run --sandbox`): every agent runs in it, at the
52
66
  * run's working directory, and the workflow opens none of its own. Unresolved, as a file gave it.
@@ -102,6 +116,37 @@ type RunSandbox = {
102
116
  watched?: true;
103
117
  };
104
118
 
119
+ /** A sandbox's record as it stands, its agents admitted so far with it. */
120
+ function recordOf({
121
+ key,
122
+ provider,
123
+ resolved,
124
+ directory,
125
+ opened,
126
+ agents,
127
+ }: RunSandbox): SandboxRecord {
128
+ const domains = new Set([...resolved.network, ...agents.flatMap((agent) => agent.domains)]);
129
+ return {
130
+ callPath: [],
131
+ key,
132
+ provider,
133
+ spec: {
134
+ cwd: resolved.cwd,
135
+ read: [...resolved.read],
136
+ write: [...resolved.write],
137
+ network: [...resolved.network],
138
+ ...(resolved.sockets.length > 0 ? { sockets: [...resolved.sockets] } : {}),
139
+ ...(resolved.webSearch ? { webSearch: true } : {}),
140
+ ...({ [provider]: resolved.environment } as SandboxEnvironment),
141
+ },
142
+ directory,
143
+ gitdirs: resolved.gitdirs.map((gitdir) => ({ ...gitdir })),
144
+ domains: [...domains].sort(),
145
+ ...(opened.record ? { provided: structuredClone(opened.record) } : {}),
146
+ agents: agents.map(({ agent, home }) => ({ callPath: [], agent, home })),
147
+ };
148
+ }
149
+
105
150
  /**
106
151
  * One run's sandboxes: the ones the workflow opens, and the private ones inline specs make. The
107
152
  * engine keeps them here, apart from any provider: it resolves a spec, opens it through the
@@ -131,6 +176,8 @@ export class RunSandboxes {
131
176
  /** The run's credential write-backs, which a host agent's own home shares. */
132
177
  locks: CredentialLocks;
133
178
  log(message: string): void;
179
+ /** A sandbox as it opened, before any agent is admitted to it. */
180
+ opened?(record: SandboxRecord): void;
134
181
  },
135
182
  ) {
136
183
  this.#providers = options.sandboxes?.providers ?? { installed: {} };
@@ -196,7 +243,7 @@ export class RunSandboxes {
196
243
  const home = join(sandbox.directory, "homes", id);
197
244
  // Beside `homes/`, which every agent in the sandbox can write, and out of their reach.
198
245
  const staging = join(sandbox.directory, "staging", id);
199
- const needs = await sandboxNeeds(
246
+ const needs = await (this.options.sandboxes?.homes ?? OPERATOR_HOMES).sandboxed(
200
247
  agent.execution.harness,
201
248
  home,
202
249
  agent.execution.model,
@@ -292,30 +339,28 @@ export class RunSandboxes {
292
339
  return settled.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
293
340
  }
294
341
 
342
+ /** The sandbox `agent` was last admitted to, by key, and the model domains its harness added. */
343
+ admitted(agent: string): { sandbox: string; domains: string[] } | undefined {
344
+ for (const sandbox of [...this.#opened].reverse()) {
345
+ const found = sandbox.agents.findLast((each) => each.agent === agent);
346
+ if (found) return { sandbox: sandbox.key, domains: [...found.domains] };
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ /** The sandbox `agent` was last admitted to, as its sessions' lines record it (story 042). */
352
+ sessionSandbox(agent: string): SessionSandbox | undefined {
353
+ for (const sandbox of [...this.#opened].reverse()) {
354
+ if (!sandbox.agents.some((each) => each.agent === agent)) continue;
355
+ const { provider, spec, directory, provided } = recordOf(sandbox);
356
+ return { provider, spec, directory, ...(provided ? { provided } : {}) };
357
+ }
358
+ return undefined;
359
+ }
360
+
295
361
  /** Each sandbox the run opened, a private one closed after its agent failed too, with its agents. */
296
362
  records(): SandboxRecord[] {
297
- return this.#opened.map(({ key, provider, resolved, directory, opened, agents }) => {
298
- const domains = new Set([...resolved.network, ...agents.flatMap((agent) => agent.domains)]);
299
- return {
300
- callPath: [],
301
- key,
302
- provider,
303
- spec: {
304
- cwd: resolved.cwd,
305
- read: [...resolved.read],
306
- write: [...resolved.write],
307
- network: [...resolved.network],
308
- ...(resolved.sockets.length > 0 ? { sockets: [...resolved.sockets] } : {}),
309
- ...(resolved.webSearch ? { webSearch: true } : {}),
310
- ...({ [provider]: resolved.environment } as SandboxEnvironment),
311
- },
312
- directory,
313
- gitdirs: resolved.gitdirs.map((gitdir) => ({ ...gitdir })),
314
- domains: [...domains].sort(),
315
- ...(opened.record ? { provided: structuredClone(opened.record) } : {}),
316
- agents: agents.map(({ agent, home }) => ({ callPath: [], agent, home })),
317
- };
318
- });
363
+ return this.#opened.map(recordOf);
319
364
  }
320
365
 
321
366
  /**
@@ -370,6 +415,7 @@ export class RunSandboxes {
370
415
  throw error;
371
416
  }
372
417
  this.#opened.push(sandbox);
418
+ this.options.opened?.(recordOf(sandbox));
373
419
  // Landed after the run's sandboxes closed: it closes at once, and is still recorded.
374
420
  if (this.#closed) {
375
421
  await this.#close(sandbox).catch(() => undefined);
@@ -526,3 +572,111 @@ function assertWithin(
526
572
  );
527
573
  }
528
574
  }
575
+
576
+ /** A sandbox reopened where an agent ran, its home admitted again: `awf open`'s (story 042). */
577
+ export type ReopenedPlace = {
578
+ occupant: Occupant;
579
+ /** What the sandbox's spec keeps of web search: a turn inside drops the harness's otherwise. */
580
+ webSearch: boolean;
581
+ /** Releases the agent, hands refreshed credentials back, and closes the sandbox. Never rejects. */
582
+ close(): Promise<string[]>;
583
+ };
584
+
585
+ /**
586
+ * Opens a session's recorded sandbox again, in the directory it ran in, and admits the agent with
587
+ * the home it had: its credentials refreshed, nothing else reseeded. docker runs the image the run
588
+ * ran, by digest. `door` is the agent's way back, which reaches no run.
589
+ */
590
+ export async function reopenSandbox(
591
+ recorded: SessionSandbox,
592
+ agent: { harness: string; model?: string; cwd: string; home: string },
593
+ options: {
594
+ providers: SandboxProviders;
595
+ runRoot: string;
596
+ door: AgentDoor;
597
+ deadline: AbsoluteDeadline;
598
+ environment: Readonly<Record<string, string | undefined>>;
599
+ /** The operator's home, whose machine and coordinator paths a spec may not reach. */
600
+ machineHome: string;
601
+ coordinatorRoot: string;
602
+ },
603
+ ): Promise<ReopenedPlace> {
604
+ // The sandbox could write `homes/`: a home it swapped for a link would have the refresh and the
605
+ // admit write through it, outside. Nothing runs in it now, so a check before use holds.
606
+ const entry = await lstat(agent.home).catch(() => undefined);
607
+ if (
608
+ !entry?.isDirectory() ||
609
+ dirname(agent.home) !== join(recorded.directory, "homes") ||
610
+ (await realpath(agent.home)) !== agent.home
611
+ )
612
+ throw new Error(`${agent.home} is not a home in ${recorded.directory}: it was moved or linked`);
613
+ const { provider } = recorded;
614
+ const spec: Record<string, unknown> = structuredClone(recorded.spec);
615
+ const image = recorded.provided?.image;
616
+ if (provider === "docker" && typeof image === "string")
617
+ spec.docker = { ...(spec.docker as object | undefined), image };
618
+ const { sandbox: resolved } = await resolveSandbox(spec, {
619
+ key: "awf-open",
620
+ cwd: recorded.spec.cwd,
621
+ runRoot: await realpath(options.runRoot),
622
+ machineRoot: machinePaths(options.machineHome).root,
623
+ providers: options.providers,
624
+ harnessState: Object.values(harnessState(options.environment)),
625
+ controlRoot: CONTROL_PLANE_ROOT,
626
+ coordinatorRoot: options.coordinatorRoot,
627
+ inline: false,
628
+ });
629
+ const installed = options.providers.installed[provider];
630
+ if (!installed) throw new Error(`the ${provider} sandbox provider is not installed`);
631
+ const opened = await installed
632
+ .open(resolved, {
633
+ runRoot: await realpath(options.runRoot),
634
+ directory: recorded.directory,
635
+ deadline: options.deadline,
636
+ })
637
+ .catch((error: unknown) => {
638
+ // A local image id cannot be pulled: the image the run ran was rebuilt or removed.
639
+ if (provider === "docker" && typeof image === "string" && messageOf(error).includes(image))
640
+ throw new Error(
641
+ `the image the run ran, ${image}, is no longer here: it was rebuilt or removed`,
642
+ );
643
+ throw error;
644
+ });
645
+ const locks: CredentialLocks = new Map();
646
+ let refreshed: SeededHome | undefined;
647
+ try {
648
+ const needs = await OPERATOR_HOMES.sandboxed(
649
+ agent.harness,
650
+ agent.home,
651
+ agent.model,
652
+ options.environment,
653
+ );
654
+ refreshed = await refreshHome(agent.home, needs, locks);
655
+ const occupant = await opened.admit({
656
+ cwd: agent.cwd,
657
+ home: agent.home,
658
+ harness: needs,
659
+ door: options.door,
660
+ });
661
+ return {
662
+ occupant,
663
+ webSearch: resolved.webSearch,
664
+ async close() {
665
+ const failures: string[] = [];
666
+ const said = (error: unknown) => void failures.push(messageOf(error));
667
+ await occupant.release().catch(said);
668
+ await refreshed?.writeBack().catch(said);
669
+ await opened.close().catch(said);
670
+ // What the provider moved aside as it closed, which would vanish without a word.
671
+ const quarantined = opened.record?.quarantined;
672
+ if (Array.isArray(quarantined) && quarantined.length > 0)
673
+ failures.push(`it moved aside ${quarantined.join(", ")}`);
674
+ return failures;
675
+ },
676
+ };
677
+ } catch (error) {
678
+ await refreshed?.writeBack().catch(() => undefined);
679
+ await opened.close().catch(() => undefined);
680
+ throw error;
681
+ }
682
+ }
@@ -0,0 +1,93 @@
1
+ import type { SessionSandbox, SessionSkills } from "@agentswf/contract/records";
2
+ import type { NativeSessionRef } from "@agentswf/contract/workflow";
3
+ import { messageOf } from "./errors";
4
+ import type { RunLines } from "./run-lines";
5
+
6
+ /** Where a session lives: the agent's own home, its sandbox and its skills (story 042). */
7
+ export type SessionPlace = { home?: string; sandbox?: SessionSandbox; skills?: SessionSkills };
8
+ type SessionFields = SessionPlace & { operation?: string };
9
+ type Seen = { harness: string; fields: SessionFields };
10
+
11
+ /**
12
+ * An attempt's `session` lines in `run.jsonl`: each native session an agent is seen with, once per
13
+ * attempt, as it becomes known. The latest one of an agent is what a continue resumes it on.
14
+ */
15
+ export class SessionLines {
16
+ /** By agent, each session asked for and not failed, in the order seen. */
17
+ readonly #written = new Map<string, Map<string, Seen>>();
18
+ readonly #writes = new Set<Promise<void>>();
19
+ /** Sessions a failed write dropped, which `flushed()` tries once more. */
20
+ readonly #dropped: { agent: string; id: string; seen: Seen }[] = [];
21
+
22
+ constructor(
23
+ private readonly options: {
24
+ lines: RunLines;
25
+ /**
26
+ * A line that could not be written: tried again when the session is next seen, and once
27
+ * more as the attempt flushes; a second failure there stays a warning. Until one is written,
28
+ * a continue reopens the agent on its last session recorded, or anew if none was.
29
+ */
30
+ failed(message: string): void;
31
+ },
32
+ ) {}
33
+
34
+ /** `operation` is the operation the sessions were seen in; the rest, where the session lives. */
35
+ known(agent: string, sessions: readonly NativeSessionRef[], fields: SessionFields = {}): void {
36
+ let written = this.#written.get(agent);
37
+ if (!written) {
38
+ written = new Map();
39
+ this.#written.set(agent, written);
40
+ }
41
+ for (const { harness, id } of sessions) {
42
+ if (written.has(id)) continue;
43
+ written.set(id, { harness, fields });
44
+ const write = this.options.lines
45
+ .append({
46
+ kind: "session",
47
+ attempt: this.options.lines.attempt,
48
+ at: new Date().toISOString(),
49
+ agent,
50
+ harness,
51
+ session: id,
52
+ ...(fields.operation === undefined ? {} : { operation: fields.operation }),
53
+ ...(fields.home === undefined ? {} : { home: fields.home }),
54
+ ...(fields.sandbox === undefined ? {} : { sandbox: fields.sandbox }),
55
+ ...(fields.skills === undefined ? {} : { skills: fields.skills }),
56
+ })
57
+ .catch((error) => {
58
+ // It and those seen after it are written again, in order, when next seen: the latest
59
+ // line stays the newest session.
60
+ const seen = [...written];
61
+ const at = seen.findIndex(([each]) => each === id);
62
+ if (at >= 0) {
63
+ for (const [later, how] of seen.slice(at)) {
64
+ written.delete(later);
65
+ this.#dropped.push({ agent, id: later, seen: how });
66
+ }
67
+ }
68
+ this.options.failed(
69
+ `awf: agent ${agent}'s session was not recorded: ${messageOf(error)}`,
70
+ );
71
+ })
72
+ .finally(() => this.#writes.delete(write));
73
+ this.#writes.add(write);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Once every line asked for so far is written, or has failed; a session a failed write dropped,
79
+ * and not seen since, is tried once more first.
80
+ */
81
+ async flushed(): Promise<void> {
82
+ const settled = async () => {
83
+ while (this.#writes.size > 0) await Promise.all(this.#writes);
84
+ };
85
+ await settled();
86
+ const dropped = this.#dropped.splice(0);
87
+ for (const { agent, id, seen } of dropped) {
88
+ this.known(agent, [{ harness: seen.harness, id }], seen.fields);
89
+ }
90
+ await settled();
91
+ this.#dropped.length = 0;
92
+ }
93
+ }
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import type { AgentSkillsRecord, SkillRecord } from "@agentswf/contract/records";
6
6
  import type { SkillSource } from "@agentswf/contract/workflow";
7
+ import { workDir } from "../runs";
7
8
  import { cachedRepository, extract, fetchCommit, findSkill } from "./fetch";
8
9
  import { parseSkillFile, repositoryUrl } from "./sources";
9
10
  import { copySkillTree, digestTree } from "./tree";
@@ -38,7 +39,7 @@ export class RunSkills {
38
39
  constructor(options: { runDir: string; cacheRoot?: string; environment?: Environment }) {
39
40
  this.#environment = options.environment ?? process.env;
40
41
  // Absolute: git runs in the cache's repository, and a harness is pointed at these paths.
41
- this.#root = resolve(options.runDir, "skills");
42
+ this.#root = resolve(workDir(options.runDir), "skills");
42
43
  this.#cache = resolve(options.cacheRoot ?? defaultCacheRoot(this.#environment));
43
44
  }
44
45
 
@@ -67,6 +68,12 @@ export class RunSkills {
67
68
  return this.#records.map((record) => structuredClone(record));
68
69
  }
69
70
 
71
+ /** What `agent` was given, as last recorded. */
72
+ of(agent: string): AgentSkillsRecord | undefined {
73
+ const found = this.#records.findLast((record) => record.agent === agent);
74
+ return found && structuredClone(found);
75
+ }
76
+
70
77
  #one(source: SkillSource): Promise<ResolvedSkill> {
71
78
  const key = JSON.stringify(source);
72
79
  let resolving = this.#resolved.get(key);
package/src/sources.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { WaitTake } from "@agentswf/contract/records";
2
- import { awfEventOf, type JsonValue, type WorkflowEvent } from "@agentswf/contract/workflow";
2
+ import type { JsonValue, WorkflowEvent } from "@agentswf/contract/workflow";
3
+ import { matches, recordName } from "./run-policy/waits";
3
4
 
4
5
  /** What each source the run issued takes: only awf makes a source, so only these are. */
5
6
  const takes = new WeakMap<object, WaitTake>();
@@ -35,16 +36,6 @@ export function isSignal(value: unknown): boolean {
35
36
 
36
37
  type Looked = { name: string; from: string; data?: unknown };
37
38
 
38
- /**
39
- * The name the mailbox records an event under: awf's keep `agent:ended` and `participant:reply`,
40
- * from their key, whatever name the workflow declares and is handed, `{key}:ended` or `{key}:reply`.
41
- */
42
- export function recordName(event: { name: string; from: string }): string {
43
- const awf = awfEventOf(event.name);
44
- if (!awf || awf.key !== event.from) return event.name;
45
- return awf.kind === "ended" ? "agent:ended" : "participant:reply";
46
- }
47
-
48
39
  /** `event` under the name the workflow declares for it, where it declares one. */
49
40
  export function presented<E extends WorkflowEvent<string, JsonValue>>(
50
41
  event: E,
@@ -55,39 +46,11 @@ export function presented<E extends WorkflowEvent<string, JsonValue>>(
55
46
  return Object.freeze({ ...event, name: `${event.from}:${kind}` });
56
47
  }
57
48
 
58
- /** The first of `branches` that takes `event`. */
59
- export function branchOf(
60
- branches: readonly (readonly [string, WaitTake])[],
61
- event: Looked,
62
- ): string | undefined {
63
- return branches.find(([, take]) => matches(event, take))?.[0];
64
- }
65
-
66
49
  /** What a source takes; undefined for anything the run didn't issue as one. */
67
50
  export function takeOf(value: unknown): WaitTake | undefined {
68
51
  return typeof value === "object" && value !== null ? takes.get(value) : undefined;
69
52
  }
70
53
 
71
- /** Whether a wait that takes `take` takes `event`, under its recorded name or its declared one. */
72
- export function matches(
73
- event: { name: string; from: string; data?: unknown },
74
- take: WaitTake,
75
- ): boolean {
76
- if (recordName(event) !== take.name) return false;
77
- if (take.from !== undefined && event.from !== take.from) return false;
78
- if (take.turn === undefined) return true;
79
- const data = event.data as { turn?: unknown } | null | undefined;
80
- return typeof data === "object" && data !== null && data.turn === take.turn;
81
- }
82
-
83
- /** Whether a wait taking `wanted`, or everything when it names nothing, takes `event`. */
84
- export function wouldTake(
85
- event: { name: string; from: string; data?: unknown },
86
- wanted: readonly WaitTake[] | undefined,
87
- ): boolean {
88
- return wanted === undefined || wanted.some((take) => matches(event, take));
89
- }
90
-
91
54
  /**
92
55
  * What a branch hands back of the event it took: awf's own events as their data with the event's id
93
56
  * and arrival, and a workflow's own event, or any a signal takes, whole.