@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.
- package/package.json +5 -6
- package/src/agent-cli/cli.ts +571 -0
- package/src/agent-cli/client.ts +115 -0
- package/src/agent-launcher.ts +15 -25
- package/src/allowance-command.ts +31 -21
- 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 +73 -22
- 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 +119 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/help/allowance.txt +13 -0
- package/src/help/emit.txt +13 -0
- package/src/help/inbox.txt +12 -0
- package/src/help/init.txt +12 -0
- package/src/help/logs.txt +13 -0
- package/src/help/reply.txt +18 -0
- package/src/help/run.txt +44 -0
- package/src/help/runs.txt +11 -0
- package/src/help/test.txt +9 -0
- package/src/help/tick.txt +12 -0
- package/src/here.ts +9 -3
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +279 -0
- package/src/inbox-command.ts +715 -0
- package/src/init-command.ts +29 -11
- package/src/init-template/AGENTS.md.template +4 -4
- 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 +106 -35
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +990 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/limits.ts +1 -1
- package/src/messaging/messaging.ts +478 -85
- package/src/messaging/routes.ts +11 -4
- package/src/messaging/text.ts +25 -22
- package/src/operation-liveness.ts +55 -9
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +8 -8
- 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 +29 -66
- 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 +2 -2
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +43 -25
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +11 -4
- package/src/simple-input.ts +268 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +89 -14
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +34 -11
- package/src/testing.ts +72 -4
- package/src/tick-command.ts +76 -0
- package/src/tools.ts +645 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1277 -97
- package/src/workflow-testing/coordinator.typecheck.ts +54 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +67 -7
- package/src/workflow-testing/index.ts +488 -34
- package/src/workflow-testing/script.ts +74 -4
- package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
package/src/commands.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { allowanceUsage } from "./allowance-command";
|
|
2
|
+
import { emitUsage } from "./emit-command";
|
|
3
|
+
import { replyUsage } from "./holder-command";
|
|
4
|
+
import { inboxUsage } from "./inbox-command";
|
|
5
|
+
import { initUsage } from "./init-command";
|
|
6
|
+
import { logsUsage } from "./logs-command";
|
|
7
|
+
import { usage as runUsage } from "./run-command";
|
|
8
|
+
import { shellLine } from "./run-commands";
|
|
9
|
+
import { runsUsage } from "./runs-command";
|
|
10
|
+
import { testUsage } from "./test-command";
|
|
11
|
+
import { tickUsage } from "./tick-command";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every `awf` command: what the index, `awf help` and the suggestion read, and what the dispatch is
|
|
15
|
+
* keyed by. An entry's commands share its help; each has its own line in the index.
|
|
16
|
+
*/
|
|
17
|
+
export const COMMANDS = [
|
|
18
|
+
{
|
|
19
|
+
group: "Run a workflow",
|
|
20
|
+
commands: { run: "Start a run of a workflow file, or continue one" },
|
|
21
|
+
help: runUsage,
|
|
22
|
+
},
|
|
23
|
+
{ group: "Run a workflow", commands: { test: "Run a workflow's tests" }, help: testUsage },
|
|
24
|
+
{
|
|
25
|
+
group: "Run a workflow",
|
|
26
|
+
commands: { init: "Set up a directory for writing workflows" },
|
|
27
|
+
help: initUsage,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
group: "Run a workflow",
|
|
31
|
+
commands: { emit: "Send an event to a workflow with events, starting or waking its run" },
|
|
32
|
+
help: emitUsage,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
group: "Run a workflow",
|
|
36
|
+
commands: { tick: "Wake the runs of workflows with events that are due" },
|
|
37
|
+
help: tickUsage,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
group: "Look at runs",
|
|
41
|
+
commands: { runs: "The runs here, or one run in depth" },
|
|
42
|
+
help: runsUsage,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
group: "Look at runs",
|
|
46
|
+
commands: { logs: "What a run did and is doing, merged by time" },
|
|
47
|
+
help: logsUsage,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
group: "Coordinate a run from outside",
|
|
51
|
+
commands: { inbox: "What agents said to a participant outside the run, and what it said back" },
|
|
52
|
+
help: inboxUsage,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
group: "Coordinate a run from outside",
|
|
56
|
+
commands: { reply: "Answer a question an agent asked", send: "Message an agent first" },
|
|
57
|
+
help: replyUsage,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
group: "This machine",
|
|
61
|
+
commands: { allowance: "What is left of each harness's plan" },
|
|
62
|
+
help: allowanceUsage,
|
|
63
|
+
},
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
type Entry = (typeof COMMANDS)[number];
|
|
67
|
+
type NamesOf<Each> = Each extends { commands: infer Commands } ? keyof Commands & string : never;
|
|
68
|
+
export type CommandName = NamesOf<Entry>;
|
|
69
|
+
|
|
70
|
+
const NAMES = COMMANDS.flatMap((entry) => Object.keys(entry.commands)) as CommandName[];
|
|
71
|
+
|
|
72
|
+
export function isCommand(word: string): word is CommandName {
|
|
73
|
+
return (NAMES as string[]).includes(word);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The help `awf help {name}` and `awf {name} -h` print. */
|
|
77
|
+
export function helpOf(name: CommandName): string {
|
|
78
|
+
return COMMANDS.find((entry) => name in entry.commands)!.help;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const WIDTH = Math.max(...NAMES.map((name) => name.length)) + 2;
|
|
82
|
+
|
|
83
|
+
function grouped(): [group: string, entries: Entry[]][] {
|
|
84
|
+
const groups = new Map<string, Entry[]>();
|
|
85
|
+
for (const entry of COMMANDS)
|
|
86
|
+
groups.set(entry.group, [...(groups.get(entry.group) ?? []), entry]);
|
|
87
|
+
return [...groups];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lines = (entry: Entry) =>
|
|
91
|
+
Object.entries(entry.commands).map(([name, summary]) => `${name.padEnd(WIDTH)}${summary}`);
|
|
92
|
+
|
|
93
|
+
/** What `awf`, `awf -h` and `awf help` print. */
|
|
94
|
+
export function index(): string {
|
|
95
|
+
return [
|
|
96
|
+
"awf runs workflows of coding agents.",
|
|
97
|
+
"",
|
|
98
|
+
"usage: awf <command> [arguments]",
|
|
99
|
+
"",
|
|
100
|
+
...grouped().flatMap(([group, entries]) => [
|
|
101
|
+
group,
|
|
102
|
+
...entries.flatMap(lines).map((line) => ` ${line}`),
|
|
103
|
+
"",
|
|
104
|
+
]),
|
|
105
|
+
"awf <command> -h a command's usage, options and exit codes",
|
|
106
|
+
"awf help reference every command's usage, on one page",
|
|
107
|
+
"awf --version",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A command's usage lines: its help up to the first blank line. */
|
|
112
|
+
export function usageLines(help: string): string[] {
|
|
113
|
+
const end = help.indexOf("\n\n");
|
|
114
|
+
return (end < 0 ? help : help.slice(0, end)).split("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What `awf help reference` prints: each command's summary and usage lines, grouped as the index. */
|
|
118
|
+
export function reference(): string {
|
|
119
|
+
return [
|
|
120
|
+
...grouped().flatMap(([group, entries]) => [
|
|
121
|
+
group,
|
|
122
|
+
...entries.flatMap((entry) => [
|
|
123
|
+
...lines(entry).map((line) => ` ${line}`),
|
|
124
|
+
...usageLines(entry.help).map((line) => ` ${line}`),
|
|
125
|
+
"",
|
|
126
|
+
]),
|
|
127
|
+
]),
|
|
128
|
+
"awf <command> -h for what each does, and its exit codes",
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const WORKFLOW_FILE = /\.(ts|mts|js|mjs)$/;
|
|
133
|
+
|
|
134
|
+
/** The one line `awf {word}` gets when no command is named `word`. */
|
|
135
|
+
export function unknownCommand(word: string): string {
|
|
136
|
+
const said = `awf: no command ${JSON.stringify(word)}`;
|
|
137
|
+
if (WORKFLOW_FILE.test(word)) return `${said}; did you mean: ${shellLine(["awf", "run", word])}`;
|
|
138
|
+
const meant = suggest(word);
|
|
139
|
+
const named =
|
|
140
|
+
meant.length === 0
|
|
141
|
+
? ""
|
|
142
|
+
: ` did you mean ${meant.length === 1 ? meant[0] : `${meant.slice(0, -1).join(", ")} or ${meant.at(-1)}`}?`;
|
|
143
|
+
return `${said};${named} awf -h lists the commands`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The commands `word` is the start of, else the closest within two edits; every one tied for
|
|
148
|
+
* closest.
|
|
149
|
+
*/
|
|
150
|
+
export function suggest(word: string): CommandName[] {
|
|
151
|
+
if (word === "") return [];
|
|
152
|
+
const started = NAMES.filter((name) => name.startsWith(word));
|
|
153
|
+
if (started.length > 0) return started;
|
|
154
|
+
const near = NAMES.map((name) => ({ name, distance: distance(word, name) })).filter(
|
|
155
|
+
(each) => each.distance <= 2,
|
|
156
|
+
);
|
|
157
|
+
const closest = Math.min(...near.map((each) => each.distance));
|
|
158
|
+
return near.filter((each) => each.distance === closest).map((each) => each.name);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Levenshtein distance. */
|
|
162
|
+
function distance(a: string, b: string): number {
|
|
163
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
164
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
165
|
+
const current = [i];
|
|
166
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
167
|
+
current[j] = Math.min(
|
|
168
|
+
previous[j]! + 1,
|
|
169
|
+
current[j - 1]! + 1,
|
|
170
|
+
previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
previous = current;
|
|
174
|
+
}
|
|
175
|
+
return previous[b.length]!;
|
|
176
|
+
}
|
package/src/control-plane.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type ControlResponse,
|
|
5
|
+
decodeControlRequest,
|
|
6
|
+
type GivenInput,
|
|
7
|
+
WIRE_VERSION,
|
|
8
|
+
} from "@agentswf/contract/wire";
|
|
4
9
|
import { isCode } from "./files";
|
|
5
10
|
import type { Messaging } from "./messaging/messaging";
|
|
6
11
|
import { UNRECORDED } from "./messaging/text";
|
|
7
12
|
import type { ResultSlotRegistry } from "./result-slots";
|
|
13
|
+
import type { ToolCalls } from "./tools";
|
|
14
|
+
|
|
15
|
+
/** What the control plane asks of the run's tools, by the agent whose socket asked. */
|
|
16
|
+
export type ToolHandler = Pick<ToolCalls<unknown>, "call" | "running" | "accepted">;
|
|
8
17
|
|
|
9
18
|
/** What the control plane asks of the run's messaging, by the agent whose socket asked. */
|
|
10
19
|
export type MessagingHandler = Pick<
|
|
@@ -12,10 +21,11 @@ export type MessagingHandler = Pick<
|
|
|
12
21
|
"checkFinish" | "peers" | "send" | "reply" | "owing"
|
|
13
22
|
>;
|
|
14
23
|
|
|
15
|
-
/** `
|
|
24
|
+
/** `wfa` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
|
|
16
25
|
const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
|
|
17
26
|
const MAX_CONTROL_CONNECTIONS = 64;
|
|
18
27
|
const CONNECTION_TIMEOUT_SECONDS = 30;
|
|
28
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
19
29
|
|
|
20
30
|
export type ResultChannel = {
|
|
21
31
|
/** The socket this agent's launcher connects to. */
|
|
@@ -36,6 +46,8 @@ export type ResultControlPlane = {
|
|
|
36
46
|
openChannel(agentId: string, onSession?: (id: string) => void): Promise<ResultChannel>;
|
|
37
47
|
/** The run's messaging, once it exists; until then `peers`, `send` and `reply` are refused. */
|
|
38
48
|
attach(messaging: MessagingHandler): void;
|
|
49
|
+
/** The run's tools, once it has them; until then `tool` is refused. */
|
|
50
|
+
attachTools(tools: ToolHandler): void;
|
|
39
51
|
close(): Promise<void>;
|
|
40
52
|
};
|
|
41
53
|
|
|
@@ -91,6 +103,7 @@ export async function startResultControlPlane(options: {
|
|
|
91
103
|
await chmod(directory, 0o300);
|
|
92
104
|
|
|
93
105
|
let messaging: MessagingHandler | undefined;
|
|
106
|
+
let tools: ToolHandler | undefined;
|
|
94
107
|
// Shared across channels: the budget protects the engine, not any one agent.
|
|
95
108
|
let activeConnections = 0;
|
|
96
109
|
let accepting = true;
|
|
@@ -175,13 +188,22 @@ export async function startResultControlPlane(options: {
|
|
|
175
188
|
const { promise: written, resolve } = Promise.withResolvers<void>();
|
|
176
189
|
socket.data.responded = resolve;
|
|
177
190
|
const handling = Promise.race([
|
|
178
|
-
handleFrame(
|
|
179
|
-
|
|
180
|
-
options.slots,
|
|
191
|
+
handleFrame(Buffer.concat(socket.data.chunks).toString("utf8"), {
|
|
192
|
+
slots: options.slots,
|
|
181
193
|
messaging,
|
|
194
|
+
tools,
|
|
182
195
|
agentId,
|
|
183
196
|
onSession,
|
|
184
|
-
|
|
197
|
+
// A tool call lasts as long as its budget, not the connection's 30 s.
|
|
198
|
+
lifetime: (milliseconds) => {
|
|
199
|
+
if (socket.data.lifetime) clearTimeout(socket.data.lifetime);
|
|
200
|
+
// One past a timer's range is as good as none: the call's own end closes it.
|
|
201
|
+
socket.data.lifetime =
|
|
202
|
+
milliseconds === undefined || milliseconds > MAX_TIMER_MS
|
|
203
|
+
? undefined
|
|
204
|
+
: setTimeout(() => socket.terminate(), milliseconds);
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
185
207
|
written.then(() => undefined),
|
|
186
208
|
])
|
|
187
209
|
.then((response) => {
|
|
@@ -249,6 +271,9 @@ export async function startResultControlPlane(options: {
|
|
|
249
271
|
attach(handler) {
|
|
250
272
|
messaging = handler;
|
|
251
273
|
},
|
|
274
|
+
attachTools(handler) {
|
|
275
|
+
tools = handler;
|
|
276
|
+
},
|
|
252
277
|
close() {
|
|
253
278
|
closing ??= (async () => {
|
|
254
279
|
accepting = false;
|
|
@@ -287,14 +312,18 @@ function emptyConnection(): ConnectionState {
|
|
|
287
312
|
};
|
|
288
313
|
}
|
|
289
314
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
agentId: string
|
|
295
|
-
onSession: ((id: string) => void) | undefined
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
type Handlers = {
|
|
316
|
+
slots: ResultSlotRegistry;
|
|
317
|
+
messaging: MessagingHandler | undefined;
|
|
318
|
+
tools: ToolHandler | undefined;
|
|
319
|
+
agentId: string;
|
|
320
|
+
onSession: ((id: string) => void) | undefined;
|
|
321
|
+
lifetime: (milliseconds: number | undefined) => void;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
async function handleFrame(frame: string, handlers: Handlers): Promise<ControlResponse> {
|
|
325
|
+
const { messaging, agentId } = handlers;
|
|
326
|
+
const response = await answerFrame(frame, handlers);
|
|
298
327
|
// Leads every response, so what is owed comes back after a compaction; not one that already
|
|
299
328
|
// says it, about the wait it began or the answer it is refused for.
|
|
300
329
|
const repeats =
|
|
@@ -308,10 +337,7 @@ async function handleFrame(
|
|
|
308
337
|
|
|
309
338
|
async function answerFrame(
|
|
310
339
|
frame: string,
|
|
311
|
-
slots:
|
|
312
|
-
messaging: MessagingHandler | undefined,
|
|
313
|
-
agentId: string,
|
|
314
|
-
onSession: ((id: string) => void) | undefined,
|
|
340
|
+
{ slots, messaging, tools, agentId, onSession, lifetime }: Handlers,
|
|
315
341
|
): Promise<ControlResponse> {
|
|
316
342
|
if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
|
|
317
343
|
return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
|
|
@@ -329,6 +355,17 @@ async function answerFrame(
|
|
|
329
355
|
if (decoded.value.session) onSession?.(decoded.value.session);
|
|
330
356
|
|
|
331
357
|
const request = decoded.value;
|
|
358
|
+
if (request.command === "tool") {
|
|
359
|
+
if (!tools) return rejected("unsupported-command", "tools are not available in this run");
|
|
360
|
+
let answer: Awaited<ReturnType<ToolHandler["call"]>>;
|
|
361
|
+
try {
|
|
362
|
+
answer = await tools.call(agentId, request, lifetime);
|
|
363
|
+
} catch {
|
|
364
|
+
return rejected("internal-error", "the tool call failed internally; it may have run");
|
|
365
|
+
}
|
|
366
|
+
if (answer.kind === "refused") return rejected(answer.code, answer.error);
|
|
367
|
+
return { version: WIRE_VERSION, ...answer };
|
|
368
|
+
}
|
|
332
369
|
if (request.command !== "result" && request.command !== "waiting") {
|
|
333
370
|
if (!messaging)
|
|
334
371
|
return rejected("unsupported-command", "messaging is not available in this run");
|
|
@@ -358,6 +395,15 @@ async function answerFrame(
|
|
|
358
395
|
}
|
|
359
396
|
// Before the slot is asked: a message operation has none, and an answer owed comes first.
|
|
360
397
|
const gate = messaging?.checkFinish(agentId, request.operationId, request.command);
|
|
398
|
+
const running = tools?.running(agentId);
|
|
399
|
+
if (running && gate?.kind !== "refused") {
|
|
400
|
+
const by =
|
|
401
|
+
running.settledBy === undefined ? "" : ` by ${new Date(running.settledBy).toISOString()}`;
|
|
402
|
+
return rejected(
|
|
403
|
+
"tool-running",
|
|
404
|
+
`your call of ${running.tool} is still running; it will have answered${by || " when it ends"}. Run this again then.`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
361
407
|
if (gate?.kind === "refused") return rejected(gate.code, gate.error);
|
|
362
408
|
if (gate?.kind === "awaiting" && request.command === "waiting") {
|
|
363
409
|
const { peer, until, notice } = gate;
|
|
@@ -378,17 +424,22 @@ async function answerFrame(
|
|
|
378
424
|
const result = await slots.submit({
|
|
379
425
|
operationId: request.operationId,
|
|
380
426
|
agentId,
|
|
381
|
-
|
|
427
|
+
...givenOf(request),
|
|
382
428
|
source: "control-plane",
|
|
383
429
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
430
|
+
if (result.kind !== "accepted") return rejected(result.code, result.error);
|
|
431
|
+
tools?.accepted(request.operationId);
|
|
432
|
+
return { version: WIRE_VERSION, kind: "accepted" };
|
|
387
433
|
} catch {
|
|
388
434
|
return rejected("internal-error", "control request failed internally");
|
|
389
435
|
}
|
|
390
436
|
}
|
|
391
437
|
|
|
438
|
+
/** The input as the wire decoded it, without the request's other fields. */
|
|
439
|
+
function givenOf({ raw, args }: GivenInput): GivenInput {
|
|
440
|
+
return raw !== undefined ? { raw } : args !== undefined ? { args } : {};
|
|
441
|
+
}
|
|
442
|
+
|
|
392
443
|
function rejected(
|
|
393
444
|
code: Extract<ControlResponse, { kind: "rejected" }>["code"],
|
|
394
445
|
error: string,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HolderRequest,
|
|
3
|
+
HolderResult,
|
|
4
|
+
MessagingEventBody,
|
|
5
|
+
OpenQuestion,
|
|
6
|
+
} from "./messaging/messaging";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Answers for a run's outside participants, as a holder does with `awf reply` and `awf send`. Its
|
|
10
|
+
* message numbers are the attempt's; `awf inbox` names them `{attempt}.{n}`.
|
|
11
|
+
*/
|
|
12
|
+
export type CoordinatorHolder = {
|
|
13
|
+
reply(request: HolderRequest): Promise<HolderResult>;
|
|
14
|
+
send(request: HolderRequest): Promise<HolderResult>;
|
|
15
|
+
/** Questions open to outside participants now, oldest first. */
|
|
16
|
+
open(): OpenQuestion[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What answers for a run's outside participants plugs in here: `awf run`'s prompt, the socket, or a
|
|
21
|
+
* test's scripts. Told each messaging event once the run's log has it, and handed the holder before
|
|
22
|
+
* the workflow runs. Reachable through `RunWorkflowOptions`, as `awaitReply` is, but not a
|
|
23
|
+
* published surface: holders use `awf inbox`, `awf reply` and `awf send`.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export type CoordinatorPort = {
|
|
27
|
+
attach(holder: CoordinatorHolder): void;
|
|
28
|
+
recorded(event: MessagingEventBody): void;
|
|
29
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createInterface, type Interface } from "node:readline/promises";
|
|
2
|
+
import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
|
|
3
|
+
import type { MessagingEventBody } from "./messaging/messaging";
|
|
4
|
+
import { shellLine } from "./run-commands";
|
|
5
|
+
|
|
6
|
+
/** Where the prompt reads a line: `awf run`'s terminal, or a test's script. */
|
|
7
|
+
export type LineReader = {
|
|
8
|
+
/** The line typed after `prompt`; undefined when `signal` aborts first, or input ended. */
|
|
9
|
+
read(prompt: string, signal: AbortSignal): Promise<string | undefined>;
|
|
10
|
+
/** Writes a line above the prompt open, if any, and draws it again under it. */
|
|
11
|
+
above(line: string): void;
|
|
12
|
+
close(): void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** What the prompt shows beside the progress view, which holds its block while a prompt is open. */
|
|
16
|
+
export type PromptOutput = {
|
|
17
|
+
/** A line in the run's log, above the progress block. */
|
|
18
|
+
log(line: string): void;
|
|
19
|
+
/** The progress block stops redrawing while a prompt waits for a line, and lines go above it. */
|
|
20
|
+
hold(above: ((line: string) => void) | undefined): void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Question = {
|
|
24
|
+
coordinator: string;
|
|
25
|
+
message: number;
|
|
26
|
+
asker: string;
|
|
27
|
+
text: string;
|
|
28
|
+
/** Asked after others, its text shown when it came in may have scrolled away: shown again. */
|
|
29
|
+
again: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** What an agent wrote, safe on a terminal: no escape sequences or other control characters. */
|
|
33
|
+
function shown(text: string): string[] {
|
|
34
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it strips.
|
|
35
|
+
return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").split("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const indented = (text: string) => shown(text).map((line) => ` ${line}`);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `awf run`'s interactive mode: each message to a coordinator is a line in the run's log, and each
|
|
42
|
+
* question is asked at the terminal, oldest first, for every coordinator. An empty line leaves it
|
|
43
|
+
* open for another holder; one another holder answers, or that closes, is passed over.
|
|
44
|
+
*/
|
|
45
|
+
export function promptCoordinator(
|
|
46
|
+
reader: LineReader,
|
|
47
|
+
output: PromptOutput,
|
|
48
|
+
): CoordinatorPort & { close(): void; shows(message: { from: string; to: string }): boolean } {
|
|
49
|
+
let holder: CoordinatorHolder | undefined;
|
|
50
|
+
const connected = new Set<string>();
|
|
51
|
+
const queue: Question[] = [];
|
|
52
|
+
let asking: { question: Question; stop: AbortController } | undefined;
|
|
53
|
+
/** Questions this prompt answered: their answers are what was typed, shown already. */
|
|
54
|
+
const typed = new Set<number>();
|
|
55
|
+
/** Questions still open, by number: an end names its asker. */
|
|
56
|
+
const open = new Map<number, Question>();
|
|
57
|
+
let closed = false;
|
|
58
|
+
|
|
59
|
+
const ask = async (question: Question): Promise<void> => {
|
|
60
|
+
if (question.again)
|
|
61
|
+
output.log([`● ${question.asker} asked:`, ...indented(question.text)].join("\n"));
|
|
62
|
+
const stop = new AbortController();
|
|
63
|
+
asking = { question, stop };
|
|
64
|
+
output.hold((line) => reader.above(line));
|
|
65
|
+
let line: string | undefined;
|
|
66
|
+
try {
|
|
67
|
+
const as = connected.size > 1 ? ` as ${question.coordinator}` : "";
|
|
68
|
+
line = (await reader.read(`reply to ${question.asker}${as} › `, stop.signal))?.trim();
|
|
69
|
+
} finally {
|
|
70
|
+
// Released before the reply: what it records comes as lines, not under a prompt answered.
|
|
71
|
+
asking = undefined;
|
|
72
|
+
output.hold(undefined);
|
|
73
|
+
}
|
|
74
|
+
if (!line || !holder) return;
|
|
75
|
+
// Before the reply: its record arrives while it is being made.
|
|
76
|
+
typed.add(question.message);
|
|
77
|
+
const result = await holder.reply({
|
|
78
|
+
coordinator: question.coordinator,
|
|
79
|
+
agent: question.asker,
|
|
80
|
+
to: question.message,
|
|
81
|
+
text: line,
|
|
82
|
+
via: "terminal",
|
|
83
|
+
});
|
|
84
|
+
if (result.code !== "delivered") {
|
|
85
|
+
typed.delete(question.message);
|
|
86
|
+
output.log(`awf: not delivered: ${result.error}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const next = async (): Promise<void> => {
|
|
91
|
+
while (!asking && !closed && holder) {
|
|
92
|
+
const question = queue.shift();
|
|
93
|
+
if (!question) return;
|
|
94
|
+
// One that fails is said and passed over; the others are still asked.
|
|
95
|
+
await ask(question).catch((error: unknown) =>
|
|
96
|
+
output.log(`awf: the prompt failed: ${error instanceof Error ? error.message : error}`),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ended = (message: number) => {
|
|
102
|
+
const at = queue.findIndex((each) => each.message === message);
|
|
103
|
+
if (at >= 0) queue.splice(at, 1);
|
|
104
|
+
if (asking?.question.message === message) asking.stop.abort();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
attach(given) {
|
|
109
|
+
holder = given;
|
|
110
|
+
},
|
|
111
|
+
recorded(event: MessagingEventBody) {
|
|
112
|
+
if (event.kind === "participant-connected") connected.add(event.participant);
|
|
113
|
+
if (event.kind === "request-ended" && connected.has(event.owedBy)) {
|
|
114
|
+
ended(event.message);
|
|
115
|
+
const question = open.get(event.message);
|
|
116
|
+
open.delete(event.message);
|
|
117
|
+
if (question && event.end !== "replied")
|
|
118
|
+
output.log(`● ${question.asker}'s question closed unanswered: ${event.end}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (event.kind !== "message-sent") return;
|
|
122
|
+
// The coordinator's answers and sends that came another way: a holder's, or a script's.
|
|
123
|
+
if (connected.has(event.from)) {
|
|
124
|
+
if (event.replyTo !== undefined && typed.has(event.replyTo)) return;
|
|
125
|
+
const how = "via" in event && event.via ? ` (${event.via})` : "";
|
|
126
|
+
output.log([`● ${event.from} → ${event.to}${how}:`, ...indented(event.text)].join("\n"));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!connected.has(event.to)) return;
|
|
130
|
+
const asks = event.awaitsReply !== undefined;
|
|
131
|
+
output.log(
|
|
132
|
+
[`● ${event.from} ${asks ? "asks" : `→ ${event.to}`}:`, ...indented(event.text)].join("\n"),
|
|
133
|
+
);
|
|
134
|
+
if (!asks) return;
|
|
135
|
+
const question = {
|
|
136
|
+
coordinator: event.to,
|
|
137
|
+
message: event.message,
|
|
138
|
+
asker: event.from,
|
|
139
|
+
text: event.text,
|
|
140
|
+
again: asking !== undefined || queue.length > 0,
|
|
141
|
+
};
|
|
142
|
+
open.set(event.message, question);
|
|
143
|
+
queue.push(question);
|
|
144
|
+
void next();
|
|
145
|
+
},
|
|
146
|
+
shows: ({ from, to }) => connected.has(from) || connected.has(to),
|
|
147
|
+
close() {
|
|
148
|
+
closed = true;
|
|
149
|
+
queue.length = 0;
|
|
150
|
+
asking?.stop.abort();
|
|
151
|
+
reader.close();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reads lines from this process's terminal, writing on stderr beside the progress view. It holds
|
|
158
|
+
* stdin only while a question is asked, so typing at any other time reaches nothing, and a run
|
|
159
|
+
* that asks none leaves the terminal as it found it.
|
|
160
|
+
*/
|
|
161
|
+
export function terminalReader(): LineReader {
|
|
162
|
+
let lines: Interface | undefined;
|
|
163
|
+
let closed = false;
|
|
164
|
+
const clearLine = () => process.stderr.write("\r\x1b[2K");
|
|
165
|
+
return {
|
|
166
|
+
async read(prompt, signal) {
|
|
167
|
+
if (closed) return undefined;
|
|
168
|
+
const current = createInterface({
|
|
169
|
+
input: process.stdin,
|
|
170
|
+
output: process.stderr,
|
|
171
|
+
terminal: true,
|
|
172
|
+
});
|
|
173
|
+
lines = current;
|
|
174
|
+
// Ctrl-C at the prompt asks the run to stop, as it does anywhere else.
|
|
175
|
+
current.on("SIGINT", () => process.kill(process.pid, "SIGINT"));
|
|
176
|
+
// Ctrl-D ends the input: the question is left open, as an empty line leaves it.
|
|
177
|
+
const ended = new Promise<undefined>((resolve) =>
|
|
178
|
+
current.once("close", () => resolve(undefined)),
|
|
179
|
+
);
|
|
180
|
+
// The progress view hides the cursor and clips long lines; typing needs both back.
|
|
181
|
+
process.stderr.write("\x1b[?7h\x1b[?25h");
|
|
182
|
+
try {
|
|
183
|
+
return await Promise.race([current.question(prompt, { signal }), ended]);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (signal.aborted) return undefined;
|
|
186
|
+
throw error;
|
|
187
|
+
} finally {
|
|
188
|
+
if (signal.aborted || closed) clearLine();
|
|
189
|
+
lines = undefined;
|
|
190
|
+
current.close();
|
|
191
|
+
process.stderr.write("\x1b[?25l\x1b[?7l");
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
above(line) {
|
|
195
|
+
clearLine();
|
|
196
|
+
process.stderr.write(`${line}\n`);
|
|
197
|
+
lines?.prompt(true);
|
|
198
|
+
},
|
|
199
|
+
close() {
|
|
200
|
+
closed = true;
|
|
201
|
+
if (lines) clearLine();
|
|
202
|
+
lines?.close();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Without a prompt (`--json`, or no terminal): each question to a coordinator is a line with its
|
|
209
|
+
* text and the command that answers it, so a run awaiting one does not look stuck.
|
|
210
|
+
*/
|
|
211
|
+
export function replyHints(
|
|
212
|
+
run: string,
|
|
213
|
+
/** What finds the run from another command, such as `--run-root`. */
|
|
214
|
+
place: readonly string[],
|
|
215
|
+
attempt: number,
|
|
216
|
+
log: (line: string) => void,
|
|
217
|
+
): CoordinatorPort & { shows(message: { to: string; asks?: boolean }): boolean } {
|
|
218
|
+
const connected = new Set<string>();
|
|
219
|
+
return {
|
|
220
|
+
attach() {},
|
|
221
|
+
recorded(event: MessagingEventBody) {
|
|
222
|
+
if (event.kind === "participant-connected") connected.add(event.participant);
|
|
223
|
+
if (event.kind !== "message-sent" || !connected.has(event.to) || !event.awaitsReply) return;
|
|
224
|
+
const id = `${attempt}.${event.message}`;
|
|
225
|
+
const as = connected.size > 1 ? ["--as", event.to] : [];
|
|
226
|
+
const command = ["awf", "reply", run, event.from, "--to", id, ...as, ...place];
|
|
227
|
+
log(
|
|
228
|
+
[
|
|
229
|
+
`● ${event.from} asks ${event.to} (${id}):`,
|
|
230
|
+
...indented(event.text),
|
|
231
|
+
` answer: ${shellLine(command)} '…'`,
|
|
232
|
+
].join("\n"),
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
shows: ({ to, asks }) => asks === true && connected.has(to),
|
|
236
|
+
};
|
|
237
|
+
}
|