@agentswf/engine 0.1.0-beta.3 → 0.1.0-beta.5
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 +611 -0
- package/src/agent-cli/client.ts +115 -0
- package/src/agent-launcher.ts +15 -25
- package/src/allowance-command.ts +3 -15
- package/src/attempt-ending.ts +2 -4
- package/src/attempt-output.ts +3 -2
- package/src/attempt-view.ts +1 -1
- package/src/control-plane.ts +38 -6
- package/src/coordinator-socket.ts +15 -6
- package/src/deadlines.ts +69 -2
- package/src/decisions/directory.ts +9 -11
- package/src/deprecations.ts +177 -0
- package/src/duration.ts +50 -20
- package/src/emit-command.ts +57 -54
- package/src/emit.ts +124 -15
- package/src/help/allowance.txt +13 -0
- package/src/help/emit.txt +21 -0
- package/src/help/inbox.txt +12 -0
- package/src/help/init.txt +12 -0
- package/src/help/logs.txt +14 -0
- package/src/help/reply.txt +18 -0
- package/src/help/run.txt +58 -0
- package/src/help/runs.txt +11 -0
- package/src/help/test.txt +9 -0
- package/src/help/tick.txt +12 -0
- package/src/herdr-run-session.ts +2 -2
- package/src/here.ts +1 -1
- package/src/holder-command.ts +38 -51
- package/src/inbox-command.ts +9 -24
- package/src/init-command.ts +2 -11
- package/src/init-template/AGENTS.md.template +3 -3
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +6 -6
- package/src/keep-alive.ts +28 -13
- package/src/logs-command.ts +14 -27
- package/src/mailbox.ts +16 -21
- package/src/messaging/limits.ts +5 -5
- package/src/messaging/messaging.ts +48 -43
- package/src/messaging/routes.ts +2 -2
- package/src/messaging/text.ts +24 -22
- package/src/operation-liveness.ts +1 -1
- package/src/operator-cli.ts +23 -9
- package/src/operator-runtime.ts +9 -12
- package/src/pane-layout.ts +125 -32
- package/src/progress-view.ts +8 -8
- package/src/result-validation.ts +1 -1
- package/src/run-command.ts +27 -47
- package/src/run-commands.ts +1 -1
- package/src/run-prepare.ts +46 -8
- package/src/run-usage.ts +2 -2
- package/src/run-view.ts +9 -4
- package/src/runs-command.ts +14 -23
- package/src/runs.ts +44 -8
- package/src/sandboxes.ts +3 -2
- package/src/simple-input.ts +13 -3
- package/src/sources.ts +42 -6
- package/src/stage-ledger.ts +43 -13
- package/src/stage-plan.ts +37 -1
- package/src/test-command.ts +9 -14
- package/src/testing.ts +8 -9
- package/src/tick-command.ts +2 -13
- package/src/tools.ts +62 -25
- package/src/workflow-loader.ts +5 -2
- package/src/workflow-runner.ts +636 -271
- package/src/workflow-testing/coordinator.typecheck.ts +12 -13
- package/src/workflow-testing/coordinators.ts +2 -2
- package/src/workflow-testing/host.ts +115 -46
- package/src/workflow-testing/index.ts +45 -837
- package/src/workflow-testing/run-test.ts +1213 -0
- package/src/workflow-testing/script.ts +106 -45
- package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createConnection, type Socket } from "node:net";
|
|
2
|
+
import {
|
|
3
|
+
type ControlRequest,
|
|
4
|
+
decodeResponseFor,
|
|
5
|
+
type ResponseFor,
|
|
6
|
+
type ResultSubmitRequest,
|
|
7
|
+
type ResultSubmitResponse,
|
|
8
|
+
type WaitingRequest,
|
|
9
|
+
type WaitingResponse,
|
|
10
|
+
} from "@agentswf/contract/wire";
|
|
11
|
+
|
|
12
|
+
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
13
|
+
|
|
14
|
+
/** The connection never opened: nothing was written, so nothing reached the engine. */
|
|
15
|
+
export class NotSentError extends Error {}
|
|
16
|
+
|
|
17
|
+
export function submitResult(
|
|
18
|
+
endpoint: string,
|
|
19
|
+
request: ResultSubmitRequest,
|
|
20
|
+
timeoutSeconds = 30,
|
|
21
|
+
connect: (endpoint: string) => Socket = createConnection,
|
|
22
|
+
): Promise<ResultSubmitResponse> {
|
|
23
|
+
return submitControl(endpoint, request, timeoutSeconds, connect);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function submitWaiting(
|
|
27
|
+
endpoint: string,
|
|
28
|
+
request: WaitingRequest,
|
|
29
|
+
timeoutSeconds = 30,
|
|
30
|
+
connect: (endpoint: string) => Socket = createConnection,
|
|
31
|
+
): Promise<WaitingResponse> {
|
|
32
|
+
return submitControl(endpoint, request, timeoutSeconds, connect);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sends one request and resolves with a response that answers its command. */
|
|
36
|
+
export function submitControl<R extends ControlRequest>(
|
|
37
|
+
endpoint: string,
|
|
38
|
+
request: R,
|
|
39
|
+
/** Null waits as long as the engine takes, as a tool call does: it answers once the call settles. */
|
|
40
|
+
timeoutSeconds: number | null = 30,
|
|
41
|
+
connect: (endpoint: string) => Socket = createConnection,
|
|
42
|
+
): Promise<ResponseFor[R["command"]]> {
|
|
43
|
+
type Response = ResponseFor[R["command"]];
|
|
44
|
+
if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
|
|
45
|
+
return Promise.reject(new Error("control-plane timeout must be positive"));
|
|
46
|
+
}
|
|
47
|
+
const outgoing = Buffer.from(`${JSON.stringify(request)}\n`);
|
|
48
|
+
|
|
49
|
+
return new Promise<Response>((resolve, reject) => {
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
let bytes = 0;
|
|
52
|
+
let settled = false;
|
|
53
|
+
let connected = false;
|
|
54
|
+
const socket = connect(endpoint);
|
|
55
|
+
const lifetime =
|
|
56
|
+
timeoutSeconds === null
|
|
57
|
+
? undefined
|
|
58
|
+
: setTimeout(
|
|
59
|
+
() => fail(new Error("control plane response timed out")),
|
|
60
|
+
timeoutSeconds * 1000,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const fail = (error: Error): void => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(lifetime);
|
|
67
|
+
socket.destroy();
|
|
68
|
+
reject(error);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
socket.once("connect", () => {
|
|
72
|
+
connected = true;
|
|
73
|
+
socket.end(outgoing);
|
|
74
|
+
});
|
|
75
|
+
socket.on("data", (data) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
const chunk = typeof data === "string" ? Buffer.from(data) : data;
|
|
78
|
+
bytes += chunk.byteLength;
|
|
79
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
80
|
+
fail(new Error("control-plane response exceeds the size limit"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
chunks.push(chunk);
|
|
84
|
+
});
|
|
85
|
+
socket.once("end", () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
const frame = Buffer.concat(chunks).toString("utf8");
|
|
88
|
+
if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
|
|
89
|
+
fail(new Error("control plane returned an invalid response frame"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(frame.slice(0, -1));
|
|
95
|
+
} catch {
|
|
96
|
+
fail(new Error("control plane returned invalid JSON"));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const decoded = decodeResponseFor<R["command"]>(request.command, parsed);
|
|
100
|
+
if (!decoded.ok) {
|
|
101
|
+
fail(new Error(decoded.error));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(lifetime);
|
|
106
|
+
resolve(decoded.value);
|
|
107
|
+
});
|
|
108
|
+
socket.once("error", (error) =>
|
|
109
|
+
fail(connected ? error : new NotSentError(error.message, { cause: error })),
|
|
110
|
+
);
|
|
111
|
+
socket.once("close", () => {
|
|
112
|
+
if (!settled) fail(new Error("control plane closed without a response"));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/agent-launcher.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { chmod, mkdir,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { type AgentDoor, shellQuote } from "@agentswf/sandbox";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const AGENT_CLI = fileURLToPath(new URL("./agent-cli/cli.ts", import.meta.url));
|
|
7
|
+
|
|
8
|
+
/** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wfa`'s. */
|
|
7
9
|
const NO_ENV = "--no-env-file";
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -27,19 +29,18 @@ export async function installAgentLauncher(
|
|
|
27
29
|
*/
|
|
28
30
|
sessionEnv?: string,
|
|
29
31
|
): Promise<string> {
|
|
30
|
-
|
|
31
|
-
return writeLauncher(directory, [process.execPath, NO_ENV, command], endpoint, sessionEnv);
|
|
32
|
+
return writeLauncher(directory, [process.execPath, NO_ENV, AGENT_CLI], endpoint, sessionEnv);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
* A sandboxed agent's door: the same launcher, running the bundled `
|
|
36
|
+
* A sandboxed agent's door: the same launcher, running the bundled `wfa` with the host's bun by
|
|
36
37
|
* its real path, since a sandbox reads neither the workspace's sources nor bun's links under `~`.
|
|
37
38
|
* A box copies `boxScript` to the launcher's path, which runs the bundle with the box's own bun.
|
|
38
39
|
*/
|
|
39
40
|
export async function installSandboxedDoor(
|
|
40
41
|
directory: string,
|
|
41
42
|
endpoint: string,
|
|
42
|
-
/** The bundled `
|
|
43
|
+
/** The bundled `wfa`'s source, written beside the launcher. */
|
|
43
44
|
source: string,
|
|
44
45
|
sessionEnv?: string,
|
|
45
46
|
): Promise<AgentDoor> {
|
|
@@ -50,7 +51,7 @@ export async function installSandboxedDoor(
|
|
|
50
51
|
const socket = join(real, basename(endpoint));
|
|
51
52
|
// In the agent's own directory: bun will not load a module from the control plane's, which is
|
|
52
53
|
// not listable so that no agent can find another's socket.
|
|
53
|
-
const bundle = join(real, "
|
|
54
|
+
const bundle = join(real, "wfa.js");
|
|
54
55
|
await writeFile(bundle, source, { mode: 0o500 });
|
|
55
56
|
const launcher = await writeLauncher(real, [bun, NO_ENV, bundle], socket, sessionEnv);
|
|
56
57
|
return {
|
|
@@ -64,12 +65,12 @@ export async function installSandboxedDoor(
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
|
-
* `
|
|
68
|
+
* `wfa` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
|
|
68
69
|
* a `bun build` of its own: in process, the bundler resolved packages against the test runner's
|
|
69
70
|
* state and missed the workspace's links.
|
|
70
71
|
*/
|
|
71
72
|
export async function buildAgentBundle(): Promise<string> {
|
|
72
|
-
const entry =
|
|
73
|
+
const entry = AGENT_CLI;
|
|
73
74
|
const built = Bun.spawn({
|
|
74
75
|
cmd: [process.execPath, "build", entry, "--target=bun"],
|
|
75
76
|
cwd: dirname(entry),
|
|
@@ -81,7 +82,7 @@ export async function buildAgentBundle(): Promise<string> {
|
|
|
81
82
|
new Response(built.stdout).text(),
|
|
82
83
|
new Response(built.stderr).text(),
|
|
83
84
|
]);
|
|
84
|
-
if (code !== 0) throw new Error(`could not bundle
|
|
85
|
+
if (code !== 0) throw new Error(`could not bundle wfa: ${stderr.trim().slice(0, 400)}`);
|
|
85
86
|
return bundle;
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -93,7 +94,7 @@ async function writeLauncher(
|
|
|
93
94
|
): Promise<string> {
|
|
94
95
|
await mkdir(directory, { recursive: true });
|
|
95
96
|
await chmod(directory, 0o700);
|
|
96
|
-
const path = join(directory, "
|
|
97
|
+
const path = join(directory, "wfa");
|
|
97
98
|
// Not writable, so an agent that redirects into its own launcher fails rather than replacing its
|
|
98
99
|
// only way to answer. A `chmod` still gets past it; a sandbox denies the write outright.
|
|
99
100
|
await writeFile(path, launcherScript(command, endpoint, sessionEnv, path), { mode: 0o500 });
|
|
@@ -105,7 +106,7 @@ function launcherScript(
|
|
|
105
106
|
command: readonly string[],
|
|
106
107
|
endpoint: string,
|
|
107
108
|
sessionEnv: string | undefined,
|
|
108
|
-
/** Its own path, which `
|
|
109
|
+
/** Its own path, which `wfa` names in what it prints, as an agent must type it. */
|
|
109
110
|
path: string,
|
|
110
111
|
): string {
|
|
111
112
|
if (sessionEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sessionEnv)) {
|
|
@@ -114,14 +115,3 @@ function launcherScript(
|
|
|
114
115
|
const session = sessionEnv ? ` --session "\${${sessionEnv}:-}"` : "";
|
|
115
116
|
return `#!/bin/sh\nexec ${command.map(shellQuote).join(" ")} --at ${shellQuote(endpoint)} --as ${shellQuote(path)}${session} "$@"\n`;
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
async function resolveAgentCommand(): Promise<string> {
|
|
119
|
-
const require = createRequire(import.meta.url);
|
|
120
|
-
const manifestPath = require.resolve("@agentswf/wf/package.json");
|
|
121
|
-
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
|
|
122
|
-
bin?: string | Record<string, string>;
|
|
123
|
-
};
|
|
124
|
-
const relative = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.wf;
|
|
125
|
-
if (!relative) throw new Error("@agentswf/wf does not publish the wf command");
|
|
126
|
-
return resolve(dirname(manifestPath), relative);
|
|
127
|
-
}
|
package/src/allowance-command.ts
CHANGED
|
@@ -4,24 +4,12 @@ import type {
|
|
|
4
4
|
HarnessAllowance,
|
|
5
5
|
} from "@agentswf/contract/records";
|
|
6
6
|
import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
|
|
7
|
-
import {
|
|
7
|
+
import { planPrice } from "./accounting/plans";
|
|
8
8
|
import { messageOf } from "./errors";
|
|
9
|
+
import help from "./help/allowance.txt" with { type: "text" };
|
|
9
10
|
import type { Out } from "./run-commands";
|
|
10
11
|
|
|
11
|
-
export const allowanceUsage =
|
|
12
|
-
"usage: awf allowance [harness...] [--json]",
|
|
13
|
-
"",
|
|
14
|
-
"What is left of each harness's plan, as the harness itself shows it: claude's /usage, codex's",
|
|
15
|
-
"/status, and cursor's /usage, read in a Herdr pane. Nothing asks a model. Every harness when",
|
|
16
|
-
`none is named: ${HARNESS_NAMES.join(", ")}.`,
|
|
17
|
-
"",
|
|
18
|
-
"A plan's monthly price is the vendor's list price as of the table's date, not what was",
|
|
19
|
-
`charged: ${PLAN_BASIS}.`,
|
|
20
|
-
"",
|
|
21
|
-
`--json prints one document, the interface for scripts, with the plan as the`,
|
|
22
|
-
"harness names it and no price; what it prints otherwise is for reading. A harness that could",
|
|
23
|
-
"not be read says why. Exits 0 once every harness was asked, 2 on a usage error.",
|
|
24
|
-
].join("\n");
|
|
12
|
+
export const allowanceUsage = help.trimEnd();
|
|
25
13
|
|
|
26
14
|
export type AllowanceCommand = { harnesses: Harness[]; json: boolean } | "help";
|
|
27
15
|
|
package/src/attempt-ending.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:os";
|
|
2
|
-
import type {
|
|
2
|
+
import type { StageRecord } from "@agentswf/contract/records";
|
|
3
3
|
import {
|
|
4
4
|
type AbsoluteDeadline,
|
|
5
5
|
type AttemptOutcome,
|
|
@@ -37,8 +37,6 @@ export type AttemptEnd = {
|
|
|
37
37
|
* the run's stages to choose from.
|
|
38
38
|
*/
|
|
39
39
|
choose?: readonly StageRecord[];
|
|
40
|
-
/** The stages whose values `--values` gives, to start at a later one. */
|
|
41
|
-
needs?: readonly StageNeed[];
|
|
42
40
|
};
|
|
43
41
|
|
|
44
42
|
/** What a started run came to: the result it returned, or what it threw. */
|
|
@@ -107,12 +105,12 @@ export function decideEnding(
|
|
|
107
105
|
reason: stop?.reason ?? errorDetail(error),
|
|
108
106
|
stages,
|
|
109
107
|
continue: goOn(stop, stages),
|
|
108
|
+
...(stop?.needs ? { needs: [...stop.needs] } : {}),
|
|
110
109
|
},
|
|
111
110
|
...(settled ? { settled } : {}),
|
|
112
111
|
exitCode: cancellation ? signalExitCode(cancellation.reason) : OUTCOMES[kind].exitCode!,
|
|
113
112
|
...(beside ? { alsoFailed: beside } : {}),
|
|
114
113
|
...(stop instanceof FromStageUnreached ? { choose: stop.recorded } : {}),
|
|
115
|
-
...(stop?.needs ? { needs: stop.needs } : {}),
|
|
116
114
|
};
|
|
117
115
|
}
|
|
118
116
|
|
package/src/attempt-output.ts
CHANGED
|
@@ -58,7 +58,7 @@ export async function keepRecords(
|
|
|
58
58
|
outcome: ending.kind,
|
|
59
59
|
reason: ending.reason,
|
|
60
60
|
...at,
|
|
61
|
-
...(
|
|
61
|
+
...(ending.needs ? { needs: ending.needs } : {}),
|
|
62
62
|
};
|
|
63
63
|
const kept: Kept = report ? { report } : {};
|
|
64
64
|
const output = outputFile(dir);
|
|
@@ -70,6 +70,7 @@ export async function keepRecords(
|
|
|
70
70
|
} else {
|
|
71
71
|
const record: OutputRecord = {
|
|
72
72
|
version: OUTPUT_RECORD_VERSION,
|
|
73
|
+
ref: `${context.loaded.workflow.meta.name}/${settled.runId}`,
|
|
73
74
|
runId: settled.runId,
|
|
74
75
|
attempt: attempt.record.attempt,
|
|
75
76
|
workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
|
|
@@ -107,7 +108,7 @@ export async function keepRecords(
|
|
|
107
108
|
const unacknowledged =
|
|
108
109
|
ending.kind === "completed" && attempt.run.record.events
|
|
109
110
|
? await readMailbox(dir).then(
|
|
110
|
-
// The event that started the run is `
|
|
111
|
+
// The event that started the run is `wf.started`'s, taken or not.
|
|
111
112
|
(records) => {
|
|
112
113
|
const state = foldMailbox(records);
|
|
113
114
|
return state.unacknowledged.filter((id) => id !== state.started?.id).length;
|
package/src/attempt-view.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function printEnding(
|
|
|
71
71
|
records: pathFrom(shellCwd, context.run.dir, { home: context.home }),
|
|
72
72
|
...(kept.report ? { report: pathFrom(shellCwd, kept.report, { home: context.home }) } : {}),
|
|
73
73
|
...(end.choose?.length ? { choose: listRecorded(end.choose, context.now) } : {}),
|
|
74
|
-
...(end.needs ? { needs: end.needs } : {}),
|
|
74
|
+
...("needs" in end.ending && end.ending.needs ? { needs: end.ending.needs } : {}),
|
|
75
75
|
...(settled?.panes ? { panes: settled.panes } : {}),
|
|
76
76
|
// As the run's Herdr workspace is labelled.
|
|
77
77
|
runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
|
package/src/control-plane.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type MessagingHandler = Pick<
|
|
|
21
21
|
"checkFinish" | "peers" | "send" | "reply" | "owing"
|
|
22
22
|
>;
|
|
23
23
|
|
|
24
|
-
/** `
|
|
24
|
+
/** `wfa` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
|
|
25
25
|
const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
|
|
26
26
|
const MAX_CONTROL_CONNECTIONS = 64;
|
|
27
27
|
const CONNECTION_TIMEOUT_SECONDS = 30;
|
|
@@ -321,16 +321,17 @@ type Handlers = {
|
|
|
321
321
|
lifetime: (milliseconds: number | undefined) => void;
|
|
322
322
|
};
|
|
323
323
|
|
|
324
|
-
async function handleFrame(frame: string, handlers: Handlers): Promise<
|
|
324
|
+
async function handleFrame(frame: string, handlers: Handlers): Promise<Answer> {
|
|
325
325
|
const { messaging, agentId } = handlers;
|
|
326
326
|
const response = await answerFrame(frame, handlers);
|
|
327
|
+
if (inOtherVersion(response)) return response;
|
|
327
328
|
// Leads every response, so what is owed comes back after a compaction; not one that already
|
|
328
329
|
// says it, about the wait it began or the answer it is refused for.
|
|
329
330
|
const repeats =
|
|
330
331
|
response.kind === "awaiting-reply" ||
|
|
331
332
|
(response.kind === "sent" && response.until !== undefined) ||
|
|
332
333
|
(response.kind === "rejected" &&
|
|
333
|
-
(response.code === "owes-reply" || response.code === "awaiting"));
|
|
334
|
+
(response.code === "owes-reply" || response.code === "already-awaiting"));
|
|
334
335
|
const owing = repeats ? undefined : messaging?.owing(agentId);
|
|
335
336
|
return owing ? { ...response, owing } : response;
|
|
336
337
|
}
|
|
@@ -338,7 +339,7 @@ async function handleFrame(frame: string, handlers: Handlers): Promise<ControlRe
|
|
|
338
339
|
async function answerFrame(
|
|
339
340
|
frame: string,
|
|
340
341
|
{ slots, messaging, tools, agentId, onSession, lifetime }: Handlers,
|
|
341
|
-
): Promise<
|
|
342
|
+
): Promise<Answer> {
|
|
342
343
|
if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
|
|
343
344
|
return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
|
|
344
345
|
}
|
|
@@ -350,7 +351,10 @@ async function answerFrame(
|
|
|
350
351
|
return rejected("invalid-request", "request is not valid JSON");
|
|
351
352
|
}
|
|
352
353
|
const decoded = decodeControlRequest(parsed);
|
|
353
|
-
if (!decoded.ok)
|
|
354
|
+
if (!decoded.ok)
|
|
355
|
+
return decoded.code === "unsupported-version"
|
|
356
|
+
? otherVersion((parsed as { version: number }).version)
|
|
357
|
+
: rejected(decoded.code, decoded.error);
|
|
354
358
|
// Before the result is judged: a rejected submission still proves which session sent it.
|
|
355
359
|
if (decoded.value.session) onSession?.(decoded.value.session);
|
|
356
360
|
|
|
@@ -447,7 +451,35 @@ function rejected(
|
|
|
447
451
|
return { version: WIRE_VERSION, kind: "rejected", code, error };
|
|
448
452
|
}
|
|
449
453
|
|
|
450
|
-
|
|
454
|
+
/** A refusal in another wire version than this engine's: the one answer not a `ControlResponse`. */
|
|
455
|
+
type OtherVersion = {
|
|
456
|
+
version: number;
|
|
457
|
+
kind: "rejected";
|
|
458
|
+
code: "unsupported-version";
|
|
459
|
+
error: string;
|
|
460
|
+
};
|
|
461
|
+
type Answer = ControlResponse | OtherVersion;
|
|
462
|
+
|
|
463
|
+
function inOtherVersion(answer: Answer): answer is OtherVersion {
|
|
464
|
+
return answer.version !== WIRE_VERSION;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Refused in the version the request gave: a `wfa` of version 4 or 5 decodes a rejection of that
|
|
469
|
+
* shape and prints its error, where it would refuse this engine's version unread. They meet when
|
|
470
|
+
* awf is installed anew in place while a run is live: a launcher runs the `wfa` on disk, which is
|
|
471
|
+
* no longer the one the engine in memory shipped with.
|
|
472
|
+
*/
|
|
473
|
+
function otherVersion(version: number): OtherVersion {
|
|
474
|
+
return {
|
|
475
|
+
version,
|
|
476
|
+
kind: "rejected",
|
|
477
|
+
code: "unsupported-version",
|
|
478
|
+
error: `this wfa speaks wire version ${version} and the workflow engine ${WIRE_VERSION}: awf was installed anew while the run was live. Nothing was done; the run must go on under one version of awf.`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function queueResponse(socket: Bun.Socket<ConnectionState>, response: Answer): void {
|
|
451
483
|
socket.data.outgoing = Buffer.from(`${JSON.stringify(response)}\n`);
|
|
452
484
|
socket.data.written = 0;
|
|
453
485
|
flushResponse(socket);
|
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
4
|
import type { ParticipantResultJson } from "@agentswf/contract/records";
|
|
5
|
-
import { decodeHolderRequest, type NudgeWireResult } from "@agentswf/contract/wire";
|
|
5
|
+
import { decodeHolderRequest, type NudgeWireResult, WIRE_VERSION } from "@agentswf/contract/wire";
|
|
6
6
|
import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
|
|
7
7
|
import { realPath } from "./files";
|
|
8
8
|
import type { MessagingEventBody } from "./messaging/messaging";
|
|
@@ -83,7 +83,10 @@ export function coordinatorSocket(options: {
|
|
|
83
83
|
let closed = false;
|
|
84
84
|
const id = (n: number) => `${options.attempt}.${n}`;
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Undefined for a request no `awf` command makes: the connection closes unanswered. One of
|
|
88
|
+
* another wire version is refused naming both, which an older `awf send` or `awf reply` prints.
|
|
89
|
+
*/
|
|
87
90
|
const answer = async (
|
|
88
91
|
frame: string,
|
|
89
92
|
): Promise<ParticipantResultJson | NudgeWireResult | undefined> => {
|
|
@@ -94,7 +97,13 @@ export function coordinatorSocket(options: {
|
|
|
94
97
|
return undefined;
|
|
95
98
|
}
|
|
96
99
|
const decoded = decodeHolderRequest(parsed);
|
|
97
|
-
if (!decoded.ok)
|
|
100
|
+
if (!decoded.ok)
|
|
101
|
+
return decoded.code === "unsupported-version"
|
|
102
|
+
? refused(
|
|
103
|
+
"unsupported-version",
|
|
104
|
+
`this awf speaks wire version ${(parsed as { version: number }).version} and the run's engine ${WIRE_VERSION}: awf was installed anew while the run was live. Reach it with the awf it started with, or let it end and continue it under this one.`,
|
|
105
|
+
)
|
|
106
|
+
: undefined;
|
|
98
107
|
const request = decoded.value;
|
|
99
108
|
if (request.command === "nudge") {
|
|
100
109
|
if (!options.nudged) return undefined;
|
|
@@ -119,9 +128,9 @@ export function coordinatorSocket(options: {
|
|
|
119
128
|
const result =
|
|
120
129
|
request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
|
|
121
130
|
return result.code === "delivered"
|
|
122
|
-
? { v:
|
|
131
|
+
? { v: 2, code: "delivered", id: id(result.message) }
|
|
123
132
|
: {
|
|
124
|
-
v:
|
|
133
|
+
v: 2,
|
|
125
134
|
code: result.code,
|
|
126
135
|
error: result.error,
|
|
127
136
|
...(result.choices ? { choices: result.choices } : {}),
|
|
@@ -214,7 +223,7 @@ function refused(
|
|
|
214
223
|
code: Exclude<ParticipantResultJson["code"], "delivered">,
|
|
215
224
|
error: string,
|
|
216
225
|
): ParticipantResultJson {
|
|
217
|
-
return { v:
|
|
226
|
+
return { v: 2, code, error };
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
/**
|
package/src/deadlines.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AbsoluteDeadline,
|
|
3
|
+
DeadlineExceededError,
|
|
4
|
+
positiveDurationMilliseconds,
|
|
5
|
+
type Timeout,
|
|
6
|
+
} from "@agentswf/contract/workflow";
|
|
2
7
|
|
|
3
8
|
export class WorkflowCancelledError extends Error {
|
|
4
9
|
constructor(readonly reason: unknown) {
|
|
@@ -96,11 +101,73 @@ export function deadlineWithin(
|
|
|
96
101
|
ceiling: AbsoluteDeadline | undefined,
|
|
97
102
|
): AbsoluteDeadline {
|
|
98
103
|
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
|
|
99
|
-
throw new Error(
|
|
104
|
+
throw new Error(`timeoutMs is a positive duration, in whole milliseconds: not ${milliseconds}`);
|
|
100
105
|
}
|
|
101
106
|
return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
/** A call's bound as a workflow gives it: `timeout`, or the deprecated `deadline` or `timeoutMs`. */
|
|
110
|
+
export type GivenBound = {
|
|
111
|
+
timeout?: Timeout;
|
|
112
|
+
deadline?: AbsoluteDeadline;
|
|
113
|
+
timeoutMs?: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The deadline a call's bound sets, never past `ceiling`, the deadlines around it; `ceiling` when it
|
|
118
|
+
* sets none of its own. A deprecated form is checked, then reported under `call`, the call it was
|
|
119
|
+
* given to, and refused beside `timeout`. `null` from an untyped caller is no bound, as it was.
|
|
120
|
+
*/
|
|
121
|
+
export function boundedDeadline(
|
|
122
|
+
given: GivenBound,
|
|
123
|
+
ceiling: AbsoluteDeadline | undefined,
|
|
124
|
+
deprecated: (name: "timeout-ms" | "deadline-option", call: string) => void,
|
|
125
|
+
call: string,
|
|
126
|
+
): AbsoluteDeadline | undefined {
|
|
127
|
+
const { timeout, deadline, timeoutMs } = given;
|
|
128
|
+
if (timeout != null) {
|
|
129
|
+
if (deadline != null || timeoutMs != null)
|
|
130
|
+
throw new Error(
|
|
131
|
+
"give timeout alone: deadline and timeoutMs are its deprecated forms, not limits beside it",
|
|
132
|
+
);
|
|
133
|
+
return timeoutDeadline(timeout, ceiling);
|
|
134
|
+
}
|
|
135
|
+
if (deadline != null && timeoutMs != null)
|
|
136
|
+
throw new Error("an operation cannot specify both deadline and timeoutMs");
|
|
137
|
+
if (timeoutMs != null) {
|
|
138
|
+
const bounded = deadlineWithin(timeoutMs, ceiling);
|
|
139
|
+
deprecated("timeout-ms", call);
|
|
140
|
+
return bounded;
|
|
141
|
+
}
|
|
142
|
+
if (deadline == null) return ceiling;
|
|
143
|
+
// A copy: the object given may be `wf.deadline`, whose `unixMilliseconds` reports each read.
|
|
144
|
+
const own = { unixMilliseconds: deadline.unixMilliseconds };
|
|
145
|
+
assertDeadlineValue(own);
|
|
146
|
+
deprecated("deadline-option", call);
|
|
147
|
+
return earlierDeadline(own, ceiling);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The deadline `timeout` sets from now, never past `ceiling`; `ceiling` for `false`. */
|
|
151
|
+
export function timeoutDeadline(
|
|
152
|
+
timeout: Timeout,
|
|
153
|
+
ceiling: AbsoluteDeadline | undefined,
|
|
154
|
+
): AbsoluteDeadline | undefined {
|
|
155
|
+
if (timeout === false) return ceiling;
|
|
156
|
+
if (timeout instanceof Date) {
|
|
157
|
+
const at = timeout.getTime();
|
|
158
|
+
if (!Number.isSafeInteger(at) || at < 0) throw new Error("a timeout's Date is a valid time");
|
|
159
|
+
return earlierDeadline({ unixMilliseconds: at }, ceiling);
|
|
160
|
+
}
|
|
161
|
+
if (typeof timeout !== "string")
|
|
162
|
+
throw new Error(
|
|
163
|
+
`a timeout is a Duration such as "10m", a Date, or false; not ${JSON.stringify(timeout)}`,
|
|
164
|
+
);
|
|
165
|
+
return earlierDeadline(
|
|
166
|
+
{ unixMilliseconds: Date.now() + positiveDurationMilliseconds(timeout, "a timeout") },
|
|
167
|
+
ceiling,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
104
171
|
export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
|
|
105
172
|
if (!deadline) return;
|
|
106
173
|
assertDeadlineValue(deadline);
|
|
@@ -14,13 +14,7 @@ import {
|
|
|
14
14
|
type Question,
|
|
15
15
|
} from "@agentswf/contract/workflow";
|
|
16
16
|
import { canonical } from "../canonical-json";
|
|
17
|
-
import {
|
|
18
|
-
abortableSleep,
|
|
19
|
-
assertDeadline,
|
|
20
|
-
assertDeadlineValue,
|
|
21
|
-
earlierDeadline,
|
|
22
|
-
scheduleAt,
|
|
23
|
-
} from "../deadlines";
|
|
17
|
+
import { abortableSleep, assertDeadline, boundedDeadline, scheduleAt } from "../deadlines";
|
|
24
18
|
import { messageOf } from "../errors";
|
|
25
19
|
import type { RunEventBody } from "../run-event-log";
|
|
26
20
|
import {
|
|
@@ -41,6 +35,8 @@ export type DecisionScope = {
|
|
|
41
35
|
add?(cancel: (reason: string) => Promise<unknown>): () => void;
|
|
42
36
|
/** The workflow stage it is asked in. */
|
|
43
37
|
stage?: string;
|
|
38
|
+
/** The spec gave a deprecated form of `timeout`. */
|
|
39
|
+
deprecated?(name: "timeout-ms" | "deadline-option", call: string): void;
|
|
44
40
|
};
|
|
45
41
|
|
|
46
42
|
type PreparedCall = {
|
|
@@ -133,10 +129,12 @@ export class RunDecisions {
|
|
|
133
129
|
? installed.providers[resolved.provider]
|
|
134
130
|
: undefined;
|
|
135
131
|
if (!provider) throw new Error(`decision provider "${resolved.provider}" is not installed`);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
const deadline = boundedDeadline(
|
|
133
|
+
{ timeout: spec.timeout, deadline: spec.deadline },
|
|
134
|
+
scope.deadline,
|
|
135
|
+
(name, call) => scope.deprecated?.(name, call),
|
|
136
|
+
"on decide",
|
|
137
|
+
);
|
|
140
138
|
assertDeadline(deadline);
|
|
141
139
|
const slot = this.#slots.length;
|
|
142
140
|
this.#slots.push(undefined);
|