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

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 (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -0,0 +1,55 @@
1
+ // Checked by `tsc --noEmit`, never run: story 032's workflow and its test, as an author writes them.
2
+ import {
3
+ isAnswered,
4
+ type ParticipantRef,
5
+ type WorkflowContext,
6
+ type WorkflowDefinition,
7
+ } from "@agentswf/contract/workflow";
8
+ import { answer, type ParticipantScript, testWorkflow } from ".";
9
+
10
+ declare const runtime: string;
11
+
12
+ const investigate: WorkflowDefinition<null, string> = {
13
+ meta: { name: "investigate", description: "watches checkout-api" },
14
+ async run(workflow: WorkflowContext) {
15
+ const investigator = await workflow.agents.open({ key: "investigator", runtime });
16
+ const coordinator: ParticipantRef = await workflow.participants.connect({
17
+ key: "coordinator",
18
+ labels: { audience: "operator" },
19
+ });
20
+ await workflow.messages.allow({
21
+ between: [investigator, coordinator],
22
+ purpose: "report findings that hold up; ask when the logs can't settle it",
23
+ timeoutMs: 2 * 60 * 60_000,
24
+ });
25
+ const { outcome } = await investigator.run({ prompt: "Check the checkout-api logs." });
26
+ await coordinator.release("investigation done");
27
+ // A run has no deadline unless `awf run --timeout` gives it one.
28
+ const left = workflow.deadline ? workflow.deadline.unixMilliseconds - Date.now() : undefined;
29
+ return isAnswered(outcome) ? `${outcome.value} (${left ?? "no"} ms left)` : outcome.kind;
30
+ },
31
+ };
32
+
33
+ export async function story(): Promise<string> {
34
+ const run = await testWorkflow(investigate, null, {
35
+ agents: { investigator: [answer("…")] },
36
+ participants: {
37
+ coordinator: (event) =>
38
+ event.kind === "message" && event.asks
39
+ ? event.reply("Keep two, and link them.")
40
+ : undefined,
41
+ },
42
+ });
43
+ return run.value;
44
+ }
45
+
46
+ export const sendsFirst: ParticipantScript = async (event) => {
47
+ if (event.kind !== "route") return;
48
+ const sent = await event.send("Also check the 09:12 deploy.");
49
+ if (sent.code !== "delivered") throw new Error(sent.code);
50
+ };
51
+
52
+ export async function refused(workflow: WorkflowContext): Promise<void> {
53
+ // @ts-expect-error bindingPath is gone: the socket's place is fixed.
54
+ await workflow.participants.connect({ key: "coordinator", bindingPath: "/tmp/x" });
55
+ }
@@ -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 {
@@ -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
@@ -232,6 +238,51 @@ export function createScriptedHost(
232
238
  });
233
239
  };
234
240
 
241
+ /** `wf 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(`wf 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
288
  * after `wf send --await-reply`: nothing of the host's in flight, and no step taken since.
@@ -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,
@@ -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][] = [];
@@ -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;