@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
@@ -1,19 +1,12 @@
1
1
  import { open } from "node:fs/promises";
2
- import { join } from "node:path";
3
2
  import {
4
- OPERATION_LIVENESS_KINDS,
5
3
  OPERATION_LIVENESS_RECORD_VERSION,
6
4
  type OperationLivenessRecord,
7
5
  } from "@agentswf/contract/records";
6
+ import { validEvent } from "../operation-events";
7
+ import { liveFile } from "../ops";
8
8
 
9
- type OperationEvent = Pick<
10
- OperationLivenessRecord,
11
- "kind" | "at" | "sequence" | "reason" | "until" | "peer"
12
- >;
13
9
  const MAX_BYTES = 1024 * 1024;
14
- function eventFile(runDir: string, operationId: string): string {
15
- return join(runDir, "calls", operationId, "liveness.jsonl");
16
- }
17
10
 
18
11
  export async function readOperationEvents(
19
12
  runDir: string,
@@ -25,7 +18,7 @@ export async function readOperationEvents(
25
18
  let text: string;
26
19
  let clipped = false;
27
20
  try {
28
- const file = await open(eventFile(runDir, operationId), "r");
21
+ const file = await open(liveFile(runDir, operationId), "r");
29
22
  try {
30
23
  const buffer = Buffer.alloc(MAX_BYTES + 1);
31
24
  let size = 0;
@@ -63,19 +56,6 @@ export async function readOperationEvents(
63
56
  return { records, incomplete };
64
57
  }
65
58
 
66
- function validEvent(value: OperationEvent): boolean {
67
- return (
68
- typeof value.kind === "string" &&
69
- (OPERATION_LIVENESS_KINDS as readonly string[]).includes(value.kind) &&
70
- Number.isSafeInteger(value.at) &&
71
- value.at >= 0 &&
72
- Number.isSafeInteger(value.sequence) &&
73
- value.sequence >= 0 &&
74
- (value.reason === undefined || typeof value.reason === "string") &&
75
- (value.until === undefined || (Number.isSafeInteger(value.until) && value.until >= 0)) &&
76
- (value.peer === undefined || (typeof value.peer === "string" && value.peer.trim() !== ""))
77
- );
78
- }
79
59
  function isRecord(value: unknown, operationId: string): value is OperationLivenessRecord {
80
60
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
81
61
  const item = value as OperationLivenessRecord;
package/src/testing.ts CHANGED
@@ -2,23 +2,25 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
2
2
  import { createConnection } from "node:net";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import type { AttemptRecord } from "@agentswf/contract/records";
5
+ import type { AttemptRecord, StageLine } from "@agentswf/contract/records";
6
6
  import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
7
7
  import {
8
8
  defineWorkflow,
9
- type EventWorkflowContext,
10
9
  type JsonValue,
11
10
  type OutputSchema,
12
11
  type WorkflowContext,
13
- type WorkflowDefinition,
14
12
  } from "@agentswf/contract/workflow";
15
13
  import { createSingleSessionHostFactory } from "@agentswf/harness";
16
14
  import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
17
15
  import { createFakeAdapter } from "@agentswf/harness/testing";
18
16
  import Type from "typebox";
17
+ import type { DecisionInstallation } from "./decisions/seam";
19
18
  import { writeJson } from "./files";
20
19
  import { OPERATOR_ALIASES } from "./operator-aliases";
21
- import { createRun, openRun, type Run, readAttempts } from "./runs";
20
+ import { listOps } from "./ops";
21
+ import { claimRunLines, readRunLines } from "./run-lines";
22
+ import { createRun, openRun, type Run, readAttemptFiles } from "./runs";
23
+ import type { RunnableWorkflow } from "./workflow-runner";
22
24
  import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
23
25
 
24
26
  export function createTempRunDirs(): {
@@ -62,13 +64,28 @@ export async function until(check: () => Promise<boolean>, ms = 20_000): Promise
62
64
  }
63
65
  }
64
66
 
67
+ /**
68
+ * Attempt `attempt`'s writer on the run's `run.jsonl`, claimed now, and its listing of `ops/`, as
69
+ * `awf run` claims them.
70
+ */
71
+ export async function claimLines(runDir: string, attempt: number) {
72
+ const ops = await listOps(runDir);
73
+ return { lines: await claimRunLines(runDir, attempt, new Date().toISOString()), ops };
74
+ }
75
+
76
+ /** Each stage's latest line in the run's `run.jsonl`, by name, in the order first written. */
77
+ export async function lastLines(runDir: string): Promise<Map<string, StageLine>> {
78
+ const { stages } = (await readRunLines(runDir)).view;
79
+ return new Map(Object.entries(stages).map(([name, { last }]) => [name, last]));
80
+ }
81
+
65
82
  /** Writes the run's next attempt record, as `awf run` claims one, with `fields` over defaults. */
66
83
  export async function writeAttempt(run: Run, fields: Partial<AttemptRecord>): Promise<void> {
67
- const n = (await readAttempts(run)).length + 1;
84
+ const n = (await readAttemptFiles(run)).length + 1;
68
85
  mkdirSync(join(run.dir, "attempts"), { recursive: true });
69
86
  await writeJson(join(run.dir, "attempts", `${n}.json`), {
70
- version: 1,
71
87
  attempt: n,
88
+ kind: "attempt",
72
89
  file: "/flows/case.ts",
73
90
  flags: {},
74
91
  started: new Date().toISOString(),
@@ -81,11 +98,11 @@ const Changes: OutputSchema<{ case: string }> = {
81
98
  properties: { case: { type: "string" } },
82
99
  required: ["case"],
83
100
  };
84
- const caseEvents = { "dd.changes": Changes };
101
+ export const caseEvents = { "dd.changes": Changes };
85
102
 
86
103
  /** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
87
104
  export const caseRun = (
88
- run: (workflow: EventWorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
105
+ run: (workflow: WorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
89
106
  ) =>
90
107
  defineWorkflow({
91
108
  meta: { name: "dd-case", description: "A case." },
@@ -140,7 +157,7 @@ export const DOC = Type.Object({ path: Type.String() }, { additionalProperties:
140
157
  export function workflowOf<Result extends JsonValue>(
141
158
  run: (workflow: WorkflowContext) => Promise<Result>,
142
159
  meta: { name?: string; version?: string } = {},
143
- ): WorkflowDefinition<null, Result> {
160
+ ): RunnableWorkflow<null, Result> {
144
161
  const { name = "staged", version } = meta;
145
162
  return {
146
163
  meta: { name, description: name, ...(version === undefined ? {} : { version }) },
@@ -165,20 +182,23 @@ export function answering(): AgentSessionAdapter {
165
182
 
166
183
  /**
167
184
  * One attempt of run r1 of `workflow` under `runRoot`, created by the first, its agents answered by
168
- * `answering()`. Its folder is `{runRoot}/{workflow}/r1`.
185
+ * `answering()`. Its folder is `{runRoot}/{workflow}/r1`. It claims `run.jsonl` as a claim does, and
186
+ * writes its attempt's file once it ends, as a later attempt reads its version there.
169
187
  */
170
188
  export async function runAttempt<Result extends JsonValue>(
171
- workflow: WorkflowDefinition<null, Result>,
189
+ workflow: RunnableWorkflow<null, Result>,
172
190
  options: {
173
191
  runRoot: string;
174
192
  attempt?: number;
175
193
  fromStage?: string;
176
194
  values?: Record<string, JsonValue>;
177
195
  adapter?: AgentSessionAdapter;
196
+ decisions?: DecisionInstallation;
178
197
  signal?: AbortSignal;
179
198
  },
180
199
  ) {
181
200
  const { runRoot, attempt = 1, fromStage, values, adapter = answering(), signal } = options;
201
+ const { decisions } = options;
182
202
  const name = workflow.meta.name;
183
203
  const run = existsSync(join(runRoot, name, "r1"))
184
204
  ? await openRun(runRoot, name, "r1")
@@ -189,24 +209,51 @@ export async function runAttempt<Result extends JsonValue>(
189
209
  cwd: process.cwd(),
190
210
  sandbox: null,
191
211
  });
192
- return runWorkflow(workflow, null, {
193
- runRoot,
194
- run: {
195
- dir: run.dir,
196
- id: run.record.id,
212
+ const started = new Date().toISOString();
213
+ const ops = await listOps(run.dir);
214
+ const lines = await claimRunLines(run.dir, attempt, started);
215
+ const ended = (outcome: AttemptRecord["outcome"]) =>
216
+ writeJson(join(run.dir, "attempts", `${attempt}.json`), {
197
217
  attempt,
198
- ...(fromStage === undefined ? {} : { fromStage }),
199
- ...(values === undefined ? {} : { values: new Map(Object.entries(values)) }),
200
- },
201
- runtime: { aliases: OPERATOR_ALIASES, host: createSingleSessionHostFactory(adapter) },
202
- deadline: future(),
203
- ...(signal === undefined ? {} : { signal }),
204
- });
218
+ kind: "attempt",
219
+ file: name,
220
+ ...(workflow.meta.version === undefined ? {} : { workflowVersion: workflow.meta.version }),
221
+ flags: fromStage === undefined ? {} : { fromStage },
222
+ pid: process.pid,
223
+ processStart: started,
224
+ started,
225
+ ended: new Date().toISOString(),
226
+ outcome,
227
+ } satisfies AttemptRecord);
228
+ mkdirSync(join(run.dir, "attempts"), { recursive: true });
229
+ try {
230
+ const result = await runWorkflow(workflow, null, {
231
+ runRoot,
232
+ run: {
233
+ dir: run.dir,
234
+ id: run.record.id,
235
+
236
+ lines,
237
+ ops,
238
+ ...(fromStage === undefined ? {} : { fromStage }),
239
+ ...(values === undefined ? {} : { values: new Map(Object.entries(values)) }),
240
+ },
241
+ runtime: { aliases: OPERATOR_ALIASES, host: createSingleSessionHostFactory(adapter) },
242
+ deadline: future(),
243
+ ...(decisions === undefined ? {} : { decisions }),
244
+ ...(signal === undefined ? {} : { signal }),
245
+ });
246
+ await ended("completed");
247
+ return result;
248
+ } catch (error) {
249
+ await ended("failed");
250
+ throw error;
251
+ }
205
252
  }
206
253
 
207
254
  /** `startWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
208
255
  export async function startNew<Args extends JsonValue, Result extends JsonValue>(
209
- definition: WorkflowDefinition<Args, Result>,
256
+ definition: RunnableWorkflow<Args, Result>,
210
257
  args: Args,
211
258
  options: Omit<RunWorkflowOptions, "run">,
212
259
  ) {
@@ -218,13 +265,13 @@ export async function startNew<Args extends JsonValue, Result extends JsonValue>
218
265
  });
219
266
  return startWorkflow(definition, args, {
220
267
  ...options,
221
- run: { dir: run.dir, id: run.record.id, attempt: 1 },
268
+ run: { dir: run.dir, id: run.record.id, ...(await claimLines(run.dir, 1)) },
222
269
  });
223
270
  }
224
271
 
225
272
  /** `runWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
226
273
  export async function runNew<Args extends JsonValue, Result extends JsonValue>(
227
- definition: WorkflowDefinition<Args, Result>,
274
+ definition: RunnableWorkflow<Args, Result>,
228
275
  args: Args,
229
276
  options: Omit<RunWorkflowOptions, "run">,
230
277
  ) {
@@ -1,14 +1,22 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
1
3
  import type { TickResultJson } from "@agentswf/contract/records";
4
+ import { type Out, parsePlace } from "./command-line";
2
5
  import { messageOf } from "./errors";
3
6
  import { exists } from "./files";
7
+ import { ENGINE_VARIABLE, type InstalledEngine, type ProjectEngine } from "./hand-over";
4
8
  import help from "./help/tick.txt" with { type: "text" };
9
+ import { ENGINE_VERSION, EngineRefused, engineOf } from "./project-engine";
5
10
  import { listProjects, projectRunRoot } from "./projects";
6
- import type { Out } from "./run-commands";
7
- import { parsePlace } from "./run-commands";
11
+ import { asksJson, printRefusal } from "./refusals";
8
12
  import { runLockFile, withRunLock } from "./run-lock";
9
- import { listRuns } from "./run-state";
13
+ import { listRunEntries, listRuns } from "./run-state";
14
+ import { RunUnreadable, readAttempts } from "./runs";
10
15
  import { type Launch, wake } from "./wake";
11
16
 
17
+ /** `awf tick --json`'s `"v"`, which its refusals carry too. */
18
+ export const TICK_V = 1 satisfies TickResultJson["v"];
19
+
12
20
  export const tickUsage = help.trimEnd();
13
21
 
14
22
  export async function tickCommand(
@@ -22,10 +30,6 @@ export async function tickCommand(
22
30
  now?: () => number;
23
31
  },
24
32
  ): Promise<number> {
25
- if (argv.includes("-h") || argv.includes("--help")) {
26
- out.stdout(tickUsage);
27
- return 0;
28
- }
29
33
  let parsed: ReturnType<typeof parsePlace>;
30
34
  try {
31
35
  parsed = parsePlace(argv, shellCwd);
@@ -34,24 +38,72 @@ export async function tickCommand(
34
38
  if (parsed.rest.includes("--all") && parsed.place.repeat.length > 0)
35
39
  throw new Error("--all wakes every project's runs; it takes no --cwd or --run-root");
36
40
  } catch (error) {
37
- out.stderr(`awf: ${messageOf(error)}\n\n${tickUsage}`);
41
+ printRefusal(out, { json: asksJson(argv), v: TICK_V }, error, "usage", tickUsage);
38
42
  return 2;
39
43
  }
40
44
  const { place, json } = parsed;
41
45
  const woke: TickResultJson["woke"] = [];
46
+ const unreadable: string[] = [];
42
47
  let failed = false;
43
48
  const roots: string[] = [];
44
- if (!parsed.rest.includes("--all")) roots.push(place.runRoot);
45
- else
49
+ /** Each project's runs on its own engine (ADR 0015): this one, or its own in a child. */
50
+ const onItsEngine = async (project: string, root: string) => {
51
+ let engine: ProjectEngine;
52
+ try {
53
+ engine = engineOf(project, await runFiles(root));
54
+ } catch (error) {
55
+ if (!(error instanceof EngineRefused)) throw error;
56
+ failed = true;
57
+ out.stderr(`awf: ${error.message}; its runs are not woken`);
58
+ return;
59
+ }
60
+ if (engine.kind === "declared") {
61
+ failed = true;
62
+ out.stderr(
63
+ `awf: ${project} declares agentswf in ${engine.manifest} and has none installed, so its runs are not woken: run bun install there`,
64
+ );
65
+ } else if (engine.kind === "installed" && engine.version !== ENGINE_VERSION) {
66
+ // This is the engine the project's agentswf runs, and they disagree: another child would find
67
+ // the same, without end.
68
+ if (isOwnEngine(engine)) {
69
+ failed = true;
70
+ out.stderr(
71
+ `awf: ${project}'s agentswf is ${engine.version} and its engine ${ENGINE_VERSION}, so its runs are not woken: run bun install there`,
72
+ );
73
+ return;
74
+ }
75
+ const ticked = await tickElsewhere(project, root, engine, context.environment, out);
76
+ woke.push(...ticked.woke);
77
+ if (!ticked.ok) failed = true;
78
+ } else roots.push(root);
79
+ };
80
+ if (!parsed.rest.includes("--all")) {
81
+ // A project's own run root is its engine's to tick; any other, the operator's, this one's.
82
+ const root = resolve(place.runRoot);
83
+ if (basename(root) === "runs" && basename(dirname(root)) === ".awf")
84
+ await onItsEngine(dirname(dirname(root)), root);
85
+ else roots.push(root);
86
+ } else
46
87
  for (const project of await listProjects(context.home)) {
47
- if (await exists(project)) roots.push(projectRunRoot(project));
48
- else {
88
+ if (!(await exists(project))) {
49
89
  failed = true;
50
90
  out.stderr(`awf: ${project}, in ~/.awf/projects, is gone; its runs are not woken`);
91
+ continue;
51
92
  }
93
+ // A project a plain run registered (ADR 0016) has nothing for a tick to wake; one on another
94
+ // engine is that engine's to judge, whose records this one may not read.
95
+ const root = projectRunRoot(project);
96
+ if (!(await listRuns(root)).some((run) => run.record.events) && onThisEngine(project))
97
+ continue;
98
+ await onItsEngine(project, root);
52
99
  }
53
100
  for (const root of roots) {
54
- for (const run of await listRuns(root)) {
101
+ for (const run of await listRunEntries(root)) {
102
+ if (run instanceof RunUnreadable) {
103
+ // One with events is one a tick would have woken: it is named, not an error.
104
+ if (run.events) unreadable.push(run.ref);
105
+ continue;
106
+ }
55
107
  if (!run.record.events) continue;
56
108
  const ref = `${run.record.workflow}/${run.record.id}`;
57
109
  try {
@@ -70,7 +122,85 @@ export async function tickCommand(
70
122
  }
71
123
  }
72
124
  }
73
- if (json) out.stdout(JSON.stringify({ v: 1, woke } satisfies TickResultJson));
74
- else for (const each of woke) out.stdout(`${each.did} ${each.ref}: ${each.why}`);
125
+ if (unreadable.length > 0 && !json) {
126
+ const runs = unreadable.length === 1 ? "1 run" : `${unreadable.length} runs`;
127
+ out.stderr(
128
+ `awf: ${runs} with events can't be read and ${unreadable.length === 1 ? "is" : "are"} not woken; awf runs says why`,
129
+ );
130
+ }
131
+ if (json) {
132
+ const result: TickResultJson = {
133
+ v: TICK_V,
134
+ woke,
135
+ ...(unreadable.length ? { unreadable } : {}),
136
+ };
137
+ out.stdout(JSON.stringify(result));
138
+ } else for (const each of woke) out.stdout(`${each.did} ${each.ref}: ${each.why}`);
75
139
  return failed ? 1 : 0;
76
140
  }
141
+
142
+ /** The files the event runs under `root` last ran, by which a project with no engine finds one. */
143
+ async function runFiles(root: string): Promise<string[]> {
144
+ const files: string[] = [];
145
+ for (const run of await listRuns(root)) {
146
+ if (!run.record.events) continue;
147
+ const file = (await readAttempts(run).catch(() => [])).at(-1)?.file ?? run.record.file;
148
+ if (file !== undefined) files.push(file);
149
+ }
150
+ return files;
151
+ }
152
+
153
+ /**
154
+ * `awf tick` of one project's runs on its own engine, as the hand-over runs it: in the project,
155
+ * with `AWF_ENGINE` naming it, so it never hands over by its directory.
156
+ */
157
+ async function tickElsewhere(
158
+ project: string,
159
+ root: string,
160
+ engine: InstalledEngine,
161
+ environment: Readonly<Record<string, string | undefined>>,
162
+ out: Out,
163
+ ): Promise<{ ok: boolean; woke: TickResultJson["woke"] }> {
164
+ const child = Bun.spawn({
165
+ cmd: [process.execPath, "--no-env-file", engine.bin, "tick", "--run-root", root, "--json"],
166
+ cwd: project,
167
+ env: { ...environment, [ENGINE_VARIABLE]: engine.dir },
168
+ stdio: ["ignore", "pipe", "pipe"],
169
+ });
170
+ const [stdout, stderr, code] = await Promise.all([
171
+ new Response(child.stdout).text(),
172
+ new Response(child.stderr).text(),
173
+ child.exited,
174
+ ]);
175
+ for (const line of stderr.trimEnd().split("\n")) if (line) out.stderr(line);
176
+ try {
177
+ const result = JSON.parse(stdout) as Partial<TickResultJson>;
178
+ if (!Array.isArray(result.woke)) throw new Error("no woke");
179
+ return { ok: code === 0, woke: result.woke };
180
+ } catch {
181
+ out.stderr(
182
+ `awf: ${project}'s agentswf ${engine.version} did not say what it woke (exit ${code})`,
183
+ );
184
+ return { ok: false, woke: [] };
185
+ }
186
+ }
187
+
188
+ /** Whether `engine`'s agentswf runs on this very engine package. */
189
+ function isOwnEngine(engine: InstalledEngine): boolean {
190
+ try {
191
+ const theirs = Bun.resolveSync("@agentswf/engine/package.json", engine.dir);
192
+ return realpathSync(theirs) === realpathSync(join(import.meta.dir, "..", "package.json"));
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /** Whether a project's runs would run on this engine, or none is installed there to run them. */
199
+ function onThisEngine(project: string): boolean {
200
+ try {
201
+ const engine = engineOf(project);
202
+ return engine.kind !== "installed" || engine.version === ENGINE_VERSION;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
package/src/tools.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type AgentTools,
8
8
  isJsonValue,
9
9
  type JsonValue,
10
+ positiveDurationMilliseconds,
10
11
  TOOL_LIMITS,
11
12
  type ToolSpec,
12
13
  } from "@agentswf/contract/workflow";
@@ -24,7 +25,7 @@ import {
24
25
  } from "./simple-input";
25
26
 
26
27
  /** How long a handler that ignores its aborted signal is waited for before its call is settled. */
27
- export const TOOL_SETTLE_MS = 5_000;
28
+ const TOOL_SETTLE_MS = 5_000;
28
29
 
29
30
  /**
30
31
  * The agents whose handlers the current code runs in, innermost last: one of them can't be given
@@ -76,7 +77,14 @@ export type ToolAnswer =
76
77
  | { kind: "tool-failed"; outcome: ToolFailure; error: string }
77
78
  | { kind: "refused"; code: ToolCode | "internal-error"; error: string };
78
79
 
79
- type Held = { specs: AgentTools; schemas: Map<string, JsonSchema | undefined> };
80
+ /** A call's budget: milliseconds from its start, or none of its own. */
81
+ type Budget = number | false;
82
+
83
+ type Held = {
84
+ specs: AgentTools;
85
+ schemas: Map<string, JsonSchema | undefined>;
86
+ budgets: Map<string, Budget>;
87
+ };
80
88
 
81
89
  type Running = {
82
90
  tool: string;
@@ -289,12 +297,11 @@ export class ToolCalls<Scope> {
289
297
  const controller = new AbortController();
290
298
  let cause: ToolFailure | undefined;
291
299
  let handlerEnded = false;
292
- const timeoutMs = spec.timeoutMs === undefined ? TOOL_LIMITS.defaultTimeoutMs : spec.timeoutMs;
300
+ const budget = tools.budgets.get(request.tool)!;
293
301
  const deadline = operation.deadline();
294
302
  const startedAt = this.#now();
295
- const budgetEnd = minimum(timeoutMs === false ? undefined : startedAt + timeoutMs, deadline);
296
- const bound =
297
- deadline !== undefined && budgetEnd === deadline ? ("deadline" as const) : timeoutMs;
303
+ const budgetEnd = minimum(budget === false ? undefined : startedAt + budget, deadline);
304
+ const bound = deadline !== undefined && budgetEnd === deadline ? ("deadline" as const) : budget;
298
305
  const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
299
306
  const { promise: answered, resolve: answer } = Promise.withResolvers<ToolAnswer>();
300
307
  const running: Running = {
@@ -479,7 +486,7 @@ function aborted(
479
486
  tool: string,
480
487
  cause: ToolFailure,
481
488
  /** What bounded it: its own budget, or the turn's deadline. */
482
- bound: number | false | "deadline",
489
+ bound: Budget | "deadline",
483
490
  ): { kind: "tool-failed"; outcome: ToolFailure; error: string } {
484
491
  const what =
485
492
  cause === "cancelled"
@@ -573,6 +580,7 @@ function held(agent: AgentKey, tools: AgentTools): Held {
573
580
  throw new Error(`agent ${agent} has ${names.length} tools; at most ${TOOL_LIMITS.tools}`);
574
581
  }
575
582
  const schemas = new Map<string, JsonSchema | undefined>();
583
+ const budgets = new Map<string, Budget>();
576
584
  for (const name of names) {
577
585
  const spec = tools[name]!;
578
586
  const where = `agent ${agent}'s tool ${JSON.stringify(name)}`;
@@ -586,14 +594,7 @@ function held(agent: AgentKey, tools: AgentTools): Held {
586
594
  if (Buffer.byteLength(spec.description) > TOOL_LIMITS.descriptionBytes) {
587
595
  throw new Error(`${where}: its description is over ${TOOL_LIMITS.descriptionBytes} bytes`);
588
596
  }
589
- const timeout = spec.timeoutMs;
590
- if (
591
- timeout !== undefined &&
592
- timeout !== false &&
593
- !(Number.isSafeInteger(timeout) && timeout > 0)
594
- ) {
595
- throw new Error(`${where}: timeoutMs is a positive integer, or false for none`);
596
- }
597
+ budgets.set(name, budgetOf(where, spec));
597
598
  if (spec.input === undefined) {
598
599
  schemas.set(name, undefined);
599
600
  continue;
@@ -619,7 +620,15 @@ function held(agent: AgentKey, tools: AgentTools): Held {
619
620
  // Its JSON, in the author's order for the lesson, without TypeBox's own keys.
620
621
  schemas.set(name, JSON.parse(JSON.stringify(input)) as JsonSchema);
621
622
  }
622
- return { specs: { ...tools }, schemas };
623
+ return { specs: { ...tools }, schemas, budgets };
624
+ }
625
+
626
+ /** A tool's `timeout`, checked; absent, the default. */
627
+ function budgetOf(where: string, spec: ToolSpec): Budget {
628
+ const { timeout } = spec;
629
+ if (timeout == null) return TOOL_LIMITS.defaultTimeoutMs;
630
+ if (timeout === false) return false;
631
+ return positiveDurationMilliseconds(timeout, `${where}: its timeout`);
623
632
  }
624
633
 
625
634
  function sameTools(a: Held, b: Held): boolean {
@@ -0,0 +1,37 @@
1
+ import type { RunLine } from "@agentswf/contract/records";
2
+ import type { AgentExecution, OperationRecord } from "@agentswf/contract/workflow";
3
+ import { endIdOf } from "./event-ids";
4
+ import type { TurnStartedLine } from "./run-policy/waits";
5
+
6
+ /** The usage of a turn no delivery settled: one refused, cancelled while queued, or cut off by a crash. */
7
+ export function unrunUsage(
8
+ agent: string,
9
+ operationId: string,
10
+ execution: AgentExecution,
11
+ ): OperationRecord {
12
+ return { callPath: [], agent, operationId, execution, sessions: [] };
13
+ }
14
+
15
+ /** The failed `turn-ended` attempt `attempt`'s claim writes for `turn`, which its attempt left running. */
16
+ export function crashedEnd(turn: TurnStartedLine, attempt: number, at: string): RunLine {
17
+ return {
18
+ kind: "turn-ended",
19
+ attempt,
20
+ at,
21
+ id: endIdOf(turn.agent, turn.turn, turn.attempt),
22
+ agent: turn.agent,
23
+ turn: turn.turn,
24
+ began: turn.attempt,
25
+ end: {
26
+ turn: turn.turn,
27
+ meta: turn.meta,
28
+ outcome: {
29
+ answered: false,
30
+ kind: "failed",
31
+ reason: `attempt ${turn.attempt} ended before this turn did; its work, if any, is in the agent's session`,
32
+ retryable: true,
33
+ usage: unrunUsage(turn.agent, turn.turn, turn.execution),
34
+ },
35
+ },
36
+ };
37
+ }