@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/src/holder-command.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import type { ParticipantResultJson } from "@agentswf/contract/records";
|
|
3
3
|
import { type ParticipantCode, WIRE_VERSION } from "@agentswf/contract/wire";
|
|
4
4
|
import { liveCoordinatorSocket } from "./coordinator-socket";
|
|
5
|
-
import {
|
|
5
|
+
import { deprecationLine } from "./deprecations";
|
|
6
|
+
import { deliverReply, emitToRun, eventIdProblem } from "./emit";
|
|
6
7
|
import { printEmit } from "./emit-command";
|
|
7
8
|
import { messageOf } from "./errors";
|
|
8
9
|
import { isCode } from "./files";
|
|
@@ -71,7 +72,7 @@ export async function holderCommand(
|
|
|
71
72
|
);
|
|
72
73
|
return result.code === "delivered" ? 0 : 1;
|
|
73
74
|
};
|
|
74
|
-
const refused = (code: ParticipantCode, error: string) => say({ v:
|
|
75
|
+
const refused = (code: ParticipantCode, error: string) => say({ v: 2, code, error }, "");
|
|
75
76
|
const usage = (problem: string) => {
|
|
76
77
|
io.stderr(`awf: ${problem}\n\n${replyUsage}`);
|
|
77
78
|
return 2;
|
|
@@ -122,7 +123,7 @@ export async function holderCommand(
|
|
|
122
123
|
}
|
|
123
124
|
if (!answer)
|
|
124
125
|
throw new Error(
|
|
125
|
-
`${status.ref} did not answer
|
|
126
|
+
`${status.ref} did not answer, most likely as it runs an older awf than this one, which speaks wire version ${WIRE_VERSION}: reach it with the awf it started with, or let it end and continue it under this one.`,
|
|
126
127
|
);
|
|
127
128
|
const result = JSON.parse(answer) as ParticipantResultJson;
|
|
128
129
|
if (result.code !== "delivered") return say(result, "");
|
|
@@ -178,11 +179,12 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
|
|
|
178
179
|
let agent: string | undefined;
|
|
179
180
|
[ref, agent, text, ...extra] = words;
|
|
180
181
|
if (author !== undefined) throw new Error("--author goes with --event-id");
|
|
181
|
-
if (!ref || !agent)
|
|
182
|
+
if (!ref || !agent?.trim())
|
|
182
183
|
throw new Error(`${command} needs a run, <workflow>/<id> or <id>, and an agent`);
|
|
183
184
|
target = { agent, ...(as === undefined ? {} : { as }) };
|
|
184
185
|
}
|
|
185
186
|
if (extra.length > 0) throw new Error("the text is one argument: quote it, or pipe it in");
|
|
187
|
+
if (as !== undefined && as.trim() === "") throw new Error("--as needs a participant's key");
|
|
186
188
|
if (to !== undefined && !/^\d+\.\d+$/.test(to))
|
|
187
189
|
throw new Error(`--to takes a question's id as awf inbox prints it, such as 1.5, not ${to}`);
|
|
188
190
|
return {
|
|
@@ -221,9 +223,11 @@ async function awaitedBy(
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
/**
|
|
224
|
-
* `awf send {ref} --as {participant} --event-id {message id} {text}
|
|
225
|
-
*
|
|
226
|
-
*
|
|
226
|
+
* `awf send {ref} --as {participant} --event-id {message id} {text}`, deprecated: `awf emit {ref}
|
|
227
|
+
* {participant}:reply`. It keeps its own refusals and exit codes for the beta: a participant the
|
|
228
|
+
* run never connected is refused; one the run recorded as routed to the code, declared or by the
|
|
229
|
+
* deprecated `to: "code"`, gets the reply as before, the workflow's file not loaded; any other is
|
|
230
|
+
* `awf emit`'s to check against the definition.
|
|
227
231
|
*/
|
|
228
232
|
async function toTheCode(
|
|
229
233
|
options: Extract<Options, { id: string }>,
|
|
@@ -236,6 +240,7 @@ async function toTheCode(
|
|
|
236
240
|
const environment = io.environment ?? process.env;
|
|
237
241
|
const { id, as } = options;
|
|
238
242
|
const { refused } = say;
|
|
243
|
+
io.stderr(deprecationLine("send-event-id"));
|
|
239
244
|
let found: RunStatus;
|
|
240
245
|
try {
|
|
241
246
|
found = await findRunByRef(options.ref, place, home);
|
|
@@ -243,37 +248,37 @@ async function toTheCode(
|
|
|
243
248
|
if (error instanceof RunNotFound) return refused("not-found", error.message);
|
|
244
249
|
return failed(error, place, io, messageOf(error));
|
|
245
250
|
}
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
(line) =>
|
|
249
|
-
line.kind === "participant-connected" && line.participant === as && line.to === "code",
|
|
251
|
+
const connected = (await readEvents(found.run.dir)).filter(
|
|
252
|
+
(line) => line.kind === "participant-connected" && line.participant === as,
|
|
250
253
|
);
|
|
251
|
-
|
|
254
|
+
// As it always did: only a participant the run has connected; `awf emit` doesn't ask.
|
|
255
|
+
if (!found.run.record.events || connected.length === 0)
|
|
252
256
|
return refused(
|
|
253
257
|
"no-participant",
|
|
254
|
-
`${found.ref} has no participant ${as}
|
|
258
|
+
`${found.ref} has no participant ${as} connected to its code; --event-id is for one`,
|
|
255
259
|
);
|
|
256
260
|
const text = options.text ?? (await io.stdin());
|
|
257
261
|
if (text.trim() === "") return say.usage("nothing to say: stdin was empty");
|
|
262
|
+
const data = { text, ...(options.author === undefined ? {} : { author: options.author }) };
|
|
263
|
+
const context = { home, environment };
|
|
264
|
+
// Recorded as routed to the code, by either form: delivered as before, the file not loaded.
|
|
265
|
+
if (connected.some((line) => line.kind === "participant-connected" && line.to)) {
|
|
266
|
+
try {
|
|
267
|
+
return printEmit(await deliverReply(found, as, { data, id }, context), json, io);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return failed(error, place, io, messageOf(error));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Connected to agents only, which send has always refused: whatever stops `awf emit`, a file
|
|
273
|
+
// that won't load included, is that refusal, with its reason.
|
|
274
|
+
let result: Awaited<ReturnType<typeof emitToRun>>;
|
|
258
275
|
try {
|
|
259
|
-
|
|
260
|
-
{
|
|
261
|
-
runRoot: run.root,
|
|
262
|
-
workflow: run.record.workflow,
|
|
263
|
-
runId: run.record.id,
|
|
264
|
-
event: {
|
|
265
|
-
id,
|
|
266
|
-
name: "participant:reply",
|
|
267
|
-
data: { text, ...(options.author === undefined ? {} : { author: options.author }) },
|
|
268
|
-
from: as,
|
|
269
|
-
at: new Date().toISOString(),
|
|
270
|
-
},
|
|
271
|
-
deduplicate: true,
|
|
272
|
-
},
|
|
273
|
-
{ home, environment },
|
|
274
|
-
);
|
|
275
|
-
return printEmit(result, json, io);
|
|
276
|
+
result = await emitToRun(found, { name: `${as}:reply`, data, id }, context);
|
|
276
277
|
} catch (error) {
|
|
277
|
-
return
|
|
278
|
+
return refused(
|
|
279
|
+
"no-participant",
|
|
280
|
+
`${found.ref} has no participant ${as} whose messages go to its code: ${messageOf(error)}`,
|
|
281
|
+
);
|
|
278
282
|
}
|
|
283
|
+
return printEmit(result, json, io);
|
|
279
284
|
}
|
package/src/inbox-command.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import type { InboxCode } from "@agentswf/contract/wire";
|
|
12
12
|
import type { JsonObject } from "@agentswf/contract/workflow";
|
|
13
13
|
import { maskCredentials } from "@agentswf/harness";
|
|
14
|
-
import {
|
|
14
|
+
import { formatDuration, parseWait } from "./duration";
|
|
15
15
|
import { messageOf } from "./errors";
|
|
16
16
|
import { exists } from "./files";
|
|
17
17
|
import help from "./help/inbox.txt" with { type: "text" };
|
|
@@ -100,7 +100,7 @@ export async function inboxCommand(
|
|
|
100
100
|
const parsed = parsePlace(argv, shellCwd);
|
|
101
101
|
place = parsed.place;
|
|
102
102
|
json = parsed.json;
|
|
103
|
-
options = parseInbox(parsed.rest);
|
|
103
|
+
options = parseInbox(parsed.rest, (line) => out.stderr(line));
|
|
104
104
|
} catch (error) {
|
|
105
105
|
out.stderr(`awf: ${messageOf(error)}\n\n${inboxUsage}`);
|
|
106
106
|
return 2;
|
|
@@ -198,7 +198,7 @@ export async function inboxCommand(
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
function parseInbox(rest: readonly string[]): Options {
|
|
201
|
+
function parseInbox(rest: readonly string[], deprecated: (line: string) => void): Options {
|
|
202
202
|
const options: Options = { all: false, open: false, waitSeconds: 0 };
|
|
203
203
|
for (let index = 0; index < rest.length; index += 1) {
|
|
204
204
|
const arg = rest[index]!;
|
|
@@ -212,12 +212,9 @@ function parseInbox(rest: readonly string[]): Options {
|
|
|
212
212
|
else if (arg === "--workflow") options.workflow = value();
|
|
213
213
|
else if (arg === "--as") options.as = value();
|
|
214
214
|
else if (arg === "--after") options.after = value();
|
|
215
|
-
else if (arg === "--wait")
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
|
|
219
|
-
options.waitSeconds = seconds;
|
|
220
|
-
} else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
|
|
215
|
+
else if (arg === "--wait")
|
|
216
|
+
options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS, deprecated);
|
|
217
|
+
else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
|
|
221
218
|
else if (options.ref === undefined) options.ref = arg;
|
|
222
219
|
else throw new Error(`one run at a time: ${options.ref} and ${arg}`);
|
|
223
220
|
}
|
|
@@ -658,7 +655,7 @@ function textOf(item: InboxItemJson, all: boolean): string {
|
|
|
658
655
|
? `${item.agent} → ${item.participant}`
|
|
659
656
|
: `${item.participant} → ${item.agent}`;
|
|
660
657
|
return item.state === "opened"
|
|
661
|
-
? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${
|
|
658
|
+
? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${formatDuration(item.timeoutMs)}`}`
|
|
662
659
|
: `${head}route ${arrow} closed ${item.reason}`;
|
|
663
660
|
}
|
|
664
661
|
case "message": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { answer,
|
|
2
|
+
import { answer, noAnswer, testWorkflow } from "agentswf/testing";
|
|
3
3
|
import hello, { ANSWER } from "./workflow";
|
|
4
4
|
|
|
5
5
|
test("returns the agent's answer", async () => {
|
|
6
|
-
const run = await testWorkflow(hello,
|
|
6
|
+
const run = await testWorkflow(hello, {
|
|
7
7
|
agents: { hello: answer(ANSWER, { answer: 391 }) },
|
|
8
8
|
});
|
|
9
9
|
expect(run.value).toEqual({ answer: 391 });
|
|
@@ -11,7 +11,7 @@ test("returns the agent's answer", async () => {
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
test("an agent that never answers is nudged once, then the result says why", async () => {
|
|
14
|
-
const run = await testWorkflow(hello,
|
|
14
|
+
const run = await testWorkflow(hello, { agents: { hello: noAnswer.silent() } });
|
|
15
15
|
expect(run.value).toHaveProperty("unanswered");
|
|
16
16
|
expect(run.turnsOf("hello").map((turn) => turn.nudge)).toEqual([false, true]);
|
|
17
17
|
});
|
|
@@ -7,7 +7,7 @@ export default defineWorkflow({
|
|
|
7
7
|
meta: { name: "hello", description: "Ask one agent a question with a known answer." },
|
|
8
8
|
async run(wf) {
|
|
9
9
|
// A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
|
|
10
|
-
// account has: "claude" in
|
|
10
|
+
// account has: "claude" in its TUI, the default placement, runs on a subscription.
|
|
11
11
|
const agent = await wf.agents.open({
|
|
12
12
|
key: "hello",
|
|
13
13
|
runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
|
|
@@ -15,7 +15,7 @@ export default defineWorkflow({
|
|
|
15
15
|
const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
|
|
16
16
|
prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
|
|
17
17
|
schema: ANSWER,
|
|
18
|
-
|
|
18
|
+
timeout: "3m",
|
|
19
19
|
});
|
|
20
20
|
return outcome.answered ? outcome.value : { unanswered: outcome.reason };
|
|
21
21
|
},
|
package/src/keep-alive.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import type
|
|
2
|
+
import { type AgentOpenSpec, positiveDurationMilliseconds } from "@agentswf/contract/workflow";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** `keepAlive` as the engine keeps it: its window in milliseconds. */
|
|
5
|
+
export type KeepAlive = {
|
|
6
|
+
windowMs?: number;
|
|
7
|
+
onTimeout?: NonNullable<AgentOpenSpec["keepAlive"]>["onTimeout"];
|
|
8
|
+
};
|
|
5
9
|
|
|
6
10
|
/** Claude's prompt cache lives an hour for the main conversation on a subscription. */
|
|
7
11
|
export const DEFAULT_WINDOW_MS = 60 * 60_000;
|
|
@@ -20,21 +24,32 @@ export function asTimeoutHandler<T>(fn: () => T): T {
|
|
|
20
24
|
return inHandler.run(true, fn);
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can.
|
|
29
|
+
* Its deprecated `timeoutMs` is reported, and refused beside `window`.
|
|
30
|
+
*/
|
|
31
|
+
export function checkedKeepAlive(key: string, given: unknown, deprecated: () => void): KeepAlive {
|
|
25
32
|
if (typeof given !== "object" || given === null)
|
|
26
|
-
throw new Error(`agent ${key}: keepAlive is {
|
|
27
|
-
const { timeoutMs, onTimeout } = given as Record<string, unknown>;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
throw new Error(`agent ${key}: keepAlive is { window?, onTimeout? }`);
|
|
34
|
+
const { window, timeoutMs, onTimeout } = given as Record<string, unknown>;
|
|
35
|
+
let windowMs: number | undefined;
|
|
36
|
+
if (window != null) {
|
|
37
|
+
if (timeoutMs != null)
|
|
38
|
+
throw new Error(
|
|
39
|
+
`agent ${key}: keepAlive's window is given alone: timeoutMs is its deprecated form`,
|
|
40
|
+
);
|
|
41
|
+
windowMs = positiveDurationMilliseconds(window, `agent ${key}: keepAlive's window`);
|
|
42
|
+
} else if (timeoutMs != null) {
|
|
43
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
44
|
+
throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
|
|
45
|
+
deprecated();
|
|
46
|
+
windowMs = timeoutMs;
|
|
47
|
+
}
|
|
33
48
|
if (onTimeout !== undefined && typeof onTimeout !== "function")
|
|
34
49
|
throw new Error(`agent ${key}: keepAlive's onTimeout is a function`);
|
|
35
50
|
// A copy: a later change to the caller's object would skip this check.
|
|
36
51
|
return {
|
|
37
|
-
...(
|
|
52
|
+
...(windowMs === undefined ? {} : { windowMs }),
|
|
38
53
|
...(onTimeout === undefined ? {} : { onTimeout: onTimeout as KeepAlive["onTimeout"] }),
|
|
39
|
-
}
|
|
54
|
+
};
|
|
40
55
|
}
|
package/src/logs-command.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import type { LogEventJson, LogsJson, RouteEnds, RunLogLine } from "@agentswf/contract/records";
|
|
3
3
|
import type { JsonObject } from "@agentswf/contract/workflow";
|
|
4
|
-
import {
|
|
4
|
+
import { formatDuration, parseWait } from "./duration";
|
|
5
5
|
import { messageOf } from "./errors";
|
|
6
6
|
import help from "./help/logs.txt" with { type: "text" };
|
|
7
7
|
import {
|
|
@@ -85,7 +85,7 @@ export async function logsCommand(
|
|
|
85
85
|
const parsed = parsePlace(argv, shellCwd);
|
|
86
86
|
place = parsed.place;
|
|
87
87
|
json = parsed.json;
|
|
88
|
-
options = parseLogs(parsed.rest);
|
|
88
|
+
options = parseLogs(parsed.rest, (line) => out.stderr(line));
|
|
89
89
|
if (options.follow && json) throw new Error("--follow is for reading; --json takes --wait");
|
|
90
90
|
} catch (error) {
|
|
91
91
|
out.stderr(`awf: ${messageOf(error)}\n\n${logsUsage}`);
|
|
@@ -122,7 +122,7 @@ export async function logsCommand(
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function parseLogs(rest: readonly string[]): LogsOptions {
|
|
125
|
+
function parseLogs(rest: readonly string[], deprecated: (line: string) => void): LogsOptions {
|
|
126
126
|
const options: LogsOptions = {
|
|
127
127
|
limit: DEFAULT_LIMIT,
|
|
128
128
|
waitSeconds: 0,
|
|
@@ -147,11 +147,7 @@ function parseLogs(rest: readonly string[]): LogsOptions {
|
|
|
147
147
|
throw new Error("--limit needs a whole number");
|
|
148
148
|
options.limit = limit;
|
|
149
149
|
} else if (arg === "--wait") {
|
|
150
|
-
|
|
151
|
-
if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS) {
|
|
152
|
-
throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
|
|
153
|
-
}
|
|
154
|
-
options.waitSeconds = seconds;
|
|
150
|
+
options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS, deprecated);
|
|
155
151
|
waitGiven = true;
|
|
156
152
|
} else if (arg === "--follow") options.follow = true;
|
|
157
153
|
else if (arg === "--full") options.full = true;
|
|
@@ -511,7 +507,7 @@ function trailer(
|
|
|
511
507
|
return `run interrupted${go ? ` · continue: ${go}` : ""}`;
|
|
512
508
|
}
|
|
513
509
|
const { ended, started } = span(status, Date.now());
|
|
514
|
-
const took = ended === undefined ? "" : ` in ${
|
|
510
|
+
const took = ended === undefined ? "" : ` in ${formatDuration(ended - started)}`;
|
|
515
511
|
return `run ended: ${status.state}${took} · result: ${shellLine(command(place, "awf", "runs", status.ref))}`;
|
|
516
512
|
}
|
|
517
513
|
|
|
@@ -569,7 +565,7 @@ function describeLine(line: RunLogLine, places: Places): string {
|
|
|
569
565
|
case "compact": {
|
|
570
566
|
const took =
|
|
571
567
|
line.deliveredAt && line.settledAt
|
|
572
|
-
? ` ${
|
|
568
|
+
? ` ${formatDuration(Date.parse(line.settledAt) - Date.parse(line.deliveredAt))}`
|
|
573
569
|
: "";
|
|
574
570
|
return `${operation(line.kind)} ended ${line.outcome}${took}`;
|
|
575
571
|
}
|
|
@@ -591,7 +587,9 @@ function describeLine(line: RunLogLine, places: Places): string {
|
|
|
591
587
|
return `decision ${line.decision} ${line.outcome}${line.error ? ` ${said(line.error)}` : ""}`;
|
|
592
588
|
case "route-opened":
|
|
593
589
|
return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages${
|
|
594
|
-
line.timeoutMs === undefined
|
|
590
|
+
line.timeoutMs === undefined
|
|
591
|
+
? ""
|
|
592
|
+
: `, a question closes after ${formatDuration(line.timeoutMs)}`
|
|
595
593
|
}`;
|
|
596
594
|
case "route-closed":
|
|
597
595
|
return `route ${routeEnds(line)} closed ${line.reason}`;
|
|
@@ -640,7 +638,9 @@ function describeLine(line: RunLogLine, places: Places): string {
|
|
|
640
638
|
line.tool,
|
|
641
639
|
places.calls.get(line.call)?.input,
|
|
642
640
|
// Most calls take under a second; a duration says something only past one.
|
|
643
|
-
line.durationMs < 1000
|
|
641
|
+
line.durationMs < 1000
|
|
642
|
+
? line.outcome
|
|
643
|
+
: `${line.outcome} ${formatDuration(line.durationMs)}`,
|
|
644
644
|
answered,
|
|
645
645
|
);
|
|
646
646
|
}
|
package/src/mailbox.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
AWF_ID_PREFIX,
|
|
12
12
|
type JsonValue,
|
|
13
13
|
type OperationRecord,
|
|
14
|
-
type ReceiveOptions,
|
|
15
14
|
type TurnOutcome,
|
|
16
15
|
type WorkflowEvent,
|
|
17
16
|
} from "@agentswf/contract/workflow";
|
|
@@ -627,12 +626,11 @@ export class RunMailbox {
|
|
|
627
626
|
* nothing.
|
|
628
627
|
*/
|
|
629
628
|
async receive(
|
|
630
|
-
|
|
629
|
+
label: string | undefined,
|
|
631
630
|
stage?: string,
|
|
632
631
|
/** Rejects the wait: the run ended, or the scope it waits in was cancelled. */
|
|
633
632
|
signal?: AbortSignal,
|
|
634
633
|
): Promise<WorkflowEvent<string, JsonValue>[]> {
|
|
635
|
-
const { label, take } = checked(options);
|
|
636
634
|
if (stage !== undefined) {
|
|
637
635
|
const batch = await this.batchOf(stage);
|
|
638
636
|
if (batch) return batch;
|
|
@@ -645,7 +643,7 @@ export class RunMailbox {
|
|
|
645
643
|
return this.#wait(
|
|
646
644
|
{
|
|
647
645
|
...(label === undefined ? {} : { label }),
|
|
648
|
-
take,
|
|
646
|
+
take: "all",
|
|
649
647
|
...(stage === undefined ? {} : { stage }),
|
|
650
648
|
},
|
|
651
649
|
signal,
|
|
@@ -972,19 +970,3 @@ async function mailboxSize(runDir: string): Promise<number> {
|
|
|
972
970
|
() => 0,
|
|
973
971
|
);
|
|
974
972
|
}
|
|
975
|
-
|
|
976
|
-
/** A wait's options as given, checked: what a typed caller can't get wrong, a plain one can. */
|
|
977
|
-
function checked(options: ReceiveOptions | undefined): {
|
|
978
|
-
label?: string;
|
|
979
|
-
take: "all" | "oldest";
|
|
980
|
-
} {
|
|
981
|
-
if (options !== undefined && (typeof options !== "object" || options === null))
|
|
982
|
-
throw new Error("receive takes { label?, take? }");
|
|
983
|
-
const { label, take = "all" } = options ?? {};
|
|
984
|
-
if (label !== undefined && typeof label !== "string")
|
|
985
|
-
throw new Error("receive's label is a string");
|
|
986
|
-
if (take !== "all" && take !== "oldest") throw new Error('receive\'s take is "all" or "oldest"');
|
|
987
|
-
if ("until" in (options ?? {}))
|
|
988
|
-
throw new Error("receive has no until: a timer, wf.timer(name, when), wakes it");
|
|
989
|
-
return { ...(label === undefined ? {} : { label }), take };
|
|
990
|
-
}
|
package/src/messaging/limits.ts
CHANGED
|
@@ -12,14 +12,14 @@ export const MESSAGE_LIMITS: MessageLimits = {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Where an asker may wait for an answer, by harness and placement: on where M2 passed
|
|
15
|
-
* (`docs/findings/messaging-m2.md`), off elsewhere. A
|
|
15
|
+
* (`docs/findings/messaging-m2.md`), off elsewhere. A TUI that is `confirmed` waits only where it
|
|
16
16
|
* confirms what it was sent, as `wfa waiting` needs. The fake harness always may. cursor is off:
|
|
17
17
|
* its turns ran past their deadline in M2.
|
|
18
18
|
*/
|
|
19
|
-
export const AWAIT_REPLY: Readonly<Record<string, { headless?: true;
|
|
19
|
+
export const AWAIT_REPLY: Readonly<Record<string, { headless?: true; tui?: true | "confirmed" }>> =
|
|
20
20
|
{
|
|
21
|
-
fake: { headless: true,
|
|
22
|
-
claude: { headless: true,
|
|
21
|
+
fake: { headless: true, tui: true },
|
|
22
|
+
claude: { headless: true, tui: "confirmed" },
|
|
23
23
|
codex: { headless: true },
|
|
24
24
|
pi: { headless: true },
|
|
25
25
|
};
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
import type { ParticipantCode, PeerView, ReplyCode, SendCode } from "@agentswf/contract/wire";
|
|
10
10
|
import type { AgentKey, JsonObject } from "@agentswf/contract/workflow";
|
|
11
11
|
import type { AuthoredMessage } from "@agentswf/harness/adapter";
|
|
12
|
-
import {
|
|
12
|
+
import { formatDuration } from "../duration";
|
|
13
13
|
import { MESSAGE_LIMITS } from "./limits";
|
|
14
14
|
import {
|
|
15
15
|
directions,
|
|
@@ -180,7 +180,7 @@ type Obligation = {
|
|
|
180
180
|
/** It has a `--timeout` of its own, which only that fails it at. */
|
|
181
181
|
readonly timed: boolean;
|
|
182
182
|
/**
|
|
183
|
-
* When it closes unanswered
|
|
183
|
+
* When it closes unanswered: the route's `timeoutMs` or the asker's
|
|
184
184
|
* shorter `--timeout`, and which of the two.
|
|
185
185
|
*/
|
|
186
186
|
readonly closes?: { at: number; by: "route" | "own"; ms: number };
|
|
@@ -279,15 +279,13 @@ export class Messaging {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
const timeoutMs = options.timeoutMs;
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
);
|
|
290
|
-
}
|
|
282
|
+
if (
|
|
283
|
+
timeoutMs !== undefined &&
|
|
284
|
+
(typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
285
|
+
)
|
|
286
|
+
throw new Error(
|
|
287
|
+
`a route's timeoutMs is a positive whole number of milliseconds: not ${timeoutMs}`,
|
|
288
|
+
);
|
|
291
289
|
const open = pairs.map(([from, to]) => this.#open(from, to));
|
|
292
290
|
if (open.some(Boolean)) {
|
|
293
291
|
if (
|
|
@@ -361,7 +359,7 @@ export class Messaging {
|
|
|
361
359
|
});
|
|
362
360
|
}
|
|
363
361
|
|
|
364
|
-
/** Writes `text` to a participant
|
|
362
|
+
/** Writes `text` to a participant, without a turn: an inbox item for it. */
|
|
365
363
|
async post(key: AgentKey, text: string): Promise<string> {
|
|
366
364
|
if (this.#closed) throw new Error("the attempt has ended; nothing more is posted");
|
|
367
365
|
if (!this.#outside.has(key)) throw new Error(`${key} is not connected`);
|
|
@@ -506,7 +504,7 @@ export class Messaging {
|
|
|
506
504
|
return holderRefusal("no-route", `the workflow allows no route from ${from} to ${agent}.`);
|
|
507
505
|
if (view.state === "stopped")
|
|
508
506
|
return holderRefusal(
|
|
509
|
-
"stopped",
|
|
507
|
+
"peer-stopped",
|
|
510
508
|
`${agent} has stopped${view.reason ? ` (${JSON.stringify(view.reason)})` : ""}.`,
|
|
511
509
|
);
|
|
512
510
|
const route = initiating.find((each) => each.open);
|
|
@@ -942,18 +940,20 @@ export class Messaging {
|
|
|
942
940
|
return { kind: "ok", operation, owed: latest, bytes };
|
|
943
941
|
}
|
|
944
942
|
|
|
945
|
-
/** `awaiting`, `unsupported` and `cycle`, checked for any question. */
|
|
943
|
+
/** `already-awaiting`, `unsupported` and `cycle`, checked for any question. */
|
|
946
944
|
#checkAsk(
|
|
947
945
|
from: AgentKey,
|
|
948
946
|
peer: AgentKey,
|
|
949
947
|
operation: Operation,
|
|
950
948
|
/** The question a reply answers, which counts as answered here. */
|
|
951
949
|
answering?: Obligation,
|
|
952
|
-
):
|
|
950
|
+
):
|
|
951
|
+
| { kind: "refused"; code: "already-awaiting" | "unsupported" | "cycle"; error: string }
|
|
952
|
+
| undefined {
|
|
953
953
|
const wfa = this.#host.launcher(from);
|
|
954
954
|
if (operation.awaits?.state === "open") {
|
|
955
955
|
return refuse(
|
|
956
|
-
"awaiting",
|
|
956
|
+
"already-awaiting",
|
|
957
957
|
`you already await ${operation.awaits.owedBy}. End your turn, or send without --await-reply.`,
|
|
958
958
|
);
|
|
959
959
|
}
|
|
@@ -1045,7 +1045,7 @@ export class Messaging {
|
|
|
1045
1045
|
#ask(message: Message, operation: Operation, timeoutMs?: number): { until?: number } {
|
|
1046
1046
|
message.route.count += 1;
|
|
1047
1047
|
const now = this.#host.now();
|
|
1048
|
-
const routeMs =
|
|
1048
|
+
const routeMs = message.route.timeoutMs;
|
|
1049
1049
|
// The route's limit, or the asker's own when shorter: the shorter wins.
|
|
1050
1050
|
const limit =
|
|
1051
1051
|
routeMs !== undefined && (timeoutMs === undefined || routeMs <= timeoutMs)
|
|
@@ -1069,7 +1069,7 @@ export class Messaging {
|
|
|
1069
1069
|
owedBy: message.to,
|
|
1070
1070
|
...(until === undefined ? {} : { until }),
|
|
1071
1071
|
timed: limitAt < (outside ? Infinity : (operation.deadline ?? Infinity)),
|
|
1072
|
-
...(limit
|
|
1072
|
+
...(limit ? { closes: { ...limit, at: limitAt } } : {}),
|
|
1073
1073
|
state: "open",
|
|
1074
1074
|
};
|
|
1075
1075
|
message.asks = obligation;
|
|
@@ -1085,7 +1085,7 @@ export class Messaging {
|
|
|
1085
1085
|
obligation,
|
|
1086
1086
|
"timed-out",
|
|
1087
1087
|
obligation.closes?.by === "route"
|
|
1088
|
-
? `the question closed unanswered after ${
|
|
1088
|
+
? `the question closed unanswered after ${formatDuration(obligation.closes.ms)}`
|
|
1089
1089
|
: `your --timeout passed at ${clock(closesAt, this.#host.now())}`,
|
|
1090
1090
|
);
|
|
1091
1091
|
}
|
|
@@ -1375,7 +1375,7 @@ export class Messaging {
|
|
|
1375
1375
|
return {
|
|
1376
1376
|
peer,
|
|
1377
1377
|
can: this.#can(route, agent),
|
|
1378
|
-
|
|
1378
|
+
kind: "participant",
|
|
1379
1379
|
queued: 0,
|
|
1380
1380
|
purpose: route.purpose,
|
|
1381
1381
|
};
|
|
@@ -1388,8 +1388,13 @@ export class Messaging {
|
|
|
1388
1388
|
return {
|
|
1389
1389
|
peer,
|
|
1390
1390
|
can: this.#can(route, agent),
|
|
1391
|
+
kind: "agent",
|
|
1391
1392
|
state:
|
|
1392
|
-
view?.state === "stopped"
|
|
1393
|
+
view?.state === "stopped"
|
|
1394
|
+
? "stopped"
|
|
1395
|
+
: awaiting
|
|
1396
|
+
? "awaiting-reply"
|
|
1397
|
+
: (view?.state ?? "idle"),
|
|
1393
1398
|
...(awaiting && view?.state !== "stopped" ? { awaits: awaiting } : {}),
|
|
1394
1399
|
...(view?.state === "stopped" && view.reason ? { reason: view.reason } : {}),
|
|
1395
1400
|
queued,
|
package/src/messaging/routes.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type Route = {
|
|
|
21
21
|
readonly asked?: number;
|
|
22
22
|
/** As the workflow gave it, for telling an identical grant from a conflicting one. */
|
|
23
23
|
readonly requested?: number;
|
|
24
|
-
/**
|
|
24
|
+
/** How long a question on the route waits, to an agent or an outside participant. */
|
|
25
25
|
readonly timeoutMs?: number;
|
|
26
26
|
readonly scope: Scope;
|
|
27
27
|
/** Messages counted against it, answers reserved for included. */
|
package/src/messaging/text.ts
CHANGED
|
@@ -228,11 +228,13 @@ export function peersTable(
|
|
|
228
228
|
const rows = peers.map((peer) => [
|
|
229
229
|
peer.peer,
|
|
230
230
|
peer.can,
|
|
231
|
-
peer.
|
|
232
|
-
?
|
|
233
|
-
: peer.state === "
|
|
234
|
-
? `
|
|
235
|
-
: peer.state
|
|
231
|
+
peer.kind === "participant"
|
|
232
|
+
? "participant"
|
|
233
|
+
: peer.state === "awaiting-reply"
|
|
234
|
+
? `awaiting-reply from ${peer.awaits === viewer ? "you" : peer.awaits}`
|
|
235
|
+
: peer.state === "stopped" && peer.reason
|
|
236
|
+
? `stopped (${peer.reason})`
|
|
237
|
+
: peer.state,
|
|
236
238
|
peer.queued > 0 ? String(peer.queued) : "",
|
|
237
239
|
quoted(peer.purpose),
|
|
238
240
|
]);
|