@agentswf/engine 0.1.0-beta.5 → 0.1.0
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/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +28 -55
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +4 -4
- package/src/attempt-output.ts +13 -31
- package/src/attempt-view.ts +33 -32
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +4 -6
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +27 -28
- package/src/deadlines.ts +6 -40
- package/src/decisions/directory.ts +50 -37
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +19 -33
- package/src/emit-command.ts +38 -22
- package/src/emit.ts +148 -59
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +11 -7
- package/src/help/inbox.txt +5 -3
- package/src/help/logs.txt +7 -5
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +13 -8
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +5 -13
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +54 -136
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +42 -56
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.ts.template +3 -2
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +7 -19
- package/src/logs-command.ts +134 -53
- package/src/message-operation.ts +6 -3
- package/src/messaging/messaging.ts +8 -10
- package/src/messaging/text.ts +2 -2
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +206 -90
- package/src/operator-runtime.ts +24 -2
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +49 -96
- package/src/progress-view.ts +2 -2
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +27 -31
- package/src/run-commands.ts +42 -114
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/run-policy/stage-plan.ts +154 -0
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +165 -41
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +17 -11
- package/src/run-view.ts +73 -14
- package/src/runs-command.ts +81 -63
- package/src/runs.ts +284 -222
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +2 -39
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +111 -153
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +37 -19
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +66 -18
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +11 -30
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +49 -2
- package/src/workflow-runner.ts +525 -618
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +1 -1
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +28 -29
- package/src/workflow-testing/index.ts +9 -27
- package/src/workflow-testing/run-test.ts +201 -286
- package/src/workflow-testing/script.ts +7 -39
- package/src/deprecations.ts +0 -177
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -972
- package/src/stage-plan.ts +0 -186
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// `awf test` preloads this into `bun test`, so a workflow's tests in any folder import the author
|
|
2
|
-
// surface and `agentswf/testing` with nothing installed there
|
|
2
|
+
// surface and `agentswf/testing` with nothing installed there; and `agentswf/client`, so a
|
|
3
|
+
// program's code under test and the fake client share one `ClientError`.
|
|
3
4
|
import { plugin } from "bun";
|
|
5
|
+
import * as programs from "./client/published";
|
|
4
6
|
import { serveAuthorSurface } from "./workflow-loader";
|
|
5
7
|
import * as testing from "./workflow-testing";
|
|
6
8
|
|
|
@@ -9,5 +11,6 @@ plugin({
|
|
|
9
11
|
name: "agentswf testing",
|
|
10
12
|
setup(build) {
|
|
11
13
|
build.module("agentswf/testing", () => ({ exports: testing, loader: "object" }));
|
|
14
|
+
build.module("agentswf/client", () => ({ exports: programs, loader: "object" }));
|
|
12
15
|
},
|
|
13
16
|
});
|
|
@@ -127,7 +127,7 @@ export class ParticipantScripts implements CoordinatorPort {
|
|
|
127
127
|
async #outcome(result: Promise<HolderResult>): Promise<ParticipantOutcome> {
|
|
128
128
|
const settled = await result;
|
|
129
129
|
return settled.code === "delivered"
|
|
130
|
-
? {
|
|
130
|
+
? { id: this.#id(settled.message) }
|
|
131
131
|
: { code: settled.code };
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// A program's tests' `Client`: keeps the calls it is given and answers as the real one's rules do,
|
|
2
|
+
// or as the test scripts. It makes no run and starts no process.
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type Client,
|
|
6
|
+
ClientError,
|
|
7
|
+
type Run,
|
|
8
|
+
type RunStatus,
|
|
9
|
+
type StartResult,
|
|
10
|
+
} from "@agentswf/contract/client";
|
|
11
|
+
import { type EmitResultJson, emitOk, type InboxItemJson } from "@agentswf/contract/records";
|
|
12
|
+
import {
|
|
13
|
+
awfEventOf,
|
|
14
|
+
isEventWorkflow,
|
|
15
|
+
type JsonValue,
|
|
16
|
+
nameProblem,
|
|
17
|
+
} from "@agentswf/contract/workflow";
|
|
18
|
+
import { canonical } from "../canonical-json";
|
|
19
|
+
import {
|
|
20
|
+
assertEventWorkflow,
|
|
21
|
+
checkArgs,
|
|
22
|
+
checkCursor,
|
|
23
|
+
checkEmit,
|
|
24
|
+
checkRunId,
|
|
25
|
+
derivedId,
|
|
26
|
+
fileOf,
|
|
27
|
+
nameOf,
|
|
28
|
+
} from "../client/checks";
|
|
29
|
+
import { isFinal } from "../client/runs";
|
|
30
|
+
import { checkEvent, EmitError } from "../emit";
|
|
31
|
+
import { messageOf } from "../errors";
|
|
32
|
+
import { prepareArgs } from "../run-prepare";
|
|
33
|
+
import type { AnyWorkflow, EventWorkflow } from "../workflow-loader";
|
|
34
|
+
|
|
35
|
+
/** A call a fake client was given, in order, whether or not it was refused. */
|
|
36
|
+
export type FakeClientCall =
|
|
37
|
+
| {
|
|
38
|
+
kind: "emit";
|
|
39
|
+
workflow: string;
|
|
40
|
+
/** `{workflow}/{id}`. */
|
|
41
|
+
ref: string;
|
|
42
|
+
name: string;
|
|
43
|
+
data: JsonValue;
|
|
44
|
+
eventId?: string;
|
|
45
|
+
source?: string;
|
|
46
|
+
}
|
|
47
|
+
/** `ref` names the run `id`, `id(args)` or the fake named; absent when none could be named. */
|
|
48
|
+
| { kind: "start"; workflow: string; ref?: string; args: JsonValue }
|
|
49
|
+
| { kind: "get"; workflow: string; ref: string }
|
|
50
|
+
| { kind: "inbox"; workflow: string; after?: string };
|
|
51
|
+
|
|
52
|
+
/** What a test scripts; anything it leaves out is answered as the real client's rules would. */
|
|
53
|
+
export type FakeClientScript = {
|
|
54
|
+
/**
|
|
55
|
+
* What became of an emit, for the call given, in place of what the rules would say; undefined
|
|
56
|
+
* answers by the rules. `why` alone means nothing was done (`did: "none"`). What it says is what
|
|
57
|
+
* the fake then knows: a refused or duplicate event leaves no new run, a `launch-failed` a run
|
|
58
|
+
* not launched, a `stuck` one stuck.
|
|
59
|
+
*/
|
|
60
|
+
emit?: (
|
|
61
|
+
call: Extract<FakeClientCall, { kind: "emit" }>,
|
|
62
|
+
) => Partial<Pick<EmitResultJson, "outcome" | "did" | "why" | "error">> | undefined;
|
|
63
|
+
/** A start's `did` and `error`, for the call given; undefined answers by the rules. */
|
|
64
|
+
start?: (
|
|
65
|
+
call: Extract<FakeClientCall, { kind: "start" }> & { ref: string },
|
|
66
|
+
) => { did: StartResult<JsonValue>["did"]; error?: string } | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* By ref: the rests the run comes to, in order. A wait takes the next one, as the run's next rest
|
|
69
|
+
* after the call; a run that isn't running, by the last one taken, is answered at once, as the
|
|
70
|
+
* real client does. Its status is the last one taken; an emit or start that launches it makes it
|
|
71
|
+
* `running` again, and a launch that fails leaves it at its last rest, or `interrupted` (`stuck`
|
|
72
|
+
* after three) if it has none. Its `final` is the real client's, from its state and whether the
|
|
73
|
+
* workflow takes events, whatever the rest scripted says. A start with no id names its run
|
|
74
|
+
* `fake-1`, `fake-2`, and so on, which this can name. Attempts that fail or crash count toward `stuck` only as the rests
|
|
75
|
+
* scripted say. A run named here is there for `get`; the first `start` or event to
|
|
76
|
+
* reach it, before a wait takes one of its rests, is the one that made it.
|
|
77
|
+
*/
|
|
78
|
+
rests?: Record<string, RunStatus<JsonValue>[]>;
|
|
79
|
+
/** What `inbox` returns, for the call given. */
|
|
80
|
+
inbox?: (call: Extract<FakeClientCall, { kind: "inbox" }>) => InboxItemJson[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type FakeClient = Client & {
|
|
84
|
+
/** Every call, in the order given. */
|
|
85
|
+
readonly calls: FakeClientCall[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A run the fake knows: begun by an event or a start, launched or not, and what it took. */
|
|
89
|
+
type Known = {
|
|
90
|
+
/** The args it was started with; absent for one an event began. */
|
|
91
|
+
args?: JsonValue;
|
|
92
|
+
/** Whether an attempt was ever launched: until then, a wake `started` it, after, `continued`. */
|
|
93
|
+
attempted: boolean;
|
|
94
|
+
/** Launches that failed in a row: three make it stuck, as the real rule does. */
|
|
95
|
+
failed: number;
|
|
96
|
+
/** The last rest a wait took. */
|
|
97
|
+
rest?: RunStatus<JsonValue>;
|
|
98
|
+
/** Named only by `rests`: the first start of it is the one that made it. */
|
|
99
|
+
scripted?: true;
|
|
100
|
+
eventIds: Set<string>;
|
|
101
|
+
status: RunStatus<JsonValue>;
|
|
102
|
+
rests: RunStatus<JsonValue>[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const RUNNING: RunStatus<JsonValue> = { state: "running", final: false };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A `Client` for a program's tests, as `testWorkflow` is for a workflow's: it runs the real
|
|
109
|
+
* client's checks on what it is given, so a test sees the `ClientError` the real one throws, and
|
|
110
|
+
* keeps its calls. Unscripted, it answers as the real rules do: an event to a new id is `started`
|
|
111
|
+
* if it may start a run and refused `not-a-start` if not; to a run there, `refused` `returned` for
|
|
112
|
+
* one that completed (`none`, `returned`, if resent), and otherwise woken as the real `wake` does, a `duplicate` for an event id it
|
|
113
|
+
* has: `started` for a run whose launch failed (`stuck` after three), `nudged` while running,
|
|
114
|
+
* `continued` once parked, failed or crashed, nothing for one stopped, cancelled or stuck; a reply
|
|
115
|
+
* to no run throws `no-run`; a start is `started` for a new id, `exists` for one there with the same args, and
|
|
116
|
+
* `other-args` for other args, or a run an event began.
|
|
117
|
+
*/
|
|
118
|
+
export function fakeClient(script: FakeClientScript = {}): FakeClient {
|
|
119
|
+
const calls: FakeClientCall[] = [];
|
|
120
|
+
const known = new Map<string, Known>();
|
|
121
|
+
const fresh = (args?: JsonValue): Known => ({
|
|
122
|
+
...(args === undefined ? {} : { args }),
|
|
123
|
+
attempted: true,
|
|
124
|
+
failed: 0,
|
|
125
|
+
eventIds: new Set(),
|
|
126
|
+
status: RUNNING,
|
|
127
|
+
rests: [],
|
|
128
|
+
});
|
|
129
|
+
for (const [ref, rests] of Object.entries(script.rests ?? {}))
|
|
130
|
+
known.set(ref, { ...fresh(), rests: [...rests], scripted: true });
|
|
131
|
+
|
|
132
|
+
/** `final`, as the real client says it: from the state, and whether the run takes events. */
|
|
133
|
+
const handle = (ref: string, events: boolean): Run<never> => {
|
|
134
|
+
const run = known.get(ref)!;
|
|
135
|
+
const now = (): RunStatus<JsonValue> => ({
|
|
136
|
+
...run.status,
|
|
137
|
+
final: isFinal(run.status.state, events),
|
|
138
|
+
});
|
|
139
|
+
const made: Run<JsonValue> = {
|
|
140
|
+
id: ref.slice(ref.indexOf("/") + 1),
|
|
141
|
+
ref,
|
|
142
|
+
status: async () => now(),
|
|
143
|
+
wait: async (options = {}) => {
|
|
144
|
+
options.signal?.throwIfAborted();
|
|
145
|
+
if (options.final ? now().final : run.status.state !== "running") return now();
|
|
146
|
+
for (let next = run.rests.shift(); next; next = run.rests.shift()) {
|
|
147
|
+
run.status = next;
|
|
148
|
+
run.rest = next;
|
|
149
|
+
if (!options.final || now().final) return now();
|
|
150
|
+
}
|
|
151
|
+
const { signal } = options;
|
|
152
|
+
if (!signal)
|
|
153
|
+
throw new Error(
|
|
154
|
+
`fakeClient: ${ref} has no rest scripted for this wait, and no signal ends it`,
|
|
155
|
+
);
|
|
156
|
+
return new Promise<never>((_, reject) =>
|
|
157
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true }),
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return made as Run<never>;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** What the real `wake` does to a run in this state: start, nudge, continue, or nothing. */
|
|
165
|
+
const wake = (
|
|
166
|
+
run: Known,
|
|
167
|
+
duplicate: boolean,
|
|
168
|
+
): Pick<EmitResultJson, "did"> & Partial<Pick<EmitResultJson, "why">> => {
|
|
169
|
+
if (run.failed >= 3) return { did: "none", why: "stuck" };
|
|
170
|
+
if (!run.attempted) return { did: "started" };
|
|
171
|
+
switch (run.status.state) {
|
|
172
|
+
case "running":
|
|
173
|
+
return duplicate ? { did: "none" } : { did: "nudged" };
|
|
174
|
+
case "completed":
|
|
175
|
+
return { did: "none", why: "returned" };
|
|
176
|
+
case "stopped":
|
|
177
|
+
case "cancelled":
|
|
178
|
+
case "stuck":
|
|
179
|
+
return { did: "none", why: run.status.state };
|
|
180
|
+
default:
|
|
181
|
+
// Parked, failed, timed-out or interrupted: continued. A parked run whose wait doesn't take
|
|
182
|
+
// the event is `nothing-due` to the real client; script that.
|
|
183
|
+
return { did: "continued" };
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** A run only `rests` names, no wait having taken a rest: the first start of it makes it. */
|
|
188
|
+
const unclaimedByStart = (run: Known): boolean =>
|
|
189
|
+
run.scripted === true && !("args" in run) && run.rest === undefined;
|
|
190
|
+
|
|
191
|
+
/** What the real client then reads of a run whose launch just failed. */
|
|
192
|
+
const launchFailed = (run: Known, events: boolean, stuck: boolean): void => {
|
|
193
|
+
run.failed = stuck ? Math.max(run.failed, 3) : run.failed + 1;
|
|
194
|
+
if (run.failed >= 3) run.status = { state: "stuck", final: true };
|
|
195
|
+
else if (!run.attempted) run.status = { state: "interrupted", final: !events };
|
|
196
|
+
else if (run.rest) run.status = run.rest;
|
|
197
|
+
};
|
|
198
|
+
const launchedNow = (run: Known): void => {
|
|
199
|
+
run.attempted = true;
|
|
200
|
+
run.failed = 0;
|
|
201
|
+
run.status = RUNNING;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The real client's checks of a workflow it is given that need no file: its `file` and name. */
|
|
205
|
+
const located = (workflow: AnyWorkflow): string => {
|
|
206
|
+
fileOf(workflow);
|
|
207
|
+
return nameOf(workflow);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const client: Client = {
|
|
211
|
+
emit: async (workflow, run, name, data, given = {}) => {
|
|
212
|
+
const target = workflow as unknown as EventWorkflow;
|
|
213
|
+
const ref = `${target?.meta?.name}/${run}`;
|
|
214
|
+
const call = {
|
|
215
|
+
kind: "emit" as const,
|
|
216
|
+
workflow: target?.meta?.name,
|
|
217
|
+
ref,
|
|
218
|
+
name,
|
|
219
|
+
data: data as JsonValue,
|
|
220
|
+
...(given.eventId === undefined ? {} : { eventId: given.eventId }),
|
|
221
|
+
...(given.source === undefined ? {} : { source: given.source }),
|
|
222
|
+
};
|
|
223
|
+
calls.push(call);
|
|
224
|
+
assertEventWorkflow(target);
|
|
225
|
+
located(target);
|
|
226
|
+
checkEmit(ref, run, name, data, given);
|
|
227
|
+
const reply = awfEventOf(name)?.kind === "reply";
|
|
228
|
+
try {
|
|
229
|
+
checkEvent(
|
|
230
|
+
target,
|
|
231
|
+
{ name, data: call.data, ...(given.eventId === undefined ? {} : { id: given.eventId }) },
|
|
232
|
+
reply,
|
|
233
|
+
);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof EmitError) throw new ClientError(error.code, error.message, ref);
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
const sourceProblem = () => {
|
|
239
|
+
if (given.source === undefined) return;
|
|
240
|
+
const bad = nameProblem(given.source);
|
|
241
|
+
if (bad)
|
|
242
|
+
throw new ClientError("event", `the source ${JSON.stringify(given.source)}: ${bad}`, ref);
|
|
243
|
+
};
|
|
244
|
+
if (!reply) sourceProblem();
|
|
245
|
+
checkRunId(run, ref);
|
|
246
|
+
const result = (
|
|
247
|
+
fields: Omit<EmitResultJson, "v" | "ok" | "ref" | "eventId">,
|
|
248
|
+
): EmitResultJson => ({
|
|
249
|
+
v: 1,
|
|
250
|
+
ok: emitOk(fields.outcome, fields.why),
|
|
251
|
+
ref,
|
|
252
|
+
eventId: given.eventId ?? `awf.fake${calls.length}`,
|
|
253
|
+
...fields,
|
|
254
|
+
});
|
|
255
|
+
let state = known.get(ref);
|
|
256
|
+
// A run only `rests` names, no wait having taken a rest: its first event is the one that
|
|
257
|
+
// makes it, as a first start is, and its rests are kept.
|
|
258
|
+
const unclaimed = state?.scripted && state.rest === undefined ? state : undefined;
|
|
259
|
+
if (unclaimed) state = undefined;
|
|
260
|
+
if (!state && reply)
|
|
261
|
+
throw new ClientError("no-run", `no run ${ref}: a reply never starts one`, ref);
|
|
262
|
+
if (reply) sourceProblem();
|
|
263
|
+
const duplicate =
|
|
264
|
+
state !== undefined && given.eventId !== undefined && state.eventIds.has(given.eventId);
|
|
265
|
+
let made = false;
|
|
266
|
+
let answer: Omit<EmitResultJson, "v" | "ok" | "ref" | "eventId">;
|
|
267
|
+
if (!duplicate && state?.status.state === "completed")
|
|
268
|
+
answer = { outcome: "refused", did: "none", why: "returned" };
|
|
269
|
+
else if (!state && !(target.starts?.includes(name) ?? true))
|
|
270
|
+
answer = { outcome: "refused", did: "none", why: "not-a-start" };
|
|
271
|
+
else {
|
|
272
|
+
if (!state) {
|
|
273
|
+
// As the real client checks before it makes the run: what `prepare` makes of no argv.
|
|
274
|
+
try {
|
|
275
|
+
prepareArgs(target, [], dirname(target.file!));
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw new ClientError(
|
|
278
|
+
"prepare",
|
|
279
|
+
`${target.meta.name}: a run an event starts has no arguments, which its prepare refuses: ${messageOf(error)}`,
|
|
280
|
+
ref,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
state = { ...fresh(), attempted: false, rests: unclaimed?.rests ?? [] };
|
|
284
|
+
made = true;
|
|
285
|
+
}
|
|
286
|
+
// A duplicate isn't appended, but wakes the run, as a retry after a failed launch does;
|
|
287
|
+
// a live run has it already, and isn't nudged.
|
|
288
|
+
answer = { outcome: duplicate ? "duplicate" : "accepted", ...wake(state, duplicate) };
|
|
289
|
+
}
|
|
290
|
+
const scripted = script.emit?.(call);
|
|
291
|
+
if (scripted) {
|
|
292
|
+
answer = { ...answer, ...scripted };
|
|
293
|
+
if (scripted.why !== undefined && scripted.did === undefined) answer.did = "none";
|
|
294
|
+
if (scripted.why === undefined && scripted.did !== undefined && scripted.did !== "none")
|
|
295
|
+
delete answer.why;
|
|
296
|
+
}
|
|
297
|
+
if (!state || answer.outcome === "refused" || (made && answer.outcome === "duplicate"))
|
|
298
|
+
return result(answer);
|
|
299
|
+
if (made && unclaimed) {
|
|
300
|
+
// Made in place: a handle `get` gave before sees it.
|
|
301
|
+
Object.assign(unclaimed, state);
|
|
302
|
+
state = unclaimed;
|
|
303
|
+
} else if (made) known.set(ref, state);
|
|
304
|
+
delete state.scripted;
|
|
305
|
+
if (given.eventId !== undefined) state.eventIds.add(given.eventId);
|
|
306
|
+
if (answer.did === "started" || answer.did === "continued") launchedNow(state);
|
|
307
|
+
else if (answer.why === "launch-failed" || answer.why === "stuck")
|
|
308
|
+
launchFailed(state, true, answer.why === "stuck");
|
|
309
|
+
return result(answer);
|
|
310
|
+
},
|
|
311
|
+
start: async (workflow, args, given = {}) => {
|
|
312
|
+
const target = workflow as unknown as AnyWorkflow;
|
|
313
|
+
const call: Extract<FakeClientCall, { kind: "start" }> = {
|
|
314
|
+
kind: "start",
|
|
315
|
+
workflow: target?.meta?.name,
|
|
316
|
+
args: args as JsonValue,
|
|
317
|
+
};
|
|
318
|
+
calls.push(call);
|
|
319
|
+
const name = located(target);
|
|
320
|
+
checkArgs(name, args);
|
|
321
|
+
let id = given.id ?? derivedId(target, args);
|
|
322
|
+
// As the real client generates one: an id no run here holds.
|
|
323
|
+
// A run `rests` names and nothing made yet is the one this start makes.
|
|
324
|
+
for (let n = 1; id === undefined; n++) {
|
|
325
|
+
const there = known.get(`${name}/fake-${n}`);
|
|
326
|
+
if (!there || unclaimedByStart(there)) id = `fake-${n}`;
|
|
327
|
+
}
|
|
328
|
+
const ref = `${name}/${id}`;
|
|
329
|
+
call.ref = ref;
|
|
330
|
+
checkRunId(id, ref);
|
|
331
|
+
let there = known.get(ref);
|
|
332
|
+
if (there && unclaimedByStart(there)) {
|
|
333
|
+
there.args = call.args;
|
|
334
|
+
delete there.scripted;
|
|
335
|
+
there = undefined;
|
|
336
|
+
}
|
|
337
|
+
if (there) {
|
|
338
|
+
if (
|
|
339
|
+
!("args" in there) ||
|
|
340
|
+
JSON.stringify(canonical(there.args as JsonValue)) !==
|
|
341
|
+
JSON.stringify(canonical(call.args))
|
|
342
|
+
)
|
|
343
|
+
throw new ClientError(
|
|
344
|
+
"other-args",
|
|
345
|
+
`${ref} is there already, with other args or none recorded`,
|
|
346
|
+
ref,
|
|
347
|
+
);
|
|
348
|
+
if (there.attempted)
|
|
349
|
+
return { ok: true, run: handle(ref, isEventWorkflow(target)), did: "exists" };
|
|
350
|
+
if (there.failed >= 3)
|
|
351
|
+
return { ok: false, run: handle(ref, isEventWorkflow(target)), did: "stuck" };
|
|
352
|
+
} else if (!known.has(ref)) known.set(ref, fresh(call.args));
|
|
353
|
+
const run = known.get(ref)!;
|
|
354
|
+
const did = script.start?.({ ...call, ref }) ?? { did: there ? "exists" : "started" };
|
|
355
|
+
const answer = {
|
|
356
|
+
ok: did.did === "started" || did.did === "exists",
|
|
357
|
+
run: handle(ref, isEventWorkflow(target)),
|
|
358
|
+
...did,
|
|
359
|
+
} as StartResult<never>;
|
|
360
|
+
if (answer.did === "launch-failed" || answer.did === "stuck") {
|
|
361
|
+
if (!there) run.attempted = false;
|
|
362
|
+
launchFailed(run, isEventWorkflow(target), answer.did === "stuck");
|
|
363
|
+
} else launchedNow(run);
|
|
364
|
+
return answer;
|
|
365
|
+
},
|
|
366
|
+
get: async (workflow, run) => {
|
|
367
|
+
const ref = `${(workflow as unknown as AnyWorkflow)?.meta?.name}/${run}`;
|
|
368
|
+
calls.push({ kind: "get", workflow: (workflow as unknown as AnyWorkflow)?.meta?.name, ref });
|
|
369
|
+
located(workflow as unknown as AnyWorkflow);
|
|
370
|
+
checkRunId(run, ref);
|
|
371
|
+
if (!known.has(ref)) throw new ClientError("no-run", `no run ${ref}`, ref);
|
|
372
|
+
return handle(ref, isEventWorkflow(workflow as unknown as AnyWorkflow));
|
|
373
|
+
},
|
|
374
|
+
inbox: async (workflow, given = {}) => {
|
|
375
|
+
const call = {
|
|
376
|
+
kind: "inbox" as const,
|
|
377
|
+
workflow: (workflow as unknown as AnyWorkflow)?.meta?.name,
|
|
378
|
+
...(given.after === undefined ? {} : { after: given.after }),
|
|
379
|
+
};
|
|
380
|
+
calls.push(call);
|
|
381
|
+
located(workflow as unknown as AnyWorkflow);
|
|
382
|
+
checkCursor(given.after);
|
|
383
|
+
return script.inbox?.(call) ?? [];
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
return Object.assign(client, { calls });
|
|
387
|
+
}
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
type Effort,
|
|
17
17
|
type JsonObject,
|
|
18
18
|
type KeepPane,
|
|
19
|
-
type PaneLayout,
|
|
20
19
|
type PaneSpec,
|
|
20
|
+
type PaneWorkspace,
|
|
21
21
|
placementOf,
|
|
22
22
|
positiveDurationMilliseconds,
|
|
23
23
|
} from "@agentswf/contract/workflow";
|
|
@@ -39,14 +39,11 @@ import {
|
|
|
39
39
|
type FakeFork,
|
|
40
40
|
type FakeSet,
|
|
41
41
|
} from "@agentswf/harness/testing";
|
|
42
|
-
import { submitControl
|
|
43
|
-
import type { Deprecated } from "../deprecations";
|
|
42
|
+
import { submitControl } from "../agent-cli/client";
|
|
44
43
|
import {
|
|
45
|
-
deprecatedAs,
|
|
46
44
|
type Message,
|
|
47
45
|
type MessageOutcome,
|
|
48
46
|
type MessageScripts,
|
|
49
|
-
noAnswer,
|
|
50
47
|
outcomeOf,
|
|
51
48
|
type ReceivedMessage,
|
|
52
49
|
type Scripts,
|
|
@@ -92,7 +89,6 @@ export type OpenedAgent = {
|
|
|
92
89
|
key: string;
|
|
93
90
|
execution: AgentExecution;
|
|
94
91
|
instructions?: string;
|
|
95
|
-
labels?: JsonObject;
|
|
96
92
|
/** The skills it was given by name; absent when the workflow named none. */
|
|
97
93
|
skills?: readonly string[];
|
|
98
94
|
/** The sandbox it ran in; absent on the host. */
|
|
@@ -107,19 +103,21 @@ export type OpenedAgent = {
|
|
|
107
103
|
* host has no screen, so nothing says where a pane landed or whether a `beside` fell back.
|
|
108
104
|
*/
|
|
109
105
|
pane?: PaneSpec;
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* The run's `panes` as the host was asked to apply them to this pane: the `workspace` its tab
|
|
108
|
+
* opens in, which a `beside` takes from the pane it splits, and whether it is `keep`.
|
|
109
|
+
*/
|
|
110
|
+
panes?: { workspace?: PaneWorkspace; keep?: KeepPane };
|
|
111
|
+
/** Why the engine already knew `pane` could not be used: a `beside` an agent with no pane. */
|
|
112
|
+
paneFallback?: string;
|
|
113
|
+
/** Its pane was kept when it was done: `panes.keep` asked, and its last operation's end let it. */
|
|
116
114
|
keptPane?: true;
|
|
117
115
|
};
|
|
118
116
|
|
|
119
117
|
/** The sandbox an agent ran in, as the run's record keeps it. */
|
|
120
118
|
export type AgentSandbox = Pick<SandboxRecord, "key" | "provider" | "spec" | "domains">;
|
|
121
119
|
|
|
122
|
-
|
|
120
|
+
type ScriptedHost = {
|
|
123
121
|
factory: AgentRunHostFactory;
|
|
124
122
|
turns: TestTurn[];
|
|
125
123
|
compactions: CompactionRecord[];
|
|
@@ -149,8 +147,6 @@ export function createScriptedHost(
|
|
|
149
147
|
onActivity(): void;
|
|
150
148
|
/** The script could not meet a turn: the test fails. */
|
|
151
149
|
onScriptError(message: string): void;
|
|
152
|
-
/** A script ended a turn through a deprecated name, for the run to log. */
|
|
153
|
-
onDeprecated(name: Deprecated, subject?: string): void;
|
|
154
150
|
/** A turn began, before its script answers: what a test sends during it goes now. */
|
|
155
151
|
onTurnStarted?(turn: TestTurn): Promise<void>;
|
|
156
152
|
},
|
|
@@ -222,13 +218,10 @@ export function createScriptedHost(
|
|
|
222
218
|
failTest(why);
|
|
223
219
|
throw new Error(why);
|
|
224
220
|
}
|
|
225
|
-
if (options.timeout != null && options.timeoutMs != null)
|
|
226
|
-
throw new Error(`a ${command}'s timeout is given alone: timeoutMs is its deprecated form`);
|
|
227
221
|
const timeoutMs =
|
|
228
222
|
options.timeout == null
|
|
229
|
-
?
|
|
223
|
+
? undefined
|
|
230
224
|
: positiveDurationMilliseconds(options.timeout, `a ${command}'s timeout`);
|
|
231
|
-
if (options.timeoutMs != null) events.onDeprecated("timeout-ms", `on a script's ${command}`);
|
|
232
225
|
const call = submitControl(operation.binding.endpoint, {
|
|
233
226
|
version: WIRE_VERSION,
|
|
234
227
|
command,
|
|
@@ -361,8 +354,6 @@ export function createScriptedHost(
|
|
|
361
354
|
reply: (text, options) => turn.reply(woke!.from, text, options),
|
|
362
355
|
send: turn.send,
|
|
363
356
|
tool: turn.tool,
|
|
364
|
-
silent: deprecatedAs("message-silent", noAnswer.silent),
|
|
365
|
-
fail: deprecatedAs("message-fail", noAnswer.failed),
|
|
366
357
|
} satisfies ReceivedMessage,
|
|
367
358
|
operation.message!,
|
|
368
359
|
);
|
|
@@ -441,7 +432,7 @@ export function createScriptedHost(
|
|
|
441
432
|
// As an agent's `wfa send` returns before it can run `wfa result`.
|
|
442
433
|
if (invocation) await Promise.allSettled([...invocation.calls]);
|
|
443
434
|
const { endpoint, operationId } = context.binding!;
|
|
444
|
-
const response = await
|
|
435
|
+
const response = await submitControl(endpoint, {
|
|
445
436
|
version: WIRE_VERSION,
|
|
446
437
|
command: "result",
|
|
447
438
|
operationId,
|
|
@@ -468,12 +459,11 @@ export function createScriptedHost(
|
|
|
468
459
|
};
|
|
469
460
|
}
|
|
470
461
|
const { ending } = step;
|
|
471
|
-
if (step.deprecated) events.onDeprecated(step.deprecated);
|
|
472
462
|
if (ending.kind === "waiting") {
|
|
473
463
|
return {
|
|
474
464
|
act: async () => {
|
|
475
465
|
const { endpoint, operationId } = context.binding!;
|
|
476
|
-
const response = await
|
|
466
|
+
const response = await submitControl(endpoint, {
|
|
477
467
|
version: WIRE_VERSION,
|
|
478
468
|
command: "waiting",
|
|
479
469
|
operationId,
|
|
@@ -568,7 +558,6 @@ export function createScriptedHost(
|
|
|
568
558
|
return { summary: step.value };
|
|
569
559
|
}
|
|
570
560
|
const { ending } = step;
|
|
571
|
-
if (step.deprecated) events.onDeprecated(step.deprecated);
|
|
572
561
|
if (ending.kind === "waiting") return failTest("compaction cannot report waiting");
|
|
573
562
|
if (ending.kind === "hang") {
|
|
574
563
|
return {
|
|
@@ -761,15 +750,13 @@ export function createScriptedHost(
|
|
|
761
750
|
key: request.key,
|
|
762
751
|
execution: request.execution,
|
|
763
752
|
...(request.instructions ? { instructions: request.instructions } : {}),
|
|
764
|
-
...(request.labels ? { labels: request.labels } : {}),
|
|
765
753
|
...(request.skills ? { skills: request.skills.names } : {}),
|
|
766
754
|
...(request.continues && forks.has(request.continues.sessionRef)
|
|
767
755
|
? { forkedFrom: forks.get(request.continues.sessionRef)! }
|
|
768
756
|
: {}),
|
|
769
757
|
...paneOf(request),
|
|
770
|
-
...(request
|
|
771
|
-
...(request.layoutFallback ? {
|
|
772
|
-
...(request.keepPane ? { keepPane: request.keepPane } : {}),
|
|
758
|
+
...panesOf(request),
|
|
759
|
+
...(request.layoutFallback ? { paneFallback: request.layoutFallback } : {}),
|
|
773
760
|
};
|
|
774
761
|
agents.push(opened);
|
|
775
762
|
return {
|
|
@@ -885,6 +872,18 @@ function paneOf({ layout, front }: HarnessActivation): { pane?: PaneSpec } {
|
|
|
885
872
|
return { pane: { ...(tab === undefined ? {} : { tab }), ...(front ? { front } : {}) } };
|
|
886
873
|
}
|
|
887
874
|
|
|
875
|
+
/** The run's `panes` as the host was asked to apply them to this pane. */
|
|
876
|
+
function panesOf({ layout, keepPane }: HarnessActivation): Pick<OpenedAgent, "panes"> {
|
|
877
|
+
const workspace = layout?.beside === undefined ? layout?.workspace : undefined;
|
|
878
|
+
if (workspace === undefined && keepPane === undefined) return {};
|
|
879
|
+
return {
|
|
880
|
+
panes: {
|
|
881
|
+
...(workspace === undefined ? {} : { workspace }),
|
|
882
|
+
...(keepPane === undefined ? {} : { keep: keepPane }),
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
888
887
|
/** What the pane host refuses before it opens a pane. */
|
|
889
888
|
function paneRefusal(activation: HarnessActivation): string | undefined {
|
|
890
889
|
const { harness } = activation.execution;
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// What `agentswf/testing` publishes, which `awf test` serves too: nothing here is the engine's own.
|
|
2
|
-
import type { EventSchemas, JsonValue, Workflow
|
|
2
|
+
import type { EventSchemas, JsonValue, Workflow } from "@agentswf/contract/workflow";
|
|
3
3
|
import { runTest, type TestOptions, type TestRun, testCall } from "./run-test";
|
|
4
4
|
|
|
5
5
|
export type { DecisionRequest, DecisionScript } from "./decisions";
|
|
6
|
+
export {
|
|
7
|
+
type FakeClient,
|
|
8
|
+
type FakeClientCall,
|
|
9
|
+
type FakeClientScript,
|
|
10
|
+
fakeClient,
|
|
11
|
+
} from "./fake-client";
|
|
6
12
|
export type { TestTurn } from "./host";
|
|
7
13
|
export type {
|
|
8
14
|
EarlierAttempt,
|
|
9
15
|
EmittedEvent,
|
|
10
|
-
EventTestOptions,
|
|
11
16
|
FileTree,
|
|
12
17
|
MessageRecord,
|
|
13
18
|
ScriptedEvent,
|
|
@@ -25,7 +30,6 @@ export {
|
|
|
25
30
|
type ParticipantRoute,
|
|
26
31
|
type ParticipantScript,
|
|
27
32
|
type ReceivedMessage,
|
|
28
|
-
reply,
|
|
29
33
|
type Script,
|
|
30
34
|
type SendOptions,
|
|
31
35
|
type TestTurnOutcome,
|
|
@@ -33,9 +37,6 @@ export {
|
|
|
33
37
|
type Turn,
|
|
34
38
|
} from "./script";
|
|
35
39
|
|
|
36
|
-
/** What an array, `null` or a primitive is: args the deprecated form may give second. */
|
|
37
|
-
type NotAnObject = null | string | number | boolean | JsonValue[];
|
|
38
|
-
|
|
39
40
|
/**
|
|
40
41
|
* Runs `workflow` through the real engine, answering each agent from its script and each decision
|
|
41
42
|
* from its own. It rejects when a script cannot meet what the workflow asked, or leaves a list
|
|
@@ -56,28 +57,9 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
|
|
|
56
57
|
? [options?: TestOptions<NoInfer<Args>>]
|
|
57
58
|
: [options: TestOptions<NoInfer<Args>>]
|
|
58
59
|
): Promise<TestRun<Result>>;
|
|
59
|
-
/** @deprecated Give args among the options: `testWorkflow(workflow, { args, … })`. */
|
|
60
|
-
export async function testWorkflow<Args extends JsonValue, Result extends JsonValue>(
|
|
61
|
-
workflow: DeprecatedWorkflow<Args, Result>,
|
|
62
|
-
args: NoInfer<Args>,
|
|
63
|
-
options: Omit<TestOptions<NoInfer<Args>>, "args"> | undefined,
|
|
64
|
-
): Promise<TestRun<Result>>;
|
|
65
|
-
/** @deprecated Give args among the options: `testWorkflow(workflow, { args })`. */
|
|
66
|
-
export async function testWorkflow<Args extends NotAnObject, Result extends JsonValue>(
|
|
67
|
-
workflow: DeprecatedWorkflow<Args, Result>,
|
|
68
|
-
args: Args,
|
|
69
|
-
): Promise<TestRun<Result>>;
|
|
70
60
|
export async function testWorkflow(
|
|
71
|
-
workflow:
|
|
61
|
+
workflow: Parameters<typeof runTest>[0],
|
|
72
62
|
...given: unknown[]
|
|
73
63
|
): Promise<TestRun<JsonValue>> {
|
|
74
|
-
|
|
75
|
-
return runTest(workflow, options, { used });
|
|
64
|
+
return runTest(workflow, testCall(workflow, given));
|
|
76
65
|
}
|
|
77
|
-
|
|
78
|
-
/** What the deprecated form still takes: a workflow, or its `meta` and `run` alone. */
|
|
79
|
-
type DeprecatedWorkflow<Args extends JsonValue, Result extends JsonValue> = {
|
|
80
|
-
readonly meta: WorkflowMeta;
|
|
81
|
-
readonly events?: undefined;
|
|
82
|
-
run: Workflow<Args, Result>["run"];
|
|
83
|
-
};
|