@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.4

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 (82) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -0,0 +1,54 @@
1
+ // Checked by `tsc --noEmit`, never run: story 032's workflow and its test, as an author writes them.
2
+ import type {
3
+ ParticipantRef,
4
+ WorkflowContext,
5
+ WorkflowDefinition,
6
+ } from "@agentswf/contract/workflow";
7
+ import { answer, type ParticipantScript, testWorkflow } from ".";
8
+
9
+ declare const runtime: string;
10
+
11
+ const investigate: WorkflowDefinition<null, string> = {
12
+ meta: { name: "investigate", description: "watches checkout-api" },
13
+ async run(wf: WorkflowContext) {
14
+ const investigator = await wf.agents.open({ key: "investigator", runtime });
15
+ const coordinator: ParticipantRef = await wf.participants.connect({
16
+ key: "coordinator",
17
+ labels: { audience: "operator" },
18
+ });
19
+ await wf.messages.allow({
20
+ between: [investigator, coordinator],
21
+ purpose: "report findings that hold up; ask when the logs can't settle it",
22
+ timeoutMs: 2 * 60 * 60_000,
23
+ });
24
+ const { outcome } = await investigator.run({ prompt: "Check the checkout-api logs." });
25
+ await coordinator.release("investigation done");
26
+ // A run has no deadline unless `awf run --timeout` gives it one.
27
+ const left = wf.deadline ? wf.deadline.unixMilliseconds - Date.now() : undefined;
28
+ return outcome.answered ? `${outcome.value} (${left ?? "no"} ms left)` : outcome.kind;
29
+ },
30
+ };
31
+
32
+ export async function story(): Promise<string> {
33
+ const run = await testWorkflow(investigate, null, {
34
+ agents: { investigator: [answer("…")] },
35
+ participants: {
36
+ coordinator: (event) =>
37
+ event.kind === "message" && event.asks
38
+ ? event.reply("Keep two, and link them.")
39
+ : undefined,
40
+ },
41
+ });
42
+ return run.value;
43
+ }
44
+
45
+ export const sendsFirst: ParticipantScript = async (event) => {
46
+ if (event.kind !== "route") return;
47
+ const sent = await event.send("Also check the 09:12 deploy.");
48
+ if (sent.code !== "delivered") throw new Error(sent.code);
49
+ };
50
+
51
+ export async function refused(wf: WorkflowContext): Promise<void> {
52
+ // @ts-expect-error bindingPath is gone: the socket's place is fixed.
53
+ await wf.participants.connect({ key: "coordinator", bindingPath: "/tmp/x" });
54
+ }
@@ -0,0 +1,137 @@
1
+ import type { CoordinatorHolder, CoordinatorPort } from "../coordinator-port";
2
+ import type { HolderResult, MessagingEventBody } from "../messaging/messaging";
3
+ import type {
4
+ ParticipantMessage,
5
+ ParticipantOutcome,
6
+ ParticipantRoute,
7
+ ParticipantScript,
8
+ } from "./script";
9
+
10
+ /**
11
+ * Answers for a test's coordinators from their scripts, as a holder would: each route to one as it
12
+ * opens, and each message an agent sends one once recorded, is handed to its script.
13
+ */
14
+ export class ParticipantScripts implements CoordinatorPort {
15
+ readonly #scripts: Readonly<Record<string, ParticipantScript>>;
16
+ readonly #attempt: number;
17
+ readonly #events: { onActivity(): void; onScriptError(message: string): void };
18
+ #holder: CoordinatorHolder | undefined;
19
+ readonly #connected = new Set<string>();
20
+ /** Script calls not yet settled. */
21
+ #working = 0;
22
+
23
+ constructor(
24
+ scripts: Readonly<Record<string, ParticipantScript>> | undefined,
25
+ attempt: number,
26
+ events: { onActivity(): void; onScriptError(message: string): void },
27
+ ) {
28
+ this.#scripts = scripts ?? {};
29
+ this.#attempt = attempt;
30
+ this.#events = events;
31
+ }
32
+
33
+ attach(holder: CoordinatorHolder): void {
34
+ this.#holder = holder;
35
+ }
36
+
37
+ /** Nothing a script was handed is still running. */
38
+ get idle(): boolean {
39
+ return this.#working === 0;
40
+ }
41
+
42
+ /** Questions open to coordinators now; `closesAt` where one closes unanswered. */
43
+ open(): { closesAt?: number }[] {
44
+ return this.#holder?.open() ?? [];
45
+ }
46
+
47
+ recorded(event: MessagingEventBody): void {
48
+ if (event.kind === "participant-connected") {
49
+ this.#connected.add(event.participant);
50
+ return;
51
+ }
52
+ if (event.kind === "route-opened") {
53
+ const [a, b] = event.between ?? [event.from, event.to];
54
+ for (const [coordinator, agent] of [
55
+ [a, b],
56
+ [b, a],
57
+ ] as const) {
58
+ if (!this.#connected.has(coordinator)) continue;
59
+ const direction = event.between
60
+ ? "both"
61
+ : event.from === coordinator
62
+ ? "from-participant"
63
+ : "to-participant";
64
+ const route: ParticipantRoute = {
65
+ kind: "route",
66
+ agent,
67
+ direction,
68
+ purpose: event.purpose,
69
+ send: (text) => this.#send(coordinator, agent, text),
70
+ };
71
+ this.#call(coordinator, route);
72
+ }
73
+ return;
74
+ }
75
+ if (event.kind === "message-sent" && this.#connected.has(event.to)) {
76
+ const coordinator = event.to;
77
+ const message: ParticipantMessage = {
78
+ kind: "message",
79
+ id: this.#id(event.message),
80
+ from: event.from,
81
+ text: event.text,
82
+ ...(event.awaitsReply ? { asks: true as const } : {}),
83
+ reply: (text) =>
84
+ this.#outcome(
85
+ this.#holder!.reply({
86
+ coordinator,
87
+ agent: event.from,
88
+ to: event.message,
89
+ text,
90
+ via: "test",
91
+ }),
92
+ ),
93
+ send: (agent, text) => this.#send(coordinator, agent, text),
94
+ };
95
+ this.#call(coordinator, message);
96
+ }
97
+ }
98
+
99
+ #call(coordinator: string, event: ParticipantRoute | ParticipantMessage): void {
100
+ const script = this.#scripts[coordinator];
101
+ if (!script) {
102
+ this.#events.onScriptError(
103
+ `${event.kind === "route" ? "a route" : "a message"} reached coordinator "${coordinator}", which has no script under coordinators`,
104
+ );
105
+ return;
106
+ }
107
+ this.#working += 1;
108
+ this.#events.onActivity();
109
+ // Never inside the record's write: a script's reply is a holder's, arriving after it.
110
+ void Promise.resolve()
111
+ .then(() => script(event))
112
+ .catch((error: unknown) =>
113
+ this.#events.onScriptError(
114
+ `coordinator "${coordinator}"'s script threw: ${error instanceof Error ? error.message : String(error)}`,
115
+ ),
116
+ )
117
+ .finally(() => {
118
+ this.#working -= 1;
119
+ this.#events.onActivity();
120
+ });
121
+ }
122
+
123
+ #send(coordinator: string, agent: string, text: string): Promise<ParticipantOutcome> {
124
+ return this.#outcome(this.#holder!.send({ coordinator, agent, text, via: "test" }));
125
+ }
126
+
127
+ async #outcome(result: Promise<HolderResult>): Promise<ParticipantOutcome> {
128
+ const settled = await result;
129
+ return settled.code === "delivered"
130
+ ? { code: "delivered", id: this.#id(settled.message) }
131
+ : { code: settled.code };
132
+ }
133
+
134
+ #id(message: number): string {
135
+ return `${this.#attempt}.${message}`;
136
+ }
137
+ }
@@ -5,6 +5,9 @@ import {
5
5
  type ReplyCode,
6
6
  SEND_CODES,
7
7
  type SendCode,
8
+ TOOL_CODES,
9
+ type ToolCode,
10
+ type ToolResponse,
8
11
  WIRE_VERSION,
9
12
  } from "@agentswf/contract/wire";
10
13
  import {
@@ -34,7 +37,7 @@ import {
34
37
  type FakeFork,
35
38
  type FakeSet,
36
39
  } from "@agentswf/harness/testing";
37
- import { submitControl, submitResult, submitWaiting } from "@agentswf/wf/client";
40
+ import { submitControl, submitResult, submitWaiting } from "../agent-cli/client";
38
41
  import {
39
42
  type Message,
40
43
  type MessageOutcome,
@@ -44,6 +47,7 @@ import {
44
47
  type Scripts,
45
48
  type SendOptions,
46
49
  type Step,
50
+ type ToolOutcome,
47
51
  type Turn,
48
52
  type TurnOutcome,
49
53
  } from "./script";
@@ -52,7 +56,7 @@ import {
52
56
  * A turn as the workflow wrote it, and how it ended. `messages` are those it was shown, absent when
53
57
  * none were.
54
58
  */
55
- export type TurnRecord = Omit<Turn, "signal" | "messages" | "send" | "reply"> & {
59
+ export type TurnRecord = Omit<Turn, "signal" | "messages" | "send" | "reply" | "tool"> & {
56
60
  messages?: readonly Message[];
57
61
  outcome: TurnOutcome;
58
62
  };
@@ -133,6 +137,8 @@ export function createScriptedHost(
133
137
  onActivity(): void;
134
138
  /** The script could not meet a turn: the test fails. */
135
139
  onScriptError(message: string): void;
140
+ /** A turn began, before its script answers: what a test sends during it goes now. */
141
+ onTurnStarted?(turn: TurnRecord): Promise<void>;
136
142
  },
137
143
  /**
138
144
  * The session the run is started from, as `awf run --here` finds one, and the model its files
@@ -186,7 +192,7 @@ export function createScriptedHost(
186
192
  return n;
187
193
  };
188
194
 
189
- /** `wf send` or `wf reply` from a running script; with `awaitReply`, resolves with the reply. */
195
+ /** `wfa send` or `wfa reply` from a running script; with `awaitReply`, resolves with the reply. */
190
196
  const message = async (
191
197
  invocation: Invocation,
192
198
  command: "send" | "reply",
@@ -220,7 +226,7 @@ export function createScriptedHost(
220
226
  suspendIfAwaiting(invocation);
221
227
  return { kind: "refused", code: response.code as SendCode | ReplyCode };
222
228
  }
223
- throw new Error(`wf ${command} was refused (${response.code}): ${response.error}`);
229
+ throw new Error(`wfa ${command} was refused (${response.code}): ${response.error}`);
224
230
  }
225
231
  if (!options.awaitReply) {
226
232
  suspendIfAwaiting(invocation);
@@ -232,9 +238,54 @@ export function createScriptedHost(
232
238
  });
233
239
  };
234
240
 
241
+ /** `wfa tool` from a running script, as JSON or no input; waits as long as the engine takes. */
242
+ const callTool = async (
243
+ invocation: Invocation,
244
+ name: string,
245
+ input: JsonObject | undefined,
246
+ ): Promise<ToolOutcome> => {
247
+ if (invocation.state !== "running") {
248
+ const why = `agent "${invocation.turn.agent}" turn ${invocation.turn.n}: its script called a tool after its turn ended`;
249
+ failTest(why);
250
+ throw new Error(why);
251
+ }
252
+ const call = submitControl(
253
+ invocation.operation.binding.endpoint,
254
+ {
255
+ version: WIRE_VERSION,
256
+ command: "tool",
257
+ tool: name,
258
+ ...(input === undefined ? {} : { raw: JSON.stringify(input) }),
259
+ },
260
+ null,
261
+ );
262
+ invocation.calls.add(call);
263
+ let response: ToolResponse;
264
+ try {
265
+ response = await call;
266
+ } finally {
267
+ invocation.calls.delete(call);
268
+ // A reply it awaits ends the turn once nothing of the host's is in flight, this call included.
269
+ suspendIfAwaiting(invocation);
270
+ }
271
+ switch (response.kind) {
272
+ case "tool-result":
273
+ return {
274
+ kind: "result",
275
+ ...(response.output === undefined ? {} : { output: response.output }),
276
+ };
277
+ case "tool-failed":
278
+ return { kind: "failed", outcome: response.outcome, error: response.error };
279
+ default:
280
+ if (response.code === "internal-error" || !TOOL_REFUSALS.has(response.code))
281
+ throw new Error(`wfa tool was refused (${response.code}): ${response.error}`);
282
+ return { kind: "refused", code: response.code as ToolCode, error: response.error };
283
+ }
284
+ };
285
+
235
286
  /**
236
287
  * Ends the native turn when its script has gone still awaiting a reply, as an agent ends its turn
237
- * after `wf send --await-reply`: nothing of the host's in flight, and no step taken since.
288
+ * after `wfa send --await-reply`: nothing of the host's in flight, and no step taken since.
238
289
  */
239
290
  const suspendIfAwaiting = (invocation: Invocation) => {
240
291
  setTimeout(() => {
@@ -271,6 +322,7 @@ export function createScriptedHost(
271
322
  messages,
272
323
  send: (peer, text, options) => message(invocation, "send", peer, text, options),
273
324
  reply: (peer, text, options) => message(invocation, "reply", peer, text, options),
325
+ tool: (name, input) => callTool(invocation, name, input),
274
326
  signal: context.signal,
275
327
  };
276
328
  invocation.turn = turn;
@@ -285,6 +337,7 @@ export function createScriptedHost(
285
337
  turn,
286
338
  reply: (text, options) => turn.reply(woke!.from, text, options),
287
339
  send: turn.send,
340
+ tool: turn.tool,
288
341
  silent: reply.silent,
289
342
  fail: (reason) => reply.failed(reason),
290
343
  } satisfies ReceivedMessage,
@@ -360,7 +413,7 @@ export function createScriptedHost(
360
413
  // Submitted is answered, even though the engine then releases the turn.
361
414
  let outcome: TurnOutcome = "cancelled";
362
415
  try {
363
- // As an agent's `wf send` returns before it can run `wf result`.
416
+ // As an agent's `wfa send` returns before it can run `wfa result`.
364
417
  if (invocation) await Promise.allSettled([...invocation.calls]);
365
418
  const { endpoint, operationId } = context.binding!;
366
419
  const response = await submitResult(endpoint, {
@@ -465,6 +518,7 @@ export function createScriptedHost(
465
518
  messages: [],
466
519
  send: noMessages,
467
520
  reply: noMessages,
521
+ tool: noTools,
468
522
  cwd: activation.cwd,
469
523
  model: context.settings.model,
470
524
  ...(context.settings.effort === undefined ? {} : { effort: context.settings.effort }),
@@ -555,6 +609,7 @@ export function createScriptedHost(
555
609
  };
556
610
  turns.push(record);
557
611
  open.add(record);
612
+ if (!record.nudge && context.kind === "turn") await events.onTurnStarted?.(record);
558
613
 
559
614
  // A reply or a failure settles the send awaiting it, and resumes the script that awaits it.
560
615
  const settled: [Awaiting, MessageOutcome][] = [];
@@ -727,7 +782,7 @@ type Awaiting = { peer: string; by: Invocation; resolve(outcome: MessageOutcome)
727
782
  type Invocation = {
728
783
  operation: Operation;
729
784
  turn: Turn;
730
- /** Its `wf` calls in flight. */
785
+ /** Its `wfa` calls in flight. */
731
786
  calls: Set<Promise<unknown>>;
732
787
  state: "running" | "suspended" | "settled" | "cancelled";
733
788
  /** Where it stopped in its current native turn. */
@@ -735,6 +790,7 @@ type Invocation = {
735
790
  };
736
791
 
737
792
  const MESSAGE_CODES = new Set<string>([...SEND_CODES, ...REPLY_CODES]);
793
+ const TOOL_REFUSALS = new Set<string>(TOOL_CODES);
738
794
  /** The call ended before the script's answer reached it: the turn was let go, not refused. */
739
795
  const CLOSED_CODES = new Set<string>([
740
796
  "closed-operation",
@@ -756,6 +812,10 @@ async function noMessages(): Promise<MessageOutcome> {
756
812
  throw new Error("a compaction sends no messages");
757
813
  }
758
814
 
815
+ async function noTools(): Promise<ToolOutcome> {
816
+ throw new Error("a compaction calls no tools");
817
+ }
818
+
759
819
  /** What the pane host refuses before it opens a pane. */
760
820
  function paneRefusal(activation: HarnessActivation): string | undefined {
761
821
  const { harness } = activation.execution;