@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/allowance-command.ts +29 -7
- package/src/attempt-ending.ts +23 -5
- package/src/attempt-output.ts +48 -18
- package/src/attempt-view.ts +25 -15
- package/src/commands.ts +176 -0
- package/src/control-plane.ts +72 -21
- package/src/coordinator-port.ts +29 -0
- package/src/coordinator-prompt.ts +237 -0
- package/src/coordinator-socket.ts +236 -0
- package/src/deadlines.ts +47 -10
- package/src/decisions/directory.ts +8 -6
- package/src/duration.ts +29 -0
- package/src/emit-command.ts +130 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/here.ts +8 -2
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +297 -0
- package/src/inbox-command.ts +727 -0
- package/src/init-command.ts +27 -0
- package/src/init-template/AGENTS.md.template +1 -1
- package/src/init-template/hello/workflow.test.ts.template +2 -2
- package/src/init-template/hello/workflow.ts.template +16 -20
- package/src/keep-alive.ts +40 -0
- package/src/logs-command.ts +104 -20
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +977 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/messaging.ts +458 -65
- package/src/messaging/routes.ts +10 -3
- package/src/messaging/text.ts +8 -5
- package/src/operation-liveness.ts +54 -8
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +5 -2
- package/src/progress-view.ts +49 -7
- package/src/projects.ts +57 -0
- package/src/result-slots.ts +43 -19
- package/src/result-validation.ts +18 -27
- package/src/run-command.ts +36 -32
- package/src/run-commands.ts +13 -2
- package/src/run-lock.ts +123 -0
- package/src/run-prepare.ts +94 -22
- package/src/run-state.ts +125 -6
- package/src/run-usage.ts +1 -1
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +46 -17
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +8 -2
- package/src/simple-input.ts +258 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +88 -13
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +32 -0
- package/src/testing.ts +71 -3
- package/src/tick-command.ts +87 -0
- package/src/tools.ts +636 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1210 -77
- package/src/workflow-testing/coordinator.typecheck.ts +55 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +61 -1
- package/src/workflow-testing/index.ts +488 -33
- package/src/workflow-testing/script.ts +72 -2
- package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Checked by `tsc --noEmit`, never run: story 032's workflow and its test, as an author writes them.
|
|
2
|
+
import {
|
|
3
|
+
isAnswered,
|
|
4
|
+
type ParticipantRef,
|
|
5
|
+
type WorkflowContext,
|
|
6
|
+
type WorkflowDefinition,
|
|
7
|
+
} from "@agentswf/contract/workflow";
|
|
8
|
+
import { answer, type ParticipantScript, testWorkflow } from ".";
|
|
9
|
+
|
|
10
|
+
declare const runtime: string;
|
|
11
|
+
|
|
12
|
+
const investigate: WorkflowDefinition<null, string> = {
|
|
13
|
+
meta: { name: "investigate", description: "watches checkout-api" },
|
|
14
|
+
async run(workflow: WorkflowContext) {
|
|
15
|
+
const investigator = await workflow.agents.open({ key: "investigator", runtime });
|
|
16
|
+
const coordinator: ParticipantRef = await workflow.participants.connect({
|
|
17
|
+
key: "coordinator",
|
|
18
|
+
labels: { audience: "operator" },
|
|
19
|
+
});
|
|
20
|
+
await workflow.messages.allow({
|
|
21
|
+
between: [investigator, coordinator],
|
|
22
|
+
purpose: "report findings that hold up; ask when the logs can't settle it",
|
|
23
|
+
timeoutMs: 2 * 60 * 60_000,
|
|
24
|
+
});
|
|
25
|
+
const { outcome } = await investigator.run({ prompt: "Check the checkout-api logs." });
|
|
26
|
+
await coordinator.release("investigation done");
|
|
27
|
+
// A run has no deadline unless `awf run --timeout` gives it one.
|
|
28
|
+
const left = workflow.deadline ? workflow.deadline.unixMilliseconds - Date.now() : undefined;
|
|
29
|
+
return isAnswered(outcome) ? `${outcome.value} (${left ?? "no"} ms left)` : outcome.kind;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function story(): Promise<string> {
|
|
34
|
+
const run = await testWorkflow(investigate, null, {
|
|
35
|
+
agents: { investigator: [answer("…")] },
|
|
36
|
+
participants: {
|
|
37
|
+
coordinator: (event) =>
|
|
38
|
+
event.kind === "message" && event.asks
|
|
39
|
+
? event.reply("Keep two, and link them.")
|
|
40
|
+
: undefined,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return run.value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const sendsFirst: ParticipantScript = async (event) => {
|
|
47
|
+
if (event.kind !== "route") return;
|
|
48
|
+
const sent = await event.send("Also check the 09:12 deploy.");
|
|
49
|
+
if (sent.code !== "delivered") throw new Error(sent.code);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export async function refused(workflow: WorkflowContext): Promise<void> {
|
|
53
|
+
// @ts-expect-error bindingPath is gone: the socket's place is fixed.
|
|
54
|
+
await workflow.participants.connect({ key: "coordinator", bindingPath: "/tmp/x" });
|
|
55
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { CoordinatorHolder, CoordinatorPort } from "../coordinator-port";
|
|
2
|
+
import type { HolderResult, MessagingEventBody } from "../messaging/messaging";
|
|
3
|
+
import type {
|
|
4
|
+
ParticipantMessage,
|
|
5
|
+
ParticipantOutcome,
|
|
6
|
+
ParticipantRoute,
|
|
7
|
+
ParticipantScript,
|
|
8
|
+
} from "./script";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Answers for a test's coordinators from their scripts, as a holder would: each route to one as it
|
|
12
|
+
* opens, and each message an agent sends one once recorded, is handed to its script.
|
|
13
|
+
*/
|
|
14
|
+
export class ParticipantScripts implements CoordinatorPort {
|
|
15
|
+
readonly #scripts: Readonly<Record<string, ParticipantScript>>;
|
|
16
|
+
readonly #attempt: number;
|
|
17
|
+
readonly #events: { onActivity(): void; onScriptError(message: string): void };
|
|
18
|
+
#holder: CoordinatorHolder | undefined;
|
|
19
|
+
readonly #connected = new Set<string>();
|
|
20
|
+
/** Script calls not yet settled. */
|
|
21
|
+
#working = 0;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
scripts: Readonly<Record<string, ParticipantScript>> | undefined,
|
|
25
|
+
attempt: number,
|
|
26
|
+
events: { onActivity(): void; onScriptError(message: string): void },
|
|
27
|
+
) {
|
|
28
|
+
this.#scripts = scripts ?? {};
|
|
29
|
+
this.#attempt = attempt;
|
|
30
|
+
this.#events = events;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
attach(holder: CoordinatorHolder): void {
|
|
34
|
+
this.#holder = holder;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Nothing a script was handed is still running. */
|
|
38
|
+
get idle(): boolean {
|
|
39
|
+
return this.#working === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Questions open to coordinators now; `closesAt` where one closes unanswered. */
|
|
43
|
+
open(): { closesAt?: number }[] {
|
|
44
|
+
return this.#holder?.open() ?? [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recorded(event: MessagingEventBody): void {
|
|
48
|
+
if (event.kind === "participant-connected") {
|
|
49
|
+
this.#connected.add(event.participant);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (event.kind === "route-opened") {
|
|
53
|
+
const [a, b] = event.between ?? [event.from, event.to];
|
|
54
|
+
for (const [coordinator, agent] of [
|
|
55
|
+
[a, b],
|
|
56
|
+
[b, a],
|
|
57
|
+
] as const) {
|
|
58
|
+
if (!this.#connected.has(coordinator)) continue;
|
|
59
|
+
const direction = event.between
|
|
60
|
+
? "both"
|
|
61
|
+
: event.from === coordinator
|
|
62
|
+
? "from-participant"
|
|
63
|
+
: "to-participant";
|
|
64
|
+
const route: ParticipantRoute = {
|
|
65
|
+
kind: "route",
|
|
66
|
+
agent,
|
|
67
|
+
direction,
|
|
68
|
+
purpose: event.purpose,
|
|
69
|
+
send: (text) => this.#send(coordinator, agent, text),
|
|
70
|
+
};
|
|
71
|
+
this.#call(coordinator, route);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (event.kind === "message-sent" && this.#connected.has(event.to)) {
|
|
76
|
+
const coordinator = event.to;
|
|
77
|
+
const message: ParticipantMessage = {
|
|
78
|
+
kind: "message",
|
|
79
|
+
id: this.#id(event.message),
|
|
80
|
+
from: event.from,
|
|
81
|
+
text: event.text,
|
|
82
|
+
...(event.awaitsReply ? { asks: true as const } : {}),
|
|
83
|
+
reply: (text) =>
|
|
84
|
+
this.#outcome(
|
|
85
|
+
this.#holder!.reply({
|
|
86
|
+
coordinator,
|
|
87
|
+
agent: event.from,
|
|
88
|
+
to: event.message,
|
|
89
|
+
text,
|
|
90
|
+
via: "test",
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
send: (agent, text) => this.#send(coordinator, agent, text),
|
|
94
|
+
};
|
|
95
|
+
this.#call(coordinator, message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#call(coordinator: string, event: ParticipantRoute | ParticipantMessage): void {
|
|
100
|
+
const script = this.#scripts[coordinator];
|
|
101
|
+
if (!script) {
|
|
102
|
+
this.#events.onScriptError(
|
|
103
|
+
`${event.kind === "route" ? "a route" : "a message"} reached coordinator "${coordinator}", which has no script under coordinators`,
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.#working += 1;
|
|
108
|
+
this.#events.onActivity();
|
|
109
|
+
// Never inside the record's write: a script's reply is a holder's, arriving after it.
|
|
110
|
+
void Promise.resolve()
|
|
111
|
+
.then(() => script(event))
|
|
112
|
+
.catch((error: unknown) =>
|
|
113
|
+
this.#events.onScriptError(
|
|
114
|
+
`coordinator "${coordinator}"'s script threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
.finally(() => {
|
|
118
|
+
this.#working -= 1;
|
|
119
|
+
this.#events.onActivity();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#send(coordinator: string, agent: string, text: string): Promise<ParticipantOutcome> {
|
|
124
|
+
return this.#outcome(this.#holder!.send({ coordinator, agent, text, via: "test" }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #outcome(result: Promise<HolderResult>): Promise<ParticipantOutcome> {
|
|
128
|
+
const settled = await result;
|
|
129
|
+
return settled.code === "delivered"
|
|
130
|
+
? { code: "delivered", id: this.#id(settled.message) }
|
|
131
|
+
: { code: settled.code };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#id(message: number): string {
|
|
135
|
+
return `${this.#attempt}.${message}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
type ReplyCode,
|
|
6
6
|
SEND_CODES,
|
|
7
7
|
type SendCode,
|
|
8
|
+
TOOL_CODES,
|
|
9
|
+
type ToolCode,
|
|
10
|
+
type ToolResponse,
|
|
8
11
|
WIRE_VERSION,
|
|
9
12
|
} from "@agentswf/contract/wire";
|
|
10
13
|
import {
|
|
@@ -44,6 +47,7 @@ import {
|
|
|
44
47
|
type Scripts,
|
|
45
48
|
type SendOptions,
|
|
46
49
|
type Step,
|
|
50
|
+
type ToolOutcome,
|
|
47
51
|
type Turn,
|
|
48
52
|
type TurnOutcome,
|
|
49
53
|
} from "./script";
|
|
@@ -52,7 +56,7 @@ import {
|
|
|
52
56
|
* A turn as the workflow wrote it, and how it ended. `messages` are those it was shown, absent when
|
|
53
57
|
* none were.
|
|
54
58
|
*/
|
|
55
|
-
export type TurnRecord = Omit<Turn, "signal" | "messages" | "send" | "reply"> & {
|
|
59
|
+
export type TurnRecord = Omit<Turn, "signal" | "messages" | "send" | "reply" | "tool"> & {
|
|
56
60
|
messages?: readonly Message[];
|
|
57
61
|
outcome: TurnOutcome;
|
|
58
62
|
};
|
|
@@ -133,6 +137,8 @@ export function createScriptedHost(
|
|
|
133
137
|
onActivity(): void;
|
|
134
138
|
/** The script could not meet a turn: the test fails. */
|
|
135
139
|
onScriptError(message: string): void;
|
|
140
|
+
/** A turn began, before its script answers: what a test sends during it goes now. */
|
|
141
|
+
onTurnStarted?(turn: TurnRecord): Promise<void>;
|
|
136
142
|
},
|
|
137
143
|
/**
|
|
138
144
|
* The session the run is started from, as `awf run --here` finds one, and the model its files
|
|
@@ -232,6 +238,51 @@ export function createScriptedHost(
|
|
|
232
238
|
});
|
|
233
239
|
};
|
|
234
240
|
|
|
241
|
+
/** `wf tool` from a running script, as JSON or no input; waits as long as the engine takes. */
|
|
242
|
+
const callTool = async (
|
|
243
|
+
invocation: Invocation,
|
|
244
|
+
name: string,
|
|
245
|
+
input: JsonObject | undefined,
|
|
246
|
+
): Promise<ToolOutcome> => {
|
|
247
|
+
if (invocation.state !== "running") {
|
|
248
|
+
const why = `agent "${invocation.turn.agent}" turn ${invocation.turn.n}: its script called a tool after its turn ended`;
|
|
249
|
+
failTest(why);
|
|
250
|
+
throw new Error(why);
|
|
251
|
+
}
|
|
252
|
+
const call = submitControl(
|
|
253
|
+
invocation.operation.binding.endpoint,
|
|
254
|
+
{
|
|
255
|
+
version: WIRE_VERSION,
|
|
256
|
+
command: "tool",
|
|
257
|
+
tool: name,
|
|
258
|
+
...(input === undefined ? {} : { raw: JSON.stringify(input) }),
|
|
259
|
+
},
|
|
260
|
+
null,
|
|
261
|
+
);
|
|
262
|
+
invocation.calls.add(call);
|
|
263
|
+
let response: ToolResponse;
|
|
264
|
+
try {
|
|
265
|
+
response = await call;
|
|
266
|
+
} finally {
|
|
267
|
+
invocation.calls.delete(call);
|
|
268
|
+
// A reply it awaits ends the turn once nothing of the host's is in flight, this call included.
|
|
269
|
+
suspendIfAwaiting(invocation);
|
|
270
|
+
}
|
|
271
|
+
switch (response.kind) {
|
|
272
|
+
case "tool-result":
|
|
273
|
+
return {
|
|
274
|
+
kind: "result",
|
|
275
|
+
...(response.output === undefined ? {} : { output: response.output }),
|
|
276
|
+
};
|
|
277
|
+
case "tool-failed":
|
|
278
|
+
return { kind: "failed", outcome: response.outcome, error: response.error };
|
|
279
|
+
default:
|
|
280
|
+
if (response.code === "internal-error" || !TOOL_REFUSALS.has(response.code))
|
|
281
|
+
throw new Error(`wf tool was refused (${response.code}): ${response.error}`);
|
|
282
|
+
return { kind: "refused", code: response.code as ToolCode, error: response.error };
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
235
286
|
/**
|
|
236
287
|
* Ends the native turn when its script has gone still awaiting a reply, as an agent ends its turn
|
|
237
288
|
* after `wf send --await-reply`: nothing of the host's in flight, and no step taken since.
|
|
@@ -271,6 +322,7 @@ export function createScriptedHost(
|
|
|
271
322
|
messages,
|
|
272
323
|
send: (peer, text, options) => message(invocation, "send", peer, text, options),
|
|
273
324
|
reply: (peer, text, options) => message(invocation, "reply", peer, text, options),
|
|
325
|
+
tool: (name, input) => callTool(invocation, name, input),
|
|
274
326
|
signal: context.signal,
|
|
275
327
|
};
|
|
276
328
|
invocation.turn = turn;
|
|
@@ -285,6 +337,7 @@ export function createScriptedHost(
|
|
|
285
337
|
turn,
|
|
286
338
|
reply: (text, options) => turn.reply(woke!.from, text, options),
|
|
287
339
|
send: turn.send,
|
|
340
|
+
tool: turn.tool,
|
|
288
341
|
silent: reply.silent,
|
|
289
342
|
fail: (reason) => reply.failed(reason),
|
|
290
343
|
} satisfies ReceivedMessage,
|
|
@@ -465,6 +518,7 @@ export function createScriptedHost(
|
|
|
465
518
|
messages: [],
|
|
466
519
|
send: noMessages,
|
|
467
520
|
reply: noMessages,
|
|
521
|
+
tool: noTools,
|
|
468
522
|
cwd: activation.cwd,
|
|
469
523
|
model: context.settings.model,
|
|
470
524
|
...(context.settings.effort === undefined ? {} : { effort: context.settings.effort }),
|
|
@@ -555,6 +609,7 @@ export function createScriptedHost(
|
|
|
555
609
|
};
|
|
556
610
|
turns.push(record);
|
|
557
611
|
open.add(record);
|
|
612
|
+
if (!record.nudge && context.kind === "turn") await events.onTurnStarted?.(record);
|
|
558
613
|
|
|
559
614
|
// A reply or a failure settles the send awaiting it, and resumes the script that awaits it.
|
|
560
615
|
const settled: [Awaiting, MessageOutcome][] = [];
|
|
@@ -735,6 +790,7 @@ type Invocation = {
|
|
|
735
790
|
};
|
|
736
791
|
|
|
737
792
|
const MESSAGE_CODES = new Set<string>([...SEND_CODES, ...REPLY_CODES]);
|
|
793
|
+
const TOOL_REFUSALS = new Set<string>(TOOL_CODES);
|
|
738
794
|
/** The call ended before the script's answer reached it: the turn was let go, not refused. */
|
|
739
795
|
const CLOSED_CODES = new Set<string>([
|
|
740
796
|
"closed-operation",
|
|
@@ -756,6 +812,10 @@ async function noMessages(): Promise<MessageOutcome> {
|
|
|
756
812
|
throw new Error("a compaction sends no messages");
|
|
757
813
|
}
|
|
758
814
|
|
|
815
|
+
async function noTools(): Promise<ToolOutcome> {
|
|
816
|
+
throw new Error("a compaction calls no tools");
|
|
817
|
+
}
|
|
818
|
+
|
|
759
819
|
/** What the pane host refuses before it opens a pane. */
|
|
760
820
|
function paneRefusal(activation: HarnessActivation): string | undefined {
|
|
761
821
|
const { harness } = activation.execution;
|