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