@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.
- 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
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<
|
|
@@ -16,6 +25,7 @@ export type MessagingHandler = Pick<
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import type { ParticipantResultJson } from "@agentswf/contract/records";
|
|
5
|
+
import { decodeHolderRequest, type NudgeWireResult } from "@agentswf/contract/wire";
|
|
6
|
+
import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
|
|
7
|
+
import { realPath } from "./files";
|
|
8
|
+
import type { MessagingEventBody } from "./messaging/messaging";
|
|
9
|
+
import type { RunStatus } from "./run-state";
|
|
10
|
+
|
|
11
|
+
/** A request is a line of text and its JSON; well past a message's own 16 KB limit. */
|
|
12
|
+
const MAX_REQUEST_BYTES = 256 * 1024;
|
|
13
|
+
const CONNECTION_LIFETIME_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
type Connection = {
|
|
16
|
+
chunks: Buffer[];
|
|
17
|
+
bytes: number;
|
|
18
|
+
/** Its request was taken, answered or refused: nothing more is read. */
|
|
19
|
+
taken: boolean;
|
|
20
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A run's coordinator socket: named by a hash of its run directory, so any depth of directory fits
|
|
25
|
+
* `sun_path`'s 104 bytes, in a folder only the operator can open.
|
|
26
|
+
*/
|
|
27
|
+
export function coordinatorSocketPath(dir: string, runDir: string): string {
|
|
28
|
+
// Its real path: a holder reaching it through a symlink, or `/tmp` for `/private/tmp`, names it alike.
|
|
29
|
+
const name = createHash("sha256").update(realPath(runDir)).digest("hex").slice(0, 32);
|
|
30
|
+
return join(dir, `${name}.sock`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refuses a coordinator folder inside the workflow's directory, where its agents work, through
|
|
35
|
+
* symlinks too, and one given as a relative path, which each command would read from where it ran.
|
|
36
|
+
* Then makes it, the operator's alone, before any sandbox is resolved against it.
|
|
37
|
+
*/
|
|
38
|
+
export async function prepareCoordinatorDir(
|
|
39
|
+
dir: string,
|
|
40
|
+
workflowDir: string,
|
|
41
|
+
/** It was moved with `AWF_COORDINATOR_DIR`; `~/.awf`'s own is kept from sandboxes as `~/.awf` is. */
|
|
42
|
+
moved = true,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (!isAbsolute(dir)) throw new Error(`AWF_COORDINATOR_DIR ${dir} is not an absolute path`);
|
|
45
|
+
const real = async (path: string): Promise<string> => {
|
|
46
|
+
try {
|
|
47
|
+
return await realpath(path);
|
|
48
|
+
} catch {
|
|
49
|
+
// Not there yet: its parent's real path, and its own name.
|
|
50
|
+
const parent = dirname(path);
|
|
51
|
+
return parent === path ? path : join(await real(parent), basename(path));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const inside = relative(await real(workflowDir), await real(dir));
|
|
55
|
+
const within =
|
|
56
|
+
inside === "" || !(inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside));
|
|
57
|
+
if (moved && within)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`AWF_COORDINATOR_DIR ${dir} is inside the workflow's directory, where its agents work; move it out`,
|
|
60
|
+
);
|
|
61
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
62
|
+
await chmod(dir, 0o700);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The socket `awf reply` and `awf send` reach a live run through. It opens once the workflow
|
|
67
|
+
* connects an outside participant and goes when the run ends; each connection is one request
|
|
68
|
+
* line and one `ParticipantResultJson` line back.
|
|
69
|
+
*/
|
|
70
|
+
export function coordinatorSocket(options: {
|
|
71
|
+
dir: string;
|
|
72
|
+
runDir: string;
|
|
73
|
+
attempt: number;
|
|
74
|
+
/** Why the socket did not open: the run goes on, and no holder's command reaches it. */
|
|
75
|
+
failed(reason: string): void;
|
|
76
|
+
/** A nudge: the run's mailbox has a new event. Given, the socket opens at once, for awf's nudge. */
|
|
77
|
+
nudged?(): void;
|
|
78
|
+
}): CoordinatorPort & { close(): Promise<void>; readonly path: string } {
|
|
79
|
+
const path = coordinatorSocketPath(options.dir, options.runDir);
|
|
80
|
+
let holder: CoordinatorHolder | undefined;
|
|
81
|
+
let listener: Bun.UnixSocketListener<{ chunks: Buffer[]; bytes: number }> | undefined;
|
|
82
|
+
let opening: Promise<void> | undefined;
|
|
83
|
+
let closed = false;
|
|
84
|
+
const id = (n: number) => `${options.attempt}.${n}`;
|
|
85
|
+
|
|
86
|
+
/** Undefined for a request no `awf` command makes: the connection closes unanswered. */
|
|
87
|
+
const answer = async (
|
|
88
|
+
frame: string,
|
|
89
|
+
): Promise<ParticipantResultJson | NudgeWireResult | undefined> => {
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(frame);
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const decoded = decodeHolderRequest(parsed);
|
|
97
|
+
if (!decoded.ok) return undefined;
|
|
98
|
+
const request = decoded.value;
|
|
99
|
+
if (request.command === "nudge") {
|
|
100
|
+
if (!options.nudged) return undefined;
|
|
101
|
+
options.nudged();
|
|
102
|
+
return { v: 1, code: "nudged" };
|
|
103
|
+
}
|
|
104
|
+
if (!holder) return refused("no-participant", "the run has no participant connected");
|
|
105
|
+
// Another attempt's question is never open in this one: a number no message has.
|
|
106
|
+
const to =
|
|
107
|
+
request.to === undefined
|
|
108
|
+
? undefined
|
|
109
|
+
: request.to.split(".")[0] === String(options.attempt)
|
|
110
|
+
? Number(request.to.split(".")[1])
|
|
111
|
+
: 0;
|
|
112
|
+
const asked = {
|
|
113
|
+
...(request.participant === undefined ? {} : { coordinator: request.participant }),
|
|
114
|
+
agent: request.agent,
|
|
115
|
+
...(to === undefined ? {} : { to }),
|
|
116
|
+
text: request.text,
|
|
117
|
+
via: "command" as const,
|
|
118
|
+
};
|
|
119
|
+
const result =
|
|
120
|
+
request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
|
|
121
|
+
return result.code === "delivered"
|
|
122
|
+
? { v: 1, code: "delivered", id: id(result.message) }
|
|
123
|
+
: {
|
|
124
|
+
v: 1,
|
|
125
|
+
code: result.code,
|
|
126
|
+
error: result.error,
|
|
127
|
+
...(result.choices ? { choices: result.choices } : {}),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const open = async () => {
|
|
132
|
+
// A socket a killed run left here is no one's: this run's directory names it.
|
|
133
|
+
await rm(path, { force: true });
|
|
134
|
+
if (closed) return;
|
|
135
|
+
listener = Bun.listen<Connection>({
|
|
136
|
+
unix: path,
|
|
137
|
+
allowHalfOpen: true,
|
|
138
|
+
data: { chunks: [], bytes: 0, taken: false },
|
|
139
|
+
socket: {
|
|
140
|
+
open(socket) {
|
|
141
|
+
socket.data = {
|
|
142
|
+
chunks: [],
|
|
143
|
+
bytes: 0,
|
|
144
|
+
taken: false,
|
|
145
|
+
// Only while the request is read: an answer being made is never cut off.
|
|
146
|
+
timer: setTimeout(() => socket.terminate(), CONNECTION_LIFETIME_MS),
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
data(socket, data) {
|
|
150
|
+
if (socket.data.taken) return;
|
|
151
|
+
socket.data.bytes += data.byteLength;
|
|
152
|
+
if (socket.data.bytes > MAX_REQUEST_BYTES) {
|
|
153
|
+
take(socket);
|
|
154
|
+
socket.end(`${JSON.stringify(refused("too-large", "the request is too large"))}\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
socket.data.chunks.push(Buffer.from(data));
|
|
158
|
+
// A request is one line: it is answered at its end, whether or not the client half-closes.
|
|
159
|
+
const text = Buffer.concat(socket.data.chunks).toString("utf8");
|
|
160
|
+
const newline = text.indexOf("\n");
|
|
161
|
+
if (newline >= 0) respond(socket, text.slice(0, newline));
|
|
162
|
+
},
|
|
163
|
+
end(socket) {
|
|
164
|
+
if (!socket.data.taken)
|
|
165
|
+
respond(socket, Buffer.concat(socket.data.chunks).toString("utf8"));
|
|
166
|
+
},
|
|
167
|
+
close(socket) {
|
|
168
|
+
clearTimeout(socket.data.timer);
|
|
169
|
+
},
|
|
170
|
+
error(socket) {
|
|
171
|
+
socket.terminate();
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
await chmod(path, 0o600);
|
|
176
|
+
};
|
|
177
|
+
const take = (socket: Bun.Socket<Connection>) => {
|
|
178
|
+
socket.data.taken = true;
|
|
179
|
+
clearTimeout(socket.data.timer);
|
|
180
|
+
};
|
|
181
|
+
const respond = (socket: Bun.Socket<Connection>, frame: string) => {
|
|
182
|
+
take(socket);
|
|
183
|
+
void answer(frame)
|
|
184
|
+
.catch(() => undefined)
|
|
185
|
+
.then((result) => socket.end(result ? `${JSON.stringify(result)}\n` : undefined));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (options.nudged)
|
|
189
|
+
opening = open().catch((error: unknown) =>
|
|
190
|
+
options.failed(error instanceof Error ? error.message : String(error)),
|
|
191
|
+
);
|
|
192
|
+
return {
|
|
193
|
+
path,
|
|
194
|
+
attach(given) {
|
|
195
|
+
holder = given;
|
|
196
|
+
},
|
|
197
|
+
recorded(event: MessagingEventBody) {
|
|
198
|
+
if (event.kind === "participant-connected" && !opening) {
|
|
199
|
+
opening = open().catch((error: unknown) =>
|
|
200
|
+
options.failed(error instanceof Error ? error.message : String(error)),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
async close() {
|
|
205
|
+
closed = true;
|
|
206
|
+
await opening;
|
|
207
|
+
listener?.stop(true);
|
|
208
|
+
if (listener) await rm(path, { force: true });
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function refused(
|
|
214
|
+
code: Exclude<ParticipantResultJson["code"], "delivered">,
|
|
215
|
+
error: string,
|
|
216
|
+
): ParticipantResultJson {
|
|
217
|
+
return { v: 1, code, error };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The socket of a run still live, or undefined: `run-state` decides, so a socket a killed run left
|
|
222
|
+
* behind is not trusted.
|
|
223
|
+
*/
|
|
224
|
+
export async function liveCoordinatorSocket(
|
|
225
|
+
dir: string,
|
|
226
|
+
status: Pick<RunStatus, "state" | "run">,
|
|
227
|
+
): Promise<string | undefined> {
|
|
228
|
+
if (status.state !== "running") return undefined;
|
|
229
|
+
const path = coordinatorSocketPath(dir, status.run.dir);
|
|
230
|
+
return (await stat(path).then(
|
|
231
|
+
() => true,
|
|
232
|
+
() => false,
|
|
233
|
+
))
|
|
234
|
+
? path
|
|
235
|
+
: undefined;
|
|
236
|
+
}
|