@agentswf/engine 0.1.0-beta.4 → 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 +4 -4
- package/src/agent-cli/cli.ts +57 -17
- package/src/attempt-ending.ts +2 -4
- package/src/attempt-output.ts +2 -1
- package/src/attempt-view.ts +1 -1
- package/src/control-plane.ts +37 -5
- 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 +55 -41
- package/src/emit.ts +124 -15
- package/src/help/emit.txt +12 -4
- package/src/help/inbox.txt +2 -2
- package/src/help/logs.txt +5 -4
- package/src/help/reply.txt +5 -5
- package/src/help/run.txt +21 -7
- package/src/herdr-run-session.ts +2 -2
- package/src/holder-command.ts +36 -31
- package/src/inbox-command.ts +7 -10
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +2 -2
- package/src/keep-alive.ts +28 -13
- package/src/logs-command.ts +12 -12
- package/src/mailbox.ts +2 -20
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +26 -21
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +7 -5
- package/src/operator-cli.ts +23 -9
- package/src/operator-runtime.ts +5 -5
- package/src/pane-layout.ts +125 -32
- package/src/progress-view.ts +8 -8
- package/src/run-command.ts +25 -4
- package/src/run-commands.ts +1 -1
- package/src/run-prepare.ts +46 -8
- package/src/run-usage.ts +1 -1
- package/src/run-view.ts +9 -4
- package/src/runs-command.ts +12 -10
- package/src/runs.ts +44 -8
- package/src/sources.ts +42 -6
- package/src/stage-ledger.ts +42 -12
- package/src/stage-plan.ts +37 -1
- package/src/test-command.ts +7 -3
- package/src/testing.ts +7 -8
- package/src/tools.ts +46 -18
- package/src/workflow-loader.ts +5 -2
- package/src/workflow-runner.ts +553 -235
- package/src/workflow-testing/coordinator.typecheck.ts +7 -7
- package/src/workflow-testing/coordinators.ts +2 -2
- package/src/workflow-testing/host.ts +107 -38
- package/src/workflow-testing/index.ts +45 -836
- package/src/workflow-testing/run-test.ts +1213 -0
- package/src/workflow-testing/script.ts +102 -41
- package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentswf/engine",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"description": "agents.wf: the engine that runs workflows made of coding agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://agents.wf",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"!src/**/fixtures"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@agentswf/contract": "0.1.0-beta.
|
|
33
|
-
"@agentswf/harness": "0.1.0-beta.
|
|
34
|
-
"@agentswf/sandbox": "0.1.0-beta.
|
|
32
|
+
"@agentswf/contract": "0.1.0-beta.5",
|
|
33
|
+
"@agentswf/harness": "0.1.0-beta.5",
|
|
34
|
+
"@agentswf/sandbox": "0.1.0-beta.5",
|
|
35
35
|
"typebox": "^1.3.34"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
package/src/agent-cli/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
validWaitingReason,
|
|
6
6
|
WIRE_VERSION,
|
|
7
7
|
} from "@agentswf/contract/wire";
|
|
8
|
+
import { durationMilliseconds } from "@agentswf/contract/workflow";
|
|
8
9
|
import { NotSentError, submitControl } from "./client";
|
|
9
10
|
|
|
10
11
|
/** `wfa` is its launcher's full path, as an agent must type it; `wfa` alone when it is not known. */
|
|
@@ -73,21 +74,36 @@ export async function runCli(
|
|
|
73
74
|
}
|
|
74
75
|
switch (command) {
|
|
75
76
|
case "waiting":
|
|
76
|
-
|
|
77
|
+
case "send":
|
|
78
|
+
case "reply": {
|
|
79
|
+
const said: Said = {};
|
|
80
|
+
const outcome =
|
|
81
|
+
command === "waiting"
|
|
82
|
+
? await runWaiting(endpoint, session, args, submit, said)
|
|
83
|
+
: await runMessage(endpoint, session, command, args, stdin, wfa, submit, said);
|
|
84
|
+
return noticed(outcome, said.notice);
|
|
85
|
+
}
|
|
77
86
|
case "result":
|
|
78
87
|
return runResult(endpoint, session, args, stdin, submit);
|
|
79
88
|
case "tool":
|
|
80
89
|
return runTool(endpoint, session, args, stdin, submit);
|
|
81
90
|
case "peers":
|
|
82
91
|
return runPeers(endpoint, session, args, submit);
|
|
83
|
-
case "send":
|
|
84
|
-
case "reply":
|
|
85
|
-
return runMessage(endpoint, session, command, args, stdin, wfa, submit);
|
|
86
92
|
default:
|
|
87
93
|
return usageError(usage(wfa));
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
/** What a command accepted in a deprecated form, said beside its outcome. */
|
|
98
|
+
type Said = { notice?: string };
|
|
99
|
+
|
|
100
|
+
/** Never beside a usage error: the form said was not taken then. */
|
|
101
|
+
function noticed(outcome: CliOutcome, notice: string | undefined): CliOutcome {
|
|
102
|
+
return notice && outcome.exitCode !== 2
|
|
103
|
+
? { ...outcome, stderr: outcome.stderr ? `${notice}\n${outcome.stderr}` : notice }
|
|
104
|
+
: outcome;
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
async function runResult(
|
|
92
108
|
endpoint: string,
|
|
93
109
|
session: string | undefined,
|
|
@@ -208,6 +224,7 @@ async function runWaiting(
|
|
|
208
224
|
session: string | undefined,
|
|
209
225
|
args: readonly string[],
|
|
210
226
|
submit: Submit,
|
|
227
|
+
said: Said,
|
|
211
228
|
): Promise<CliOutcome> {
|
|
212
229
|
const [operationId, ...flags] = args;
|
|
213
230
|
if (!operationId?.trim() || operationId.startsWith("--"))
|
|
@@ -226,9 +243,10 @@ async function runWaiting(
|
|
|
226
243
|
const duration = values.get("--timeout");
|
|
227
244
|
let timeoutMs: number | undefined;
|
|
228
245
|
if (duration !== undefined) {
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
246
|
+
const read = durationOf(duration);
|
|
247
|
+
if (!read) return usageError(`wfa waiting --timeout ${DURATION_GRAMMAR}`);
|
|
248
|
+
timeoutMs = read.milliseconds;
|
|
249
|
+
said.notice = read.notice;
|
|
232
250
|
}
|
|
233
251
|
let response: ControlResponse;
|
|
234
252
|
try {
|
|
@@ -313,12 +331,14 @@ async function runMessage(
|
|
|
313
331
|
stdin: string | null,
|
|
314
332
|
wfa: string,
|
|
315
333
|
submit: Submit,
|
|
334
|
+
said: Said,
|
|
316
335
|
): Promise<CliOutcome> {
|
|
317
336
|
const parsed = messageArgs(args);
|
|
318
337
|
const wrong = (problem: string) =>
|
|
319
338
|
usageError(`wfa: ${command}: ${problem}\n\n${messageUsage(wfa).join("\n")}`);
|
|
320
339
|
if ("error" in parsed) return wrong(parsed.error);
|
|
321
340
|
const { peer, message, awaitReply, timeoutMs } = parsed;
|
|
341
|
+
said.notice = parsed.notice;
|
|
322
342
|
// A heredoc ends with a newline the agent did not mean as part of the message.
|
|
323
343
|
const text = message ?? stdin?.replace(/\r?\n$/, "") ?? null;
|
|
324
344
|
if (text === null || text.trim() === "") return wrong("it needs a message");
|
|
@@ -364,9 +384,12 @@ async function runMessage(
|
|
|
364
384
|
*/
|
|
365
385
|
function messageArgs(
|
|
366
386
|
args: readonly string[],
|
|
367
|
-
):
|
|
387
|
+
):
|
|
388
|
+
| { peer: string; message?: string; awaitReply: boolean; timeoutMs?: number; notice?: string }
|
|
389
|
+
| { error: string } {
|
|
368
390
|
let awaitReply = false;
|
|
369
391
|
let timeoutMs: number | undefined;
|
|
392
|
+
let notice: string | undefined;
|
|
370
393
|
const positional: string[] = [];
|
|
371
394
|
for (let i = 0; i < args.length; i += 1) {
|
|
372
395
|
const arg = args[i]!;
|
|
@@ -377,9 +400,9 @@ function messageArgs(
|
|
|
377
400
|
if (positional.length > 1) return { error: "flags go before the message" };
|
|
378
401
|
if (arg === "--await-reply" && !awaitReply) awaitReply = true;
|
|
379
402
|
else if (arg === "--timeout" && timeoutMs === undefined) {
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
|
|
403
|
+
const read = durationOf(args[i + 1] ?? "");
|
|
404
|
+
if (!read) return { error: `--timeout ${DURATION_GRAMMAR}` };
|
|
405
|
+
({ milliseconds: timeoutMs, notice } = read);
|
|
383
406
|
i += 1;
|
|
384
407
|
} else if (positional.length === 1 && arg !== "--await-reply" && arg !== "--timeout") {
|
|
385
408
|
return { error: `${arg} is not a flag; a message that starts with -- goes in a heredoc` };
|
|
@@ -395,15 +418,32 @@ function messageArgs(
|
|
|
395
418
|
...(rest[0] === undefined ? {} : { message: rest[0] }),
|
|
396
419
|
awaitReply,
|
|
397
420
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
421
|
+
...(notice === undefined ? {} : { notice }),
|
|
398
422
|
};
|
|
399
423
|
}
|
|
400
424
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
425
|
+
const DURATION_GRAMMAR = 'takes a duration, a decimal and s, m, h or d, such as "5m"';
|
|
426
|
+
const LEGACY_MS = /^([0-9]+)ms$/;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The one grammar's, longer than none. A whole number of `ms` is read until 0.1.0, with the line
|
|
430
|
+
* `wfa` says beside its outcome.
|
|
431
|
+
*/
|
|
432
|
+
function durationOf(text: string): { milliseconds: number; notice?: string } | undefined {
|
|
433
|
+
const legacy = LEGACY_MS.exec(text);
|
|
434
|
+
let milliseconds: number;
|
|
435
|
+
try {
|
|
436
|
+
milliseconds = legacy ? Number(legacy[1]) : durationMilliseconds(text);
|
|
437
|
+
} catch {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) return undefined;
|
|
441
|
+
return legacy
|
|
442
|
+
? {
|
|
443
|
+
milliseconds,
|
|
444
|
+
notice: `wfa: --timeout ${text} is deprecated; give ${milliseconds / 1000}s: a duration is a decimal and s, m, h or d`,
|
|
445
|
+
}
|
|
446
|
+
: { milliseconds };
|
|
407
447
|
}
|
|
408
448
|
|
|
409
449
|
/** What is owed leads, so it comes back after a harness compacts; then the acknowledgement. */
|
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 },
|
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
|
@@ -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);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every name deprecated before 0.1.0, with what replaces it: each is logged once per attempt it is
|
|
3
|
+
* used in, so a continued or woken run logs it again, and removed one beta after the beta that
|
|
4
|
+
* deprecated it (story 039).
|
|
5
|
+
*/
|
|
6
|
+
export const DEPRECATIONS = {
|
|
7
|
+
labels: {
|
|
8
|
+
old: "an agent's labels option",
|
|
9
|
+
instead: "drop them: nothing reads an agent's labels",
|
|
10
|
+
},
|
|
11
|
+
whenToUse: { old: "meta.whenToUse", instead: "drop it: nothing reads it" },
|
|
12
|
+
"stage-receive": {
|
|
13
|
+
old: "a stage around wf.receive()",
|
|
14
|
+
instead: "wait with wf.race(name, …), which is recorded under its name, outside a stage",
|
|
15
|
+
},
|
|
16
|
+
isAnswered: { old: "isAnswered(outcome)", instead: "check outcome.answered" },
|
|
17
|
+
defineExecutableWorkflow: {
|
|
18
|
+
old: "defineExecutableWorkflow",
|
|
19
|
+
instead: "use defineWorkflow, with meta and run beside prepare",
|
|
20
|
+
},
|
|
21
|
+
"call-id": {
|
|
22
|
+
old: "the id of one run, start, compact or set",
|
|
23
|
+
instead:
|
|
24
|
+
"drop it: a later attempt doesn't know it, and a stage keeps a call from running twice",
|
|
25
|
+
},
|
|
26
|
+
"turn-source": {
|
|
27
|
+
old: "a started turn as a wait's branch",
|
|
28
|
+
instead: "name the agent, { done: agent }, which has at most one started turn",
|
|
29
|
+
},
|
|
30
|
+
"turn-is": { old: "turn.is(e)", instead: "use agent.is(e)" },
|
|
31
|
+
"test-reply": {
|
|
32
|
+
old: "reply.… in a test's script",
|
|
33
|
+
instead: "use noAnswer.…, the same calls: a reply answers, these end a turn without an answer",
|
|
34
|
+
},
|
|
35
|
+
"message-silent": {
|
|
36
|
+
old: "message.silent() in a test's message script",
|
|
37
|
+
instead: "return nothing, which ends the turn without a reply",
|
|
38
|
+
},
|
|
39
|
+
"message-fail": {
|
|
40
|
+
old: "message.fail() in a test's message script",
|
|
41
|
+
instead: "return noAnswer.failed(reason), which fails the turn as a failed harness does",
|
|
42
|
+
},
|
|
43
|
+
"test-event-id": {
|
|
44
|
+
old: "an event's id in a test",
|
|
45
|
+
instead: "give it as eventId, as awf emit --event-id does",
|
|
46
|
+
},
|
|
47
|
+
"run-stopped": { old: "run.stopped", instead: 'read run.ending, whose kind is "stopped"' },
|
|
48
|
+
"run-parked": { old: "run.parked", instead: 'read run.ending, whose kind is "parked"' },
|
|
49
|
+
"colon-key": {
|
|
50
|
+
old: "a key with a colon",
|
|
51
|
+
instead: (key: string) =>
|
|
52
|
+
`${key === "" ? "use - in its place" : `name it ${key.replaceAll(":", "-")}`}: a colon is kept for awf's own event names`,
|
|
53
|
+
},
|
|
54
|
+
"agents-stop": {
|
|
55
|
+
old: "wf.agents.stop(key)",
|
|
56
|
+
instead: "call close(reason?) on the agent's ref",
|
|
57
|
+
},
|
|
58
|
+
"fork-runtime": {
|
|
59
|
+
old: "a fork's own placement, metered or effort",
|
|
60
|
+
instead:
|
|
61
|
+
"give them in its runtime, as open does: fork({ key, runtime: { placement, metered, effort } })",
|
|
62
|
+
},
|
|
63
|
+
"timeout-ms": {
|
|
64
|
+
old: "timeoutMs",
|
|
65
|
+
instead: 'give timeout, a Duration such as "90s" or "10m"',
|
|
66
|
+
},
|
|
67
|
+
"deadline-option": {
|
|
68
|
+
old: "a deadline: { unixMilliseconds } option",
|
|
69
|
+
instead: 'give timeout: a Date, or a Duration such as "20m"',
|
|
70
|
+
},
|
|
71
|
+
"nudge-deadline": {
|
|
72
|
+
old: "nudge.deadline",
|
|
73
|
+
instead: "drop it: the turn's timeout bounds its check-ins",
|
|
74
|
+
},
|
|
75
|
+
"keep-alive-timeout-ms": {
|
|
76
|
+
old: "keepAlive.timeoutMs",
|
|
77
|
+
instead: 'give keepAlive.window, a Duration such as "90s"',
|
|
78
|
+
},
|
|
79
|
+
"run-deadline-ms": {
|
|
80
|
+
old: "wf.deadline.unixMilliseconds",
|
|
81
|
+
instead: "wf.deadline is a Date: read wf.deadline.getTime()",
|
|
82
|
+
},
|
|
83
|
+
"stall-ms": { old: "a test's stallMs", instead: 'give stall, a Duration such as "2s"' },
|
|
84
|
+
"test-args": {
|
|
85
|
+
old: "testWorkflow(workflow, args, options)",
|
|
86
|
+
instead: "give args among the options: testWorkflow(workflow, { args, … })",
|
|
87
|
+
},
|
|
88
|
+
"test-recorded": { old: "a test's recorded", instead: "give it as earlier: { stages }" },
|
|
89
|
+
"test-mailbox": { old: "a test's mailbox", instead: "give it as earlier: { mailbox }" },
|
|
90
|
+
"to-code": {
|
|
91
|
+
old: 'to: "code"',
|
|
92
|
+
instead: (key: string) =>
|
|
93
|
+
`declare "${key}:reply": ParticipantReply in events and connect it without to: the code takes its messages as ${key}:reply`,
|
|
94
|
+
},
|
|
95
|
+
"undeclared-ended": {
|
|
96
|
+
old: "start() for an agent whose end isn't declared",
|
|
97
|
+
instead: (key: string) => {
|
|
98
|
+
const named = key.replaceAll(":", "-");
|
|
99
|
+
const declare = `declare "${named}:ended": AgentEnded in events: its end then arrives as ${named}:ended, not agent:ended`;
|
|
100
|
+
return key.includes(":")
|
|
101
|
+
? `rename the key ${named}, which can name its end, and ${declare}`
|
|
102
|
+
: declare;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"participant-is": {
|
|
106
|
+
old: "is(e) on a participant",
|
|
107
|
+
instead: 'read e.name === "{key}:reply", declared in events',
|
|
108
|
+
},
|
|
109
|
+
"test-participant-reply": {
|
|
110
|
+
old: 'a scripted { name: "participant:reply", from } in a test',
|
|
111
|
+
instead: 'name the declared event, { name: "{key}:reply", data }',
|
|
112
|
+
},
|
|
113
|
+
"send-event-id": {
|
|
114
|
+
old: "awf send --event-id",
|
|
115
|
+
instead:
|
|
116
|
+
'use awf emit {run} {key}:reply \'{"text":…,"author":…}\' --event-id {id}, with {key}:reply declared in the workflow\'s events',
|
|
117
|
+
},
|
|
118
|
+
"pane-layout": {
|
|
119
|
+
old: "an agent's layout",
|
|
120
|
+
instead:
|
|
121
|
+
"give pane: { tab, front } or pane: { beside, side, front }; the workspace is the operator's, awf run --workspace, and the session AWF_HERDR_SESSION",
|
|
122
|
+
},
|
|
123
|
+
"keep-pane": {
|
|
124
|
+
old: "an agent's keepPane",
|
|
125
|
+
instead: "drop it: keeping panes is the operator's, awf run --keep-panes",
|
|
126
|
+
},
|
|
127
|
+
"duration-ms": {
|
|
128
|
+
old: "a duration in ms",
|
|
129
|
+
instead: (value: string) =>
|
|
130
|
+
`give ${Number.parseInt(value, 10) / 1000}s: a duration is a decimal and s, m, h or d`,
|
|
131
|
+
},
|
|
132
|
+
"wait-seconds": {
|
|
133
|
+
old: "a bare number for --wait",
|
|
134
|
+
instead: (value: string) => `give a duration, --wait ${value}s`,
|
|
135
|
+
},
|
|
136
|
+
} as const satisfies Record<
|
|
137
|
+
string,
|
|
138
|
+
{ old: string; instead: string | ((subject: string) => string) }
|
|
139
|
+
>;
|
|
140
|
+
|
|
141
|
+
export type Deprecated = keyof typeof DEPRECATIONS;
|
|
142
|
+
|
|
143
|
+
/** One attempt's deprecated uses: each name said once, as a warning in the run's log. */
|
|
144
|
+
export class RunDeprecations {
|
|
145
|
+
/** Each name said, with its subject where it has one: each key with a colon is named. */
|
|
146
|
+
readonly #said = new Set<string>();
|
|
147
|
+
/** The attempt has ended: an outcome kept past it reports to no log. */
|
|
148
|
+
#settled = false;
|
|
149
|
+
|
|
150
|
+
constructor(private readonly warn: (text: string) => void) {}
|
|
151
|
+
|
|
152
|
+
/** `subject`, where given, is what was used, and each subject is said once. */
|
|
153
|
+
used(name: Deprecated, subject?: string): void {
|
|
154
|
+
const said = subject === undefined ? name : `${name}\0${subject}`;
|
|
155
|
+
if (this.#said.has(said)) return;
|
|
156
|
+
this.#said.add(said);
|
|
157
|
+
this.warn(deprecationLine(name, subject));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
settle(): void {
|
|
161
|
+
this.#settled = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** As `used`, for a name the contract reports by its own spelling; one not listed is ignored. */
|
|
165
|
+
reported = (name: unknown): void => {
|
|
166
|
+
if (this.#settled) return;
|
|
167
|
+
if (typeof name === "string" && Object.hasOwn(DEPRECATIONS, name))
|
|
168
|
+
this.used(name as Deprecated);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The line a deprecated use logs. */
|
|
173
|
+
export function deprecationLine(name: Deprecated, subject = ""): string {
|
|
174
|
+
const { old, instead } = DEPRECATIONS[name];
|
|
175
|
+
const named = subject === "" ? "" : `, ${subject},`;
|
|
176
|
+
return `awf: ${old}${named} is deprecated; ${typeof instead === "function" ? instead(subject) : instead}`;
|
|
177
|
+
}
|