@agentswf/engine 0.1.0-beta.4 → 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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/agent-cli/cli.ts +57 -17
  3. package/src/attempt-ending.ts +2 -4
  4. package/src/attempt-output.ts +2 -1
  5. package/src/attempt-view.ts +1 -1
  6. package/src/control-plane.ts +37 -5
  7. package/src/coordinator-socket.ts +15 -6
  8. package/src/deadlines.ts +69 -2
  9. package/src/decisions/directory.ts +9 -11
  10. package/src/deprecations.ts +177 -0
  11. package/src/duration.ts +50 -20
  12. package/src/emit-command.ts +55 -41
  13. package/src/emit.ts +124 -15
  14. package/src/help/emit.txt +12 -4
  15. package/src/help/inbox.txt +2 -2
  16. package/src/help/logs.txt +5 -4
  17. package/src/help/reply.txt +5 -5
  18. package/src/help/run.txt +21 -7
  19. package/src/herdr-run-session.ts +2 -2
  20. package/src/holder-command.ts +36 -31
  21. package/src/inbox-command.ts +7 -10
  22. package/src/init-template/hello/workflow.test.ts.template +3 -3
  23. package/src/init-template/hello/workflow.ts.template +2 -2
  24. package/src/keep-alive.ts +28 -13
  25. package/src/logs-command.ts +12 -12
  26. package/src/mailbox.ts +2 -20
  27. package/src/messaging/limits.ts +4 -4
  28. package/src/messaging/messaging.ts +26 -21
  29. package/src/messaging/routes.ts +1 -1
  30. package/src/messaging/text.ts +7 -5
  31. package/src/operator-cli.ts +23 -9
  32. package/src/operator-runtime.ts +5 -5
  33. package/src/pane-layout.ts +125 -32
  34. package/src/progress-view.ts +8 -8
  35. package/src/run-command.ts +25 -4
  36. package/src/run-commands.ts +1 -1
  37. package/src/run-prepare.ts +46 -8
  38. package/src/run-usage.ts +1 -1
  39. package/src/run-view.ts +9 -4
  40. package/src/runs-command.ts +12 -10
  41. package/src/runs.ts +44 -8
  42. package/src/sources.ts +42 -6
  43. package/src/stage-ledger.ts +42 -12
  44. package/src/stage-plan.ts +37 -1
  45. package/src/test-command.ts +7 -3
  46. package/src/testing.ts +7 -8
  47. package/src/tools.ts +46 -18
  48. package/src/workflow-loader.ts +5 -2
  49. package/src/workflow-runner.ts +553 -235
  50. package/src/workflow-testing/coordinator.typecheck.ts +7 -7
  51. package/src/workflow-testing/coordinators.ts +2 -2
  52. package/src/workflow-testing/host.ts +107 -38
  53. package/src/workflow-testing/index.ts +45 -836
  54. package/src/workflow-testing/run-test.ts +1213 -0
  55. package/src/workflow-testing/script.ts +102 -41
  56. package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
@@ -6,6 +6,7 @@ import type {
6
6
  ToolFailure,
7
7
  } from "@agentswf/contract/wire";
8
8
  import type {
9
+ Duration,
9
10
  Effort,
10
11
  JsonObject,
11
12
  JsonValue,
@@ -14,6 +15,7 @@ import type {
14
15
  } from "@agentswf/contract/workflow";
15
16
  import type Type from "typebox";
16
17
  import { canonical } from "../canonical-json";
18
+ import type { Deprecated } from "../deprecations";
17
19
  import { messageOf } from "../errors";
18
20
 
19
21
  /** One turn of a scripted agent, as the workflow wrote it. */
@@ -78,7 +80,13 @@ export type Message = {
78
80
  answers?: true;
79
81
  };
80
82
 
81
- export type SendOptions = { awaitReply?: boolean; timeoutMs?: number };
83
+ export type SendOptions = {
84
+ awaitReply?: boolean;
85
+ /** How long an awaited reply is waited for, as `wfa send --timeout` gives it. */
86
+ timeout?: Duration;
87
+ /** @deprecated Give `timeout`, a `Duration` such as `"30s"`. */
88
+ timeoutMs?: number;
89
+ };
82
90
 
83
91
  /** How a send or reply ended; a refusal is a value to test, not a throw. */
84
92
  export type MessageOutcome =
@@ -89,7 +97,8 @@ export type MessageOutcome =
89
97
 
90
98
  /**
91
99
  * A message that woke the agent, and what its script may do in that turn: `reply` and `send` as
92
- * the turn's own, or end it with `silent` or `fail`. Returning ends the turn as `silent` does.
100
+ * the turn's own. Returning nothing ends the turn without a reply, and one owed is reminded once,
101
+ * then fails; returning `noAnswer.failed(reason)` fails it as a failed harness does.
93
102
  */
94
103
  export type ReceivedMessage = Message & {
95
104
  /** The turn it woke: whether it is a reminder (`nudge`), and every message shown in it. */
@@ -97,20 +106,22 @@ export type ReceivedMessage = Message & {
97
106
  reply(text: string, options?: SendOptions): Promise<MessageOutcome>;
98
107
  send(peer: string, text: string, options?: SendOptions): Promise<MessageOutcome>;
99
108
  tool(name: string, input?: JsonObject): Promise<ToolOutcome>;
100
- /** Ends the turn without a reply; one owed is reminded once, then fails. */
101
- silent(): Reply;
102
- /** The harness failed: the message operation fails, and the agent stops. */
103
- fail(reason?: string): Reply;
109
+ /** @deprecated Return nothing, which ends the turn without a reply. */
110
+ silent(): NoAnswer;
111
+ /** @deprecated Return `noAnswer.failed(reason)`. */
112
+ fail(reason?: string): NoAnswer;
104
113
  };
105
114
 
106
115
  /**
107
116
  * A message operation's script: called with the message that woke the agent, and again with the
108
117
  * next one when held messages continue the operation, or with the same one, `turn.nudge` set, when
109
- * it is reminded of a reply it owes.
118
+ * it is reminded of a reply it owes. A `noAnswer` it returns ends the turn as that says; nothing,
119
+ * or what a `reply` or `send` resolved to, ends it without a reply. A throw fails the test.
110
120
  */
111
121
  export type MessageScript = (
112
122
  message: ReceivedMessage,
113
- ) => Reply | MessageOutcome | undefined | Promise<Reply | MessageOutcome | undefined>;
123
+ // biome-ignore lint/suspicious/noConfusingVoidType: an async script that returns nothing is Promise<void>.
124
+ ) => void | NoAnswer | MessageOutcome | Promise<void | NoAnswer | MessageOutcome>;
114
125
 
115
126
  /**
116
127
  * How a participant's script's `reply` or `send` ended, as `awf reply --json` reports it: a reply's
@@ -157,7 +168,7 @@ export type ParticipantScript = (event: ParticipantRoute | ParticipantMessage) =
157
168
 
158
169
  const ENTRY: unique symbol = Symbol("agentswf.script-entry");
159
170
 
160
- type Respond = (turn: Turn) => JsonValue | Reply | Promise<JsonValue | Reply>;
171
+ type Respond = (turn: Turn) => JsonValue | NoAnswer | Promise<JsonValue | NoAnswer>;
161
172
 
162
173
  /** An answer to a turn that asks for its schema, or for text. Made by `answer`. */
163
174
  export type Answer = {
@@ -172,11 +183,13 @@ type Ending =
172
183
  | { kind: "hang" }
173
184
  | { kind: "interrupted" };
174
185
 
175
- /** A turn ended without an answer. Made by `reply`. */
176
- export type Reply = { readonly [ENTRY]: { kind: "reply"; ending: Ending } };
186
+ /** A turn ended without an answer. Made by `noAnswer`. */
187
+ export type NoAnswer = {
188
+ readonly [ENTRY]: { kind: "no-answer"; ending: Ending; deprecated?: Deprecated };
189
+ };
177
190
 
178
191
  /** Every turn the agent is asked, alike; or a list, one entry per turn in order. */
179
- export type Script = Answer | Reply | readonly (Answer | Reply)[];
192
+ export type Script = Answer | NoAnswer | readonly (Answer | NoAnswer)[];
180
193
 
181
194
  /** What a schema's answer is: the carrier the author surface types with, else TypeBox's `Static`. */
182
195
  export type AnswerOf<S> = S extends { readonly "~output"?: infer O }
@@ -186,11 +199,11 @@ export type AnswerOf<S> = S extends { readonly "~output"?: infer O }
186
199
  : StaticOf<S>;
187
200
  type StaticOf<S> = S extends Type.TSchema ? Type.Static<S> : JsonValue;
188
201
 
189
- type Answering<A> = A | ((turn: Turn) => A | Reply | Promise<A | Reply>);
202
+ type Answering<A> = A | ((turn: Turn) => A | NoAnswer | Promise<A | NoAnswer>);
190
203
 
191
204
  /**
192
205
  * An answer to a turn that asks for `schema`: a value of its type, or a function of the turn that
193
- * returns one or a `reply`. A function that returns a reply is asked again for the nudge, with
206
+ * returns one or a `noAnswer`. A function that returns no answer is asked again for the nudge, with
194
207
  * `turn.nudge` set.
195
208
  */
196
209
  export function answer<const S extends OutputSchema>(
@@ -206,46 +219,94 @@ export function answer(...args: [unknown] | [unknown, unknown]): Answer {
206
219
  return { [ENTRY]: { kind: "answer", schema: schema as OutputSchema | undefined, respond } };
207
220
  }
208
221
 
209
- const ending = (value: Ending): Reply => ({ [ENTRY]: { kind: "reply", ending: value } });
222
+ const ending = (value: Ending, deprecated?: Deprecated): NoAnswer => ({
223
+ [ENTRY]: {
224
+ kind: "no-answer",
225
+ ending: value,
226
+ ...(deprecated === undefined ? {} : { deprecated }),
227
+ },
228
+ });
210
229
 
211
- /** Ends a turn without an answer. A reply answers no question, so it takes no schema. */
212
- export const reply = {
213
- /** Reports a cooperative wait; later check-ins invoke the same answer function again. */
214
- waiting: (reason: string, timeoutMs?: number): Reply =>
215
- ending({ kind: "waiting", reason, ...(timeoutMs === undefined ? {} : { timeoutMs }) }),
230
+ /** Ends a turn without an answer, so it takes no schema. */
231
+ export const noAnswer = {
216
232
  /** Ends without a result; recovery follows the placement's production support. */
217
- silent: (): Reply => ending({ kind: "silent" }),
233
+ silent: (): NoAnswer => ending({ kind: "silent" }),
218
234
  /** The agent is stuck, as on a permission prompt. */
219
- blocked: (reason = "waiting on a permission prompt"): Reply =>
235
+ blocked: (reason = "waiting on a permission prompt"): NoAnswer =>
220
236
  ending({ kind: "blocked", reason }),
221
237
  /** The harness failed. */
222
- failed: (reason = "harness crashed"): Reply => ending({ kind: "failed", reason }),
238
+ failed: (reason = "harness crashed"): NoAnswer => ending({ kind: "failed", reason }),
223
239
  /**
224
240
  * The agent's harness cannot sign in, as with an expired login: the turn fails with `login` set,
225
241
  * naming the agent's harness, `provider` where it logs in to several, and what to run.
226
242
  */
227
- needsLogin: (provider?: string): Reply =>
243
+ needsLogin: (provider?: string): NoAnswer =>
228
244
  ending({ kind: "needs-login", ...(provider === undefined ? {} : { provider }) }),
229
245
  /** The harness reported the turn timed out; it ends at once. */
230
- timedOut: (reason = "turn timed out"): Reply => ending({ kind: "timed-out", reason }),
246
+ timedOut: (reason = "turn timed out"): NoAnswer => ending({ kind: "timed-out", reason }),
231
247
  /** Holds the turn until the engine cancels it. */
232
- hang: (): Reply => ending({ kind: "hang" }),
248
+ hang: (): NoAnswer => ending({ kind: "hang" }),
233
249
  /**
234
250
  * The operator stopped the turn in their pane, as only the calling session's can be: it settles
235
251
  * `cancelled`, is never nudged, and the session takes the next turn.
236
252
  */
237
- interrupted: (): Reply => ending({ kind: "interrupted" }),
253
+ interrupted: (): NoAnswer => ending({ kind: "interrupted" }),
254
+ };
255
+
256
+ /** What `make` ends a turn with, marked as made through a deprecated name for the run to log. */
257
+ export function deprecatedAs<A extends unknown[]>(
258
+ name: Deprecated,
259
+ make: (...args: A) => NoAnswer,
260
+ ): (...args: A) => NoAnswer {
261
+ return (...args) => ending(make(...args)[ENTRY].ending, name);
262
+ }
263
+
264
+ /** @deprecated Use `noAnswer`, the same calls: a reply answers, these end a turn without one. */
265
+ export const reply: typeof noAnswer = {
266
+ silent: deprecatedAs("test-reply", noAnswer.silent),
267
+ blocked: deprecatedAs("test-reply", noAnswer.blocked),
268
+ failed: deprecatedAs("test-reply", noAnswer.failed),
269
+ needsLogin: deprecatedAs("test-reply", noAnswer.needsLogin),
270
+ timedOut: deprecatedAs("test-reply", noAnswer.timedOut),
271
+ hang: deprecatedAs("test-reply", noAnswer.hang),
272
+ interrupted: deprecatedAs("test-reply", noAnswer.interrupted),
238
273
  };
239
274
 
240
275
  /**
241
- * How a turn ended, as `run.turns` keeps it: `awaiting-reply` when its script awaits a reply, which
242
- * a later turn of the same call resumes it with.
276
+ * Reports a cooperative wait, `wfa waiting`; later check-ins invoke the same answer function again.
277
+ * Only the engine's own tests use it: whether an agent may wait is its harness's, not a workflow's.
243
278
  */
244
- export type TurnOutcome = "answered" | Ending["kind"] | "cancelled" | "awaiting-reply";
279
+ export const waiting = (reason: string, timeoutMs?: number): NoAnswer =>
280
+ ending({ kind: "waiting", reason, ...(timeoutMs === undefined ? {} : { timeoutMs }) });
281
+
282
+ /**
283
+ * How a turn ended, as `run.turns` keeps it: the engine's word, but for `waiting`, a check-in, and
284
+ * `awaiting-reply`, a script awaiting a reply, which a later turn of the same call resumes it with.
285
+ */
286
+ export type TestTurnOutcome =
287
+ | "answered"
288
+ | "unanswered"
289
+ | "blocked"
290
+ | "failed"
291
+ | "timed-out"
292
+ | "cancelled"
293
+ | "waiting"
294
+ | "awaiting-reply";
295
+
296
+ /**
297
+ * A turn's outcome until the engine's record replaces it: what a turn before its operation's last,
298
+ * a nudged one, keeps.
299
+ */
300
+ export function outcomeOf(kind: Exclude<Ending["kind"], "waiting">): TestTurnOutcome {
301
+ if (kind === "silent") return "unanswered";
302
+ if (kind === "needs-login") return "failed";
303
+ if (kind === "hang" || kind === "interrupted") return "cancelled";
304
+ return kind;
305
+ }
245
306
 
246
307
  export type Step =
247
308
  | { kind: "answer"; value: JsonValue }
248
- | { kind: "reply"; ending: Ending }
309
+ | { kind: "no-answer"; ending: Ending; deprecated?: Deprecated }
249
310
  /** The script cannot meet this turn: the test's mistake, not the workflow's. */
250
311
  | { kind: "script-error"; message: string };
251
312
 
@@ -333,7 +394,7 @@ class Lists<E> {
333
394
  type Of = { noun: string; option: string; whose: string; expected: string };
334
395
 
335
396
  /** Each agent's turn scripts, or its compactions'. */
336
- export class Scripts extends Lists<Answer | Reply> {
397
+ export class Scripts extends Lists<Answer | NoAnswer> {
337
398
  readonly #noun: string;
338
399
 
339
400
  constructor(
@@ -345,7 +406,7 @@ export class Scripts extends Lists<Answer | Reply> {
345
406
  {
346
407
  ...of,
347
408
  whose: of.noun === "turn" ? "script" : `${of.noun} script`,
348
- expected: "answer(…), reply.…()",
409
+ expected: "answer(…), noAnswer.…()",
349
410
  },
350
411
  isEntry,
351
412
  );
@@ -357,14 +418,14 @@ export class Scripts extends Lists<Answer | Reply> {
357
418
  const found = this.entry(turn.agent, index);
358
419
  if ("why" in found) return this.error(turn, found.why);
359
420
  const meant = found.entry[ENTRY];
360
- if (meant.kind === "reply") return { kind: "reply", ending: meant.ending };
421
+ if (meant.kind === "no-answer") return { ...meant };
361
422
  if (!sameSchema(meant.schema, turn.schema)) {
362
423
  return this.error(
363
424
  turn,
364
425
  `it asks for ${describeSchema(turn.schema)}; its script answers ${describeSchema(meant.schema)}`,
365
426
  );
366
427
  }
367
- let out: JsonValue | Reply | undefined;
428
+ let out: JsonValue | NoAnswer | undefined;
368
429
  try {
369
430
  out = await meant.respond(turn);
370
431
  } catch (error) {
@@ -373,7 +434,7 @@ export class Scripts extends Lists<Answer | Reply> {
373
434
  if (out === undefined) return this.error(turn, "its script returned nothing");
374
435
  if (isEntry(out)) {
375
436
  const given = out[ENTRY];
376
- if (given.kind === "reply") return { kind: "reply", ending: given.ending };
437
+ if (given.kind === "no-answer") return { ...given };
377
438
  return this.error(turn, "its script returned answer(…); a function returns the value itself");
378
439
  }
379
440
  return { kind: "answer", value: out };
@@ -403,8 +464,8 @@ export class MessageScripts extends Lists<MessageScript> {
403
464
  }
404
465
 
405
466
  /**
406
- * Runs the script for the agent's `index`th message operation with `message`; what it returns
407
- * ends the turn, as `silent` unless it is a `reply`.
467
+ * Runs the script for the agent's `index`th message operation with `message`: what it returns
468
+ * ends the turn silent unless it is a `noAnswer`.
408
469
  */
409
470
  async step(message: ReceivedMessage, index: number): Promise<Step> {
410
471
  const { turn } = message;
@@ -427,13 +488,13 @@ export class MessageScripts extends Lists<MessageScript> {
427
488
  }
428
489
  if (isEntry(out)) {
429
490
  const given = out[ENTRY];
430
- if (given.kind === "reply") return { kind: "reply", ending: given.ending };
491
+ if (given.kind === "no-answer") return { ...given };
431
492
  return {
432
493
  kind: "script-error",
433
494
  message: `${where}: a message script ends a turn, it answers no call`,
434
495
  };
435
496
  }
436
- return { kind: "reply", ending: { kind: "silent" } };
497
+ return { kind: "no-answer", ending: { kind: "silent" } };
437
498
  }
438
499
  }
439
500
 
@@ -459,7 +520,7 @@ export function findByKey<T>(
459
520
  };
460
521
  }
461
522
 
462
- function isEntry(value: unknown): value is Answer | Reply {
523
+ function isEntry(value: unknown): value is Answer | NoAnswer {
463
524
  return typeof value === "object" && value !== null && ENTRY in value;
464
525
  }
465
526
 
@@ -1,7 +1,7 @@
1
1
  // Checked by `tsc --noEmit`, never run: each wrong answer is refused where it is written.
2
2
  import { defineWorkflow, type OutputSchema } from "@agentswf/contract/workflow";
3
3
  import Type from "typebox";
4
- import { answer, reply, type Script, testWorkflow } from ".";
4
+ import { answer, noAnswer, type Script, testWorkflow } from ".";
5
5
 
6
6
  const VERDICT = Type.Union([
7
7
  Type.Object({ kind: Type.Literal("ready"), summary: Type.String() }),
@@ -17,13 +17,15 @@ export const valid: Script[] = [
17
17
  ? { kind: "changes-requested", feedback: ["more"] }
18
18
  : { kind: "ready", summary: "ok" },
19
19
  ),
20
- answer(VERDICT, async (turn) => (turn.nudge ? reply.failed() : { kind: "ready", summary: "" })),
20
+ answer(VERDICT, async (turn) =>
21
+ turn.nudge ? noAnswer.failed() : { kind: "ready", summary: "" },
22
+ ),
21
23
  answer(CARRIED, { count: 1 }),
22
24
  answer(CARRIED, () => ({ count: 2 })),
23
25
  answer("text"),
24
26
  answer((turn) => `turn ${turn.n}`),
25
- reply.silent(),
26
- [answer(VERDICT, { kind: "ready", summary: "ok" }), reply.hang(), answer("done")],
27
+ noAnswer.silent(),
28
+ [answer(VERDICT, { kind: "ready", summary: "ok" }), noAnswer.hang(), answer("done")],
27
29
  ];
28
30
 
29
31
  export const invalid: Script[] = [
@@ -37,8 +39,8 @@ export const invalid: Script[] = [
37
39
  answer(VERDICT, () => ({ kind: "approved", summary: "ok" })),
38
40
  // @ts-expect-error the carrier's type
39
41
  answer(CARRIED, { count: "one" }),
40
- // @ts-expect-error a reply is a script entry of its own, not an answer's value
41
- answer(VERDICT, reply.silent()),
42
+ // @ts-expect-error no answer is a script entry of its own, not an answer's value
43
+ answer(VERDICT, noAnswer.silent()),
42
44
  // @ts-expect-error text answers a turn with no schema
43
45
  answer(42),
44
46
  ];
@@ -53,12 +55,13 @@ const withEvents = defineWorkflow({
53
55
  run: async (wf) => (await wf.receive()).length,
54
56
  });
55
57
  export const tested = () => testWorkflow(withEvents, { id: "k" });
56
- // @ts-expect-error a workflow with events is tested from { id, event? }, never its args
58
+ // @ts-expect-error a workflow with events takes its args among its options
57
59
  export const byArgs = () => testWorkflow(withEvents, null);
58
60
  const plain = defineWorkflow({
59
61
  meta: { name: "p", description: "p" },
60
62
  prepare: ({ argv }) => ({ n: argv.length }),
61
63
  run: async (_wf, args) => args.n,
62
64
  });
63
- // @ts-expect-error a workflow without events is tested from its args, not { id }
65
+ // @ts-expect-error a workflow that takes args is given them
64
66
  export const byOptions = () => testWorkflow(plain, { id: "k" });
67
+ export const argued = () => testWorkflow(plain, { id: "k", args: { n: 1 } });