@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,279 @@
1
+ import { homedir } from "node:os";
2
+ import type { ParticipantResultJson } from "@agentswf/contract/records";
3
+ import { type ParticipantCode, WIRE_VERSION } from "@agentswf/contract/wire";
4
+ import { liveCoordinatorSocket } from "./coordinator-socket";
5
+ import { deliver, eventIdProblem } from "./emit";
6
+ import { printEmit } from "./emit-command";
7
+ import { messageOf } from "./errors";
8
+ import { isCode } from "./files";
9
+ import help from "./help/reply.txt" with { type: "text" };
10
+ import { ItemFold } from "./inbox-command";
11
+ import { coordinatorDir } from "./machine";
12
+ import { failed, type Out, parsePlace, type ReadPlace } from "./run-commands";
13
+ import { findRunByRef, RunNotFound, type RunStatus } from "./run-state";
14
+ import { readAttempts, readEvents, readNumberedEvents } from "./runs";
15
+ import { exchange } from "./socket-exchange";
16
+
17
+ export const replyUsage = help.trimEnd();
18
+
19
+ type Options = {
20
+ command: "reply" | "send";
21
+ ref: string;
22
+ author?: string;
23
+ to?: string;
24
+ text?: string;
25
+ } & (
26
+ | { agent: string; as?: string; id?: undefined }
27
+ /**
28
+ * `--event-id`: the message's own id, which a participant routed to the code takes as its event's. It
29
+ * goes to no agent.
30
+ */
31
+ | { id: string; as: string; agent?: undefined }
32
+ );
33
+
34
+ export type HolderIo = Out & {
35
+ /** Stdin, when no text is given as an argument. */
36
+ stdin(): Promise<string>;
37
+ /** Then there is nothing piped in to read: refused rather than waited on. */
38
+ stdinIsTerminal?: boolean;
39
+ environment?: Readonly<Record<string, string | undefined>>;
40
+ home?: string;
41
+ };
42
+
43
+ export async function holderCommand(
44
+ argv: readonly string[],
45
+ shellCwd: string,
46
+ io: HolderIo,
47
+ ): Promise<number> {
48
+ // Help only as the first word after the command: a message's text may say anything.
49
+ if (argv[1] === "-h" || argv[1] === "--help") {
50
+ io.stdout(replyUsage);
51
+ return 0;
52
+ }
53
+ let place: ReadPlace;
54
+ let json: boolean;
55
+ let options: Options;
56
+ try {
57
+ const parsed = parsePlace(argv.slice(1), shellCwd);
58
+ place = parsed.place;
59
+ json = parsed.json;
60
+ options = parseHolder(argv[0] as "reply" | "send", parsed.rest);
61
+ } catch (error) {
62
+ io.stderr(`awf: ${messageOf(error)}\n\n${replyUsage}`);
63
+ return 2;
64
+ }
65
+ const say = (result: ParticipantResultJson, line: string): number => {
66
+ if (json) io.stdout(JSON.stringify(result));
67
+ else if (result.code === "delivered") io.stdout(line);
68
+ else
69
+ io.stderr(
70
+ `awf: ${options.command} refused (${result.code}): ${result.error}${result.choices ? ` Choose: ${result.choices.join(", ")}` : ""}`,
71
+ );
72
+ return result.code === "delivered" ? 0 : 1;
73
+ };
74
+ const refused = (code: ParticipantCode, error: string) => say({ v: 1, code, error }, "");
75
+ const usage = (problem: string) => {
76
+ io.stderr(`awf: ${problem}\n\n${replyUsage}`);
77
+ return 2;
78
+ };
79
+ if (options.text !== undefined && options.text.trim() === "")
80
+ return usage("nothing to say: the text is blank");
81
+ if (options.text === undefined && io.stdinIsTerminal)
82
+ return usage("nothing to say: give the text as an argument, or pipe it in");
83
+ if (options.id !== undefined) return toTheCode(options, place, json, io, { refused, usage });
84
+ try {
85
+ let status: RunStatus;
86
+ try {
87
+ status = await findRunByRef(options.ref, place, io.home ?? homedir());
88
+ } catch (error) {
89
+ if (error instanceof RunNotFound) return refused("not-found", error.message);
90
+ throw error;
91
+ }
92
+ if (status.state !== "running")
93
+ return refused("ended", `${status.ref} has ended (${status.state}); start a new run`);
94
+ // After the run is found: one that is not there says so before stdin is waited on.
95
+ const text = options.text ?? (await io.stdin());
96
+ if (text.trim() === "") return usage("nothing to say: stdin was empty");
97
+ const unreachable = () =>
98
+ refused(
99
+ "no-participant",
100
+ `${status.ref} has no participant${options.as ? ` ${options.as}` : ""} to reach yet; try again once it connects one`,
101
+ );
102
+ const dir = coordinatorDir(io.home ?? homedir(), io.environment ?? process.env);
103
+ // Live with no socket: its attempt has not connected one yet, or is opening it.
104
+ const socket = await liveCoordinatorSocket(dir, status);
105
+ if (!socket) return unreachable();
106
+ const request = {
107
+ version: WIRE_VERSION,
108
+ command: options.command,
109
+ ...(options.as === undefined ? {} : { participant: options.as }),
110
+ agent: options.agent,
111
+ ...(options.to === undefined ? {} : { to: options.to }),
112
+ text,
113
+ };
114
+ let answer: string;
115
+ try {
116
+ answer = await exchange(socket, `${JSON.stringify(request)}\n`);
117
+ } catch (error) {
118
+ // A socket an attempt left when it was killed: this attempt has not opened its own yet.
119
+ if (["ECONNREFUSED", "ENOENT", "ENOTSOCK"].some((code) => isCode(error, code)))
120
+ return unreachable();
121
+ throw error;
122
+ }
123
+ if (!answer)
124
+ throw new Error(
125
+ `${status.ref} did not answer the request; it may run another version of awf`,
126
+ );
127
+ const result = JSON.parse(answer) as ParticipantResultJson;
128
+ if (result.code !== "delivered") return say(result, "");
129
+ if (options.command === "reply") return say(result, `answered ${result.id}`);
130
+ // Held for an agent awaiting this participant's answer: it comes with that answer.
131
+ const awaited = await awaitedBy(status, options.agent, options.as);
132
+ return say(
133
+ result,
134
+ `delivered as ${result.id}${awaited ? `; ${options.agent} still awaits the reply to ${awaited}` : ""}`,
135
+ );
136
+ } catch (error) {
137
+ return failed(error, place, io, messageOf(error));
138
+ }
139
+ }
140
+
141
+ function parseHolder(command: "reply" | "send", rest: readonly string[]): Options {
142
+ const words: string[] = [];
143
+ let to: string | undefined;
144
+ let as: string | undefined;
145
+ let id: string | undefined;
146
+ let author: string | undefined;
147
+ for (let index = 0; index < rest.length; index += 1) {
148
+ const arg = rest[index]!;
149
+ const value = () => {
150
+ const given = rest[index + 1];
151
+ if (given === undefined) throw new Error(`${arg} needs a value`);
152
+ index += 1;
153
+ return given;
154
+ };
155
+ if (arg === "--to" && command === "reply") to = value();
156
+ else if (arg === "--as") as = value();
157
+ else if (arg === "--event-id" && command === "send") id = value();
158
+ else if (arg === "--author" && command === "send") author = value();
159
+ else if (arg === "--") {
160
+ words.push(...rest.slice(index + 1));
161
+ break;
162
+ } else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`);
163
+ else words.push(arg);
164
+ }
165
+ // With --event-id, the participant's reply goes to the run's code: there is no agent to name.
166
+ let target: { agent: string; as?: string } | { id: string; as: string };
167
+ let ref: string | undefined;
168
+ let text: string | undefined;
169
+ let extra: string[];
170
+ if (id !== undefined) {
171
+ [ref, text, ...extra] = words;
172
+ if (!ref) throw new Error("send needs a run, <workflow>/<id> or <id>");
173
+ if (!as) throw new Error("--event-id sends as a participant routed to the code: --as names it");
174
+ const problem = eventIdProblem(id);
175
+ if (problem) throw new Error(`--event-id ${id}: ${problem}`);
176
+ target = { id, as };
177
+ } else {
178
+ let agent: string | undefined;
179
+ [ref, agent, text, ...extra] = words;
180
+ if (author !== undefined) throw new Error("--author goes with --event-id");
181
+ if (!ref || !agent)
182
+ throw new Error(`${command} needs a run, <workflow>/<id> or <id>, and an agent`);
183
+ target = { agent, ...(as === undefined ? {} : { as }) };
184
+ }
185
+ if (extra.length > 0) throw new Error("the text is one argument: quote it, or pipe it in");
186
+ if (to !== undefined && !/^\d+\.\d+$/.test(to))
187
+ throw new Error(`--to takes a question's id as awf inbox prints it, such as 1.5, not ${to}`);
188
+ return {
189
+ command,
190
+ ref,
191
+ ...target,
192
+ ...(to === undefined ? {} : { to }),
193
+ ...(text === undefined ? {} : { text }),
194
+ ...(author === undefined ? {} : { author }),
195
+ };
196
+ }
197
+
198
+ /** The question `agent` still awaits from the coordinator, as the run's log has it. */
199
+ async function awaitedBy(
200
+ status: RunStatus,
201
+ agent: string,
202
+ as: string | undefined,
203
+ ): Promise<string | undefined> {
204
+ const items =
205
+ new ItemFold()
206
+ .add(await readNumberedEvents(status.run.dir))
207
+ .items(await readAttempts(status.run), true) ?? [];
208
+ const closed = new Set(
209
+ items.flatMap((item) => (item.kind === "answered" || item.kind === "expired" ? [item.id] : [])),
210
+ );
211
+ const open = items.flatMap((item) =>
212
+ item.kind === "message" &&
213
+ item.asks === true &&
214
+ item.from === agent &&
215
+ (as === undefined || item.participant === as) &&
216
+ !closed.has(item.id)
217
+ ? [item.id]
218
+ : [],
219
+ );
220
+ return open.at(-1);
221
+ }
222
+
223
+ /**
224
+ * `awf send {ref} --as {participant} --event-id {message id} {text}`: a reply on a participant the run
225
+ * routed to the code, delivered through the one way in as `participant:reply`, so it continues a
226
+ * parked run. The run is found here, or in a project awf started an event run in (Q15).
227
+ */
228
+ async function toTheCode(
229
+ options: Extract<Options, { id: string }>,
230
+ place: ReadPlace,
231
+ json: boolean,
232
+ io: HolderIo,
233
+ say: { refused(code: ParticipantCode, error: string): number; usage(problem: string): number },
234
+ ): Promise<number> {
235
+ const home = io.home ?? homedir();
236
+ const environment = io.environment ?? process.env;
237
+ const { id, as } = options;
238
+ const { refused } = say;
239
+ let found: RunStatus;
240
+ try {
241
+ found = await findRunByRef(options.ref, place, home);
242
+ } catch (error) {
243
+ if (error instanceof RunNotFound) return refused("not-found", error.message);
244
+ return failed(error, place, io, messageOf(error));
245
+ }
246
+ const { run } = found;
247
+ const routed = (await readEvents(run.dir)).some(
248
+ (line) =>
249
+ line.kind === "participant-connected" && line.participant === as && line.to === "code",
250
+ );
251
+ if (!run.record.events || !routed)
252
+ return refused(
253
+ "no-participant",
254
+ `${found.ref} has no participant ${as} routed to its code; --event-id is for one`,
255
+ );
256
+ const text = options.text ?? (await io.stdin());
257
+ if (text.trim() === "") return say.usage("nothing to say: stdin was empty");
258
+ try {
259
+ const result = await deliver(
260
+ {
261
+ runRoot: run.root,
262
+ workflow: run.record.workflow,
263
+ runId: run.record.id,
264
+ event: {
265
+ id,
266
+ name: "participant:reply",
267
+ data: { text, ...(options.author === undefined ? {} : { author: options.author }) },
268
+ from: as,
269
+ at: new Date().toISOString(),
270
+ },
271
+ deduplicate: true,
272
+ },
273
+ { home, environment },
274
+ );
275
+ return printEmit(result, json, io);
276
+ } catch (error) {
277
+ return failed(error, place, io, messageOf(error));
278
+ }
279
+ }