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

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 (72) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +611 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +3 -15
  6. package/src/attempt-ending.ts +2 -4
  7. package/src/attempt-output.ts +3 -2
  8. package/src/attempt-view.ts +1 -1
  9. package/src/control-plane.ts +38 -6
  10. package/src/coordinator-socket.ts +15 -6
  11. package/src/deadlines.ts +69 -2
  12. package/src/decisions/directory.ts +9 -11
  13. package/src/deprecations.ts +177 -0
  14. package/src/duration.ts +50 -20
  15. package/src/emit-command.ts +57 -54
  16. package/src/emit.ts +124 -15
  17. package/src/help/allowance.txt +13 -0
  18. package/src/help/emit.txt +21 -0
  19. package/src/help/inbox.txt +12 -0
  20. package/src/help/init.txt +12 -0
  21. package/src/help/logs.txt +14 -0
  22. package/src/help/reply.txt +18 -0
  23. package/src/help/run.txt +58 -0
  24. package/src/help/runs.txt +11 -0
  25. package/src/help/test.txt +9 -0
  26. package/src/help/tick.txt +12 -0
  27. package/src/herdr-run-session.ts +2 -2
  28. package/src/here.ts +1 -1
  29. package/src/holder-command.ts +38 -51
  30. package/src/inbox-command.ts +9 -24
  31. package/src/init-command.ts +2 -11
  32. package/src/init-template/AGENTS.md.template +3 -3
  33. package/src/init-template/hello/workflow.test.ts.template +3 -3
  34. package/src/init-template/hello/workflow.ts.template +6 -6
  35. package/src/keep-alive.ts +28 -13
  36. package/src/logs-command.ts +14 -27
  37. package/src/mailbox.ts +16 -21
  38. package/src/messaging/limits.ts +5 -5
  39. package/src/messaging/messaging.ts +48 -43
  40. package/src/messaging/routes.ts +2 -2
  41. package/src/messaging/text.ts +24 -22
  42. package/src/operation-liveness.ts +1 -1
  43. package/src/operator-cli.ts +23 -9
  44. package/src/operator-runtime.ts +9 -12
  45. package/src/pane-layout.ts +125 -32
  46. package/src/progress-view.ts +8 -8
  47. package/src/result-validation.ts +1 -1
  48. package/src/run-command.ts +27 -47
  49. package/src/run-commands.ts +1 -1
  50. package/src/run-prepare.ts +46 -8
  51. package/src/run-usage.ts +2 -2
  52. package/src/run-view.ts +9 -4
  53. package/src/runs-command.ts +14 -23
  54. package/src/runs.ts +44 -8
  55. package/src/sandboxes.ts +3 -2
  56. package/src/simple-input.ts +13 -3
  57. package/src/sources.ts +42 -6
  58. package/src/stage-ledger.ts +43 -13
  59. package/src/stage-plan.ts +37 -1
  60. package/src/test-command.ts +9 -14
  61. package/src/testing.ts +8 -9
  62. package/src/tick-command.ts +2 -13
  63. package/src/tools.ts +62 -25
  64. package/src/workflow-loader.ts +5 -2
  65. package/src/workflow-runner.ts +636 -271
  66. package/src/workflow-testing/coordinator.typecheck.ts +12 -13
  67. package/src/workflow-testing/coordinators.ts +2 -2
  68. package/src/workflow-testing/host.ts +115 -46
  69. package/src/workflow-testing/index.ts +45 -837
  70. package/src/workflow-testing/run-test.ts +1213 -0
  71. package/src/workflow-testing/script.ts +106 -45
  72. package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
package/src/stage-plan.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { StageNeed, StageRecord } from "@agentswf/contract/records";
1
+ import type {
2
+ AttemptRecord,
3
+ AttemptStage,
4
+ StageNeed,
5
+ StageRecord,
6
+ } from "@agentswf/contract/records";
2
7
  import { type JsonSchema, type SchemaError, validate } from "@agentswf/contract/schema";
3
8
  import type { JsonValue } from "@agentswf/contract/workflow";
4
9
 
@@ -148,3 +153,34 @@ export function planStageByContent(
148
153
  ? { kind: "reuse", record, value: record.value }
149
154
  : { kind: "stop", reason: misfit };
150
155
  }
156
+
157
+ /**
158
+ * A `--from-stage {stage}` that attempt `attempt` redid, in a run of a workflow with `events`. In
159
+ * that attempt and every later one, a stage first entered after `stage` whose record an earlier
160
+ * attempt than `attempt` wrote runs again, its record replaced: it was made against the stage
161
+ * before the redo. A record never entered after the stage keeps its reuse.
162
+ */
163
+ export type StageRedo = { stage: string; attempt: number };
164
+
165
+ /**
166
+ * The last `--from-stage` an earlier attempt of a run with `events` redid: the latest attempt with
167
+ * one that ran its stage. Its attempt record says so in `stages`, which a stop for a stage never
168
+ * reached, a refusal and a crash leave without it; a crash after the stage's new record was written
169
+ * leaves that record, the attempt's own. A redo whose stage failed and ran again on a wake is still
170
+ * the redo. Only attempt and stage records are read, which a continue may read.
171
+ */
172
+ export function lastRedo(
173
+ attempts: readonly (Pick<AttemptRecord, "attempt" | "flags"> & {
174
+ stages?: readonly Pick<AttemptStage, "stage" | "source">[];
175
+ })[],
176
+ records: ReadonlyMap<string, StageRecord>,
177
+ before: number,
178
+ ): StageRedo | undefined {
179
+ for (const { attempt, flags, stages } of [...attempts].sort((a, b) => b.attempt - a.attempt)) {
180
+ const stage = flags.fromStage;
181
+ if (attempt >= before || stage === undefined) continue;
182
+ const ran = stages?.some((each) => each.stage === stage && each.source === "ran") ?? false;
183
+ if (ran || records.get(stage)?.attempt === attempt) return { stage, attempt };
184
+ }
185
+ return undefined;
186
+ }
@@ -4,21 +4,12 @@ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseDuration } from "./duration";
6
6
  import { messageOf } from "./errors";
7
+ import help from "./help/test.txt" with { type: "text" };
7
8
  import type { Out } from "./run-commands";
8
9
 
9
10
  const PRELOAD = fileURLToPath(new URL("./workflow-test-preload.ts", import.meta.url));
10
11
 
11
- export const testUsage = [
12
- "usage: awf test [paths...] [-t <pattern>] [--watch] [--timeout <duration>]",
13
- "",
14
- "Runs a workflow's tests (*.test.ts and *.spec.ts files) under the current directory, or under",
15
- "each file or directory given, with agentswf/workflow, agentswf/testing and typebox served as",
16
- "awf run serves them: nothing is installed. -t runs the tests whose names match the pattern;",
17
- "--watch reruns them on a change; --timeout bounds each test, 5s by default.",
18
- "",
19
- "Exits 0 when every test passed, 1 when one failed or none was found, 2 on a usage error. What",
20
- "it prints is for reading, not parsing.",
21
- ].join("\n");
12
+ export const testUsage = help.trimEnd();
22
13
 
23
14
  export type TestCommand = { paths: string[]; bunArgs: string[] };
24
15
 
@@ -33,7 +24,7 @@ export async function testCommand(
33
24
  ): Promise<number> {
34
25
  let tests: TestCommand | "help";
35
26
  try {
36
- tests = parseTestCommand(argv, cwd);
27
+ tests = parseTestCommand(argv, cwd, (line) => io.stderr(line));
37
28
  } catch (error) {
38
29
  io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
39
30
  return 2;
@@ -52,7 +43,11 @@ export async function testCommand(
52
43
  * Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
53
44
  * and a later runner can take its place. A path must exist; Bun would take it as a filter.
54
45
  */
55
- export function parseTestCommand(argv: readonly string[], cwd: string): TestCommand | "help" {
46
+ export function parseTestCommand(
47
+ argv: readonly string[],
48
+ cwd: string,
49
+ deprecated: (line: string) => void = () => {},
50
+ ): TestCommand | "help" {
56
51
  const paths: string[] = [];
57
52
  const bunArgs: string[] = [];
58
53
  for (let index = 0; index < argv.length; index += 1) {
@@ -67,7 +62,7 @@ export function parseTestCommand(argv: readonly string[], cwd: string): TestComm
67
62
  index += 1;
68
63
  } else if (option === "--timeout") {
69
64
  if (value === undefined) throw new Error("--timeout needs a duration such as 10s");
70
- bunArgs.push("--timeout", String(parseDuration(value)));
65
+ bunArgs.push("--timeout", String(parseDuration(value, "--timeout", deprecated)));
71
66
  index += 1;
72
67
  } else if (option.startsWith("-")) {
73
68
  throw new Error(`unknown option: ${option}`);
package/src/testing.ts CHANGED
@@ -6,11 +6,9 @@ import type { AttemptRecord } 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";
@@ -19,6 +17,7 @@ import Type from "typebox";
19
17
  import { writeJson } from "./files";
20
18
  import { OPERATOR_ALIASES } from "./operator-aliases";
21
19
  import { createRun, openRun, type Run, readAttempts } from "./runs";
20
+ import type { RunnableWorkflow } from "./workflow-runner";
22
21
  import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
23
22
 
24
23
  export function createTempRunDirs(): {
@@ -81,11 +80,11 @@ const Changes: OutputSchema<{ case: string }> = {
81
80
  properties: { case: { type: "string" } },
82
81
  required: ["case"],
83
82
  };
84
- const caseEvents = { "dd.changes": Changes };
83
+ export const caseEvents = { "dd.changes": Changes };
85
84
 
86
85
  /** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
87
86
  export const caseRun = (
88
- run: (workflow: EventWorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
87
+ run: (workflow: WorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
89
88
  ) =>
90
89
  defineWorkflow({
91
90
  meta: { name: "dd-case", description: "A case." },
@@ -104,7 +103,7 @@ export function future(milliseconds = 60_000): { unixMilliseconds: number } {
104
103
  return { unixMilliseconds: Date.now() + milliseconds };
105
104
  }
106
105
 
107
- /** Answers a call the way an agent's `wf` does: one framed request over its own socket. */
106
+ /** Answers a call the way an agent's `wfa` does: one framed request over its own socket. */
108
107
  export async function submit(
109
108
  binding: { endpoint: string; operationId: string },
110
109
  value: unknown,
@@ -140,7 +139,7 @@ export const DOC = Type.Object({ path: Type.String() }, { additionalProperties:
140
139
  export function workflowOf<Result extends JsonValue>(
141
140
  run: (workflow: WorkflowContext) => Promise<Result>,
142
141
  meta: { name?: string; version?: string } = {},
143
- ): WorkflowDefinition<null, Result> {
142
+ ): RunnableWorkflow<null, Result> {
144
143
  const { name = "staged", version } = meta;
145
144
  return {
146
145
  meta: { name, description: name, ...(version === undefined ? {} : { version }) },
@@ -168,7 +167,7 @@ export function answering(): AgentSessionAdapter {
168
167
  * `answering()`. Its folder is `{runRoot}/{workflow}/r1`.
169
168
  */
170
169
  export async function runAttempt<Result extends JsonValue>(
171
- workflow: WorkflowDefinition<null, Result>,
170
+ workflow: RunnableWorkflow<null, Result>,
172
171
  options: {
173
172
  runRoot: string;
174
173
  attempt?: number;
@@ -206,7 +205,7 @@ export async function runAttempt<Result extends JsonValue>(
206
205
 
207
206
  /** `startWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
208
207
  export async function startNew<Args extends JsonValue, Result extends JsonValue>(
209
- definition: WorkflowDefinition<Args, Result>,
208
+ definition: RunnableWorkflow<Args, Result>,
210
209
  args: Args,
211
210
  options: Omit<RunWorkflowOptions, "run">,
212
211
  ) {
@@ -224,7 +223,7 @@ export async function startNew<Args extends JsonValue, Result extends JsonValue>
224
223
 
225
224
  /** `runWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
226
225
  export async function runNew<Args extends JsonValue, Result extends JsonValue>(
227
- definition: WorkflowDefinition<Args, Result>,
226
+ definition: RunnableWorkflow<Args, Result>,
228
227
  args: Args,
229
228
  options: Omit<RunWorkflowOptions, "run">,
230
229
  ) {
@@ -1,6 +1,7 @@
1
1
  import type { TickResultJson } from "@agentswf/contract/records";
2
2
  import { messageOf } from "./errors";
3
3
  import { exists } from "./files";
4
+ import help from "./help/tick.txt" with { type: "text" };
4
5
  import { listProjects, projectRunRoot } from "./projects";
5
6
  import type { Out } from "./run-commands";
6
7
  import { parsePlace } from "./run-commands";
@@ -8,19 +9,7 @@ import { runLockFile, withRunLock } from "./run-lock";
8
9
  import { listRuns } from "./run-state";
9
10
  import { type Launch, wake } from "./wake";
10
11
 
11
- export const tickUsage = [
12
- "usage: awf tick [--cwd <directory>] [--run-root <directory>] [--json]",
13
- " awf tick --all [--json]",
14
- "",
15
- "Wakes every run of a workflow with events under --cwd's .awf/runs, or --run-root, the runs awf",
16
- "runs lists there, as an event would: continues a parked run whose wait's timer is due, and a",
17
- "crashed or failed one until it has failed three times in a row. Runs of other workflows are left",
18
- "as they are. --all wakes them in every project awf has started one in, as ~/.awf/projects lists",
19
- "them. Run it on any schedule; a run is continued once however many wakers come at once.",
20
- "",
21
- "Prints a line for each run it woke, and nothing when it woke none. --json prints one document.",
22
- "Exits 0, or 1 when a project is gone or a run could not be woken; the others are still woken.",
23
- ].join("\n");
12
+ export const tickUsage = help.trimEnd();
24
13
 
25
14
  export async function tickCommand(
26
15
  argv: readonly string[],
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";
@@ -14,7 +15,14 @@ import { canonical } from "./canonical-json";
14
15
  import { scheduleAt } from "./deadlines";
15
16
  import { messageOf } from "./errors";
16
17
  import type { RunEventBody } from "./run-event-log";
17
- import { checkGiven, QUOTING_RULE, readGiven, simpleInput, usageLine } from "./simple-input";
18
+ import {
19
+ checkGiven,
20
+ QUOTING_RULE,
21
+ readGiven,
22
+ simpleInput,
23
+ takesText,
24
+ usageLine,
25
+ } from "./simple-input";
18
26
 
19
27
  /** How long a handler that ignores its aborted signal is waited for before its call is settled. */
20
28
  export const TOOL_SETTLE_MS = 5_000;
@@ -51,6 +59,8 @@ export type ToolHost<Scope> = {
51
59
  /** Runs `fn` in the operation's scope, or in none. */
52
60
  runIn<T>(scope: Scope | undefined, fn: () => T): T;
53
61
  now?(): number;
62
+ /** A tool given its deprecated `timeoutMs`. */
63
+ deprecated?(name: "timeout-ms", call: string): void;
54
64
  };
55
65
 
56
66
  /** An operation as tool calls bind to it. */
@@ -69,7 +79,14 @@ export type ToolAnswer =
69
79
  | { kind: "tool-failed"; outcome: ToolFailure; error: string }
70
80
  | { kind: "refused"; code: ToolCode | "internal-error"; error: string };
71
81
 
72
- type Held = { specs: AgentTools; schemas: Map<string, JsonSchema | undefined> };
82
+ /** A call's budget: milliseconds from its start, or none of its own. */
83
+ type Budget = number | false;
84
+
85
+ type Held = {
86
+ specs: AgentTools;
87
+ schemas: Map<string, JsonSchema | undefined>;
88
+ budgets: Map<string, Budget>;
89
+ };
73
90
 
74
91
  type Running = {
75
92
  tool: string;
@@ -108,7 +125,7 @@ export class ToolCalls<Scope> {
108
125
 
109
126
  /** An agent opened or forked with these tools; checked against the limits first. */
110
127
  give(agent: AgentKey, tools: AgentTools): void {
111
- this.#held.set(agent, held(agent, tools));
128
+ this.#held.set(agent, held(agent, tools, this.#host.deprecated));
112
129
  }
113
130
 
114
131
  /**
@@ -117,7 +134,7 @@ export class ToolCalls<Scope> {
117
134
  */
118
135
  reopen(agent: AgentKey, tools: AgentTools | undefined): void {
119
136
  if (tools === undefined) return;
120
- const next = held(agent, tools);
137
+ const next = held(agent, tools, this.#host.deprecated);
121
138
  const existing = this.#held.get(agent);
122
139
  if (!existing || !sameTools(existing, next)) {
123
140
  throw new Error(`agent ${agent} is already open with different tools`);
@@ -195,7 +212,7 @@ export class ToolCalls<Scope> {
195
212
  if (operation) operation.deadline = () => deadline;
196
213
  }
197
214
 
198
- /** The call this agent has running, which holds back its `wf result` and `wf waiting`. */
215
+ /** The call this agent has running, which holds back its `wfa result` and `wfa waiting`. */
199
216
  running(agent: AgentKey): { tool: string; settledBy?: number } | undefined {
200
217
  const running = this.#current.get(agent)?.running;
201
218
  return running && { tool: running.tool, ...optional("settledBy", running.settledBy) };
@@ -204,7 +221,7 @@ export class ToolCalls<Scope> {
204
221
  // Calls
205
222
 
206
223
  /**
207
- * `wf tool`'s request from `agent`'s socket. `lifetime` sets how long its connection may stay
224
+ * `wfa tool`'s request from `agent`'s socket. `lifetime` sets how long its connection may stay
208
225
  * open, from now: undefined for as long as the call takes.
209
226
  */
210
227
  async call(
@@ -282,12 +299,11 @@ export class ToolCalls<Scope> {
282
299
  const controller = new AbortController();
283
300
  let cause: ToolFailure | undefined;
284
301
  let handlerEnded = false;
285
- const timeoutMs = spec.timeoutMs === undefined ? TOOL_LIMITS.defaultTimeoutMs : spec.timeoutMs;
302
+ const budget = tools.budgets.get(request.tool)!;
286
303
  const deadline = operation.deadline();
287
304
  const startedAt = this.#now();
288
- const budgetEnd = minimum(timeoutMs === false ? undefined : startedAt + timeoutMs, deadline);
289
- const bound =
290
- deadline !== undefined && budgetEnd === deadline ? ("deadline" as const) : timeoutMs;
305
+ const budgetEnd = minimum(budget === false ? undefined : startedAt + budget, deadline);
306
+ const bound = deadline !== undefined && budgetEnd === deadline ? ("deadline" as const) : budget;
291
307
  const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
292
308
  const { promise: answered, resolve: answer } = Promise.withResolvers<ToolAnswer>();
293
309
  const running: Running = {
@@ -472,7 +488,7 @@ function aborted(
472
488
  tool: string,
473
489
  cause: ToolFailure,
474
490
  /** What bounded it: its own budget, or the turn's deadline. */
475
- bound: number | false | "deadline",
491
+ bound: Budget | "deadline",
476
492
  ): { kind: "tool-failed"; outcome: ToolFailure; error: string } {
477
493
  const what =
478
494
  cause === "cancelled"
@@ -519,8 +535,8 @@ function readInput(
519
535
 
520
536
  /**
521
537
  * The tools block of an operation's prompt: how to call one, then each tool's name and description
522
- * and its usage line, or the schema for an input that isn't simple. The quoting rule comes with it
523
- * unless the result block above already said it; none for an agent without tools.
538
+ * and its usage line, or the schema for an input that isn't simple. The quoting rule comes with a
539
+ * free-text flag unless the result block above already said it; none for an agent without tools.
524
540
  */
525
541
  export function toolsLesson(
526
542
  tools: readonly { name: string; description: string; input?: JsonSchema }[],
@@ -533,9 +549,11 @@ export function toolsLesson(
533
549
  return { ...each, simple };
534
550
  });
535
551
  const flags = taught.some((each) => each.simple && each.simple.length > 0);
552
+ const text = taught.some((each) => each.simple && takesText(each.simple));
536
553
  const json = taught.some((each) => !each.simple);
554
+ const value = text ? "'<value>'" : "<value>";
537
555
  const forms = [
538
- flags ? `${launcher} tool <name> --<option> '<value>' ...` : `${launcher} tool <name>`,
556
+ flags ? `${launcher} tool <name> --<option> ${value} ...` : `${launcher} tool <name>`,
539
557
  ...(json ? [`${launcher} tool <name> <<'WF_JSON'`, "<json>", "WF_JSON"] : []),
540
558
  ];
541
559
  const entries = taught.map(({ name, description, input, simple }) => {
@@ -549,13 +567,17 @@ export function toolsLesson(
549
567
  "You have tools the workflow runs for you. Call one with:",
550
568
  "",
551
569
  ...forms,
552
- ...(flags && !quotingSaid ? ["", QUOTING_RULE] : []),
570
+ ...(text && !quotingSaid ? ["", QUOTING_RULE] : []),
553
571
  "",
554
572
  ...entries,
555
573
  ].join("\n");
556
574
  }
557
575
 
558
- function held(agent: AgentKey, tools: AgentTools): Held {
576
+ function held(
577
+ agent: AgentKey,
578
+ tools: AgentTools,
579
+ deprecated: ToolHost<unknown>["deprecated"],
580
+ ): Held {
559
581
  if (typeof tools !== "object" || tools === null || Array.isArray(tools)) {
560
582
  throw new Error(`agent ${agent}'s tools are an object of tools by name`);
561
583
  }
@@ -564,6 +586,7 @@ function held(agent: AgentKey, tools: AgentTools): Held {
564
586
  throw new Error(`agent ${agent} has ${names.length} tools; at most ${TOOL_LIMITS.tools}`);
565
587
  }
566
588
  const schemas = new Map<string, JsonSchema | undefined>();
589
+ const budgets = new Map<string, Budget>();
567
590
  for (const name of names) {
568
591
  const spec = tools[name]!;
569
592
  const where = `agent ${agent}'s tool ${JSON.stringify(name)}`;
@@ -577,14 +600,7 @@ function held(agent: AgentKey, tools: AgentTools): Held {
577
600
  if (Buffer.byteLength(spec.description) > TOOL_LIMITS.descriptionBytes) {
578
601
  throw new Error(`${where}: its description is over ${TOOL_LIMITS.descriptionBytes} bytes`);
579
602
  }
580
- const timeout = spec.timeoutMs;
581
- if (
582
- timeout !== undefined &&
583
- timeout !== false &&
584
- !(Number.isSafeInteger(timeout) && timeout > 0)
585
- ) {
586
- throw new Error(`${where}: timeoutMs is a positive integer, or false for none`);
587
- }
603
+ budgets.set(name, budgetOf(where, spec, deprecated));
588
604
  if (spec.input === undefined) {
589
605
  schemas.set(name, undefined);
590
606
  continue;
@@ -610,7 +626,28 @@ function held(agent: AgentKey, tools: AgentTools): Held {
610
626
  // Its JSON, in the author's order for the lesson, without TypeBox's own keys.
611
627
  schemas.set(name, JSON.parse(JSON.stringify(input)) as JsonSchema);
612
628
  }
613
- return { specs: { ...tools }, schemas };
629
+ return { specs: { ...tools }, schemas, budgets };
630
+ }
631
+
632
+ /** A tool's `timeout`, or its deprecated `timeoutMs`, checked; absent, the default. */
633
+ function budgetOf(
634
+ where: string,
635
+ spec: ToolSpec,
636
+ deprecated: ToolHost<unknown>["deprecated"],
637
+ ): Budget {
638
+ const { timeout, timeoutMs } = spec;
639
+ if (timeout != null) {
640
+ if (timeoutMs != null)
641
+ throw new Error(`${where}: give timeout alone: timeoutMs is its deprecated form`);
642
+ if (timeout === false) return false;
643
+ return positiveDurationMilliseconds(timeout, `${where}: its timeout`);
644
+ }
645
+ if (timeoutMs == null) return TOOL_LIMITS.defaultTimeoutMs;
646
+ if (timeoutMs !== false && !(Number.isSafeInteger(timeoutMs) && timeoutMs > 0)) {
647
+ throw new Error(`${where}: timeoutMs is a positive integer, or false for none`);
648
+ }
649
+ deprecated?.("timeout-ms", "on a tool");
650
+ return timeoutMs;
614
651
  }
615
652
 
616
653
  function sameTools(a: Held, b: Held): boolean {
@@ -1,7 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { extname, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import * as workflowSurface from "@agentswf/contract/workflow";
4
+ import * as workflowSurface from "@agentswf/contract/author";
5
5
  import {
6
6
  type EventSchemas,
7
7
  eventWorkflowProblem,
@@ -85,7 +85,10 @@ export async function loadWorkflowFile(file: string, cwd: string): Promise<Loade
85
85
  // As `tsc` would have checked them, for a file it never saw.
86
86
  const eventProblem = eventWorkflowProblem(given);
87
87
  if (eventProblem) throw new Error(eventProblem);
88
- if (realPath(given.file as string) !== realPath(absolute))
88
+ if (given.file === undefined)
89
+ // Found by the path it was loaded from, which is all a sender with that path needs.
90
+ return { file: absolute, workflow: { ...given, file: absolute } as AnyWorkflow };
91
+ if (realPath(given.file) !== realPath(absolute))
89
92
  throw new Error(
90
93
  `file is ${JSON.stringify(given.file)}; a workflow with events gives file: import.meta.path, ${absolute}`,
91
94
  );