@agentswf/engine 0.1.0-beta.4 → 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 +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- 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 +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- 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 +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- 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 +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- 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 +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- 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 +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- 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 +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- 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 +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- 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 +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { open } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
2
|
import {
|
|
4
|
-
OPERATION_LIVENESS_KINDS,
|
|
5
3
|
OPERATION_LIVENESS_RECORD_VERSION,
|
|
6
4
|
type OperationLivenessRecord,
|
|
7
5
|
} from "@agentswf/contract/records";
|
|
6
|
+
import { validEvent } from "../operation-events";
|
|
7
|
+
import { liveFile } from "../ops";
|
|
8
8
|
|
|
9
|
-
type OperationEvent = Pick<
|
|
10
|
-
OperationLivenessRecord,
|
|
11
|
-
"kind" | "at" | "sequence" | "reason" | "until" | "peer"
|
|
12
|
-
>;
|
|
13
9
|
const MAX_BYTES = 1024 * 1024;
|
|
14
|
-
function eventFile(runDir: string, operationId: string): string {
|
|
15
|
-
return join(runDir, "calls", operationId, "liveness.jsonl");
|
|
16
|
-
}
|
|
17
10
|
|
|
18
11
|
export async function readOperationEvents(
|
|
19
12
|
runDir: string,
|
|
@@ -25,7 +18,7 @@ export async function readOperationEvents(
|
|
|
25
18
|
let text: string;
|
|
26
19
|
let clipped = false;
|
|
27
20
|
try {
|
|
28
|
-
const file = await open(
|
|
21
|
+
const file = await open(liveFile(runDir, operationId), "r");
|
|
29
22
|
try {
|
|
30
23
|
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
31
24
|
let size = 0;
|
|
@@ -63,19 +56,6 @@ export async function readOperationEvents(
|
|
|
63
56
|
return { records, incomplete };
|
|
64
57
|
}
|
|
65
58
|
|
|
66
|
-
function validEvent(value: OperationEvent): boolean {
|
|
67
|
-
return (
|
|
68
|
-
typeof value.kind === "string" &&
|
|
69
|
-
(OPERATION_LIVENESS_KINDS as readonly string[]).includes(value.kind) &&
|
|
70
|
-
Number.isSafeInteger(value.at) &&
|
|
71
|
-
value.at >= 0 &&
|
|
72
|
-
Number.isSafeInteger(value.sequence) &&
|
|
73
|
-
value.sequence >= 0 &&
|
|
74
|
-
(value.reason === undefined || typeof value.reason === "string") &&
|
|
75
|
-
(value.until === undefined || (Number.isSafeInteger(value.until) && value.until >= 0)) &&
|
|
76
|
-
(value.peer === undefined || (typeof value.peer === "string" && value.peer.trim() !== ""))
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
59
|
function isRecord(value: unknown, operationId: string): value is OperationLivenessRecord {
|
|
80
60
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
81
61
|
const item = value as OperationLivenessRecord;
|
package/src/testing.ts
CHANGED
|
@@ -2,23 +2,25 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
|
|
2
2
|
import { createConnection } from "node:net";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import type { AttemptRecord } from "@agentswf/contract/records";
|
|
5
|
+
import type { AttemptRecord, StageLine } from "@agentswf/contract/records";
|
|
6
6
|
import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
|
|
7
7
|
import {
|
|
8
8
|
defineWorkflow,
|
|
9
|
-
type EventWorkflowContext,
|
|
10
9
|
type JsonValue,
|
|
11
10
|
type OutputSchema,
|
|
12
11
|
type WorkflowContext,
|
|
13
|
-
type WorkflowDefinition,
|
|
14
12
|
} from "@agentswf/contract/workflow";
|
|
15
13
|
import { createSingleSessionHostFactory } from "@agentswf/harness";
|
|
16
14
|
import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
|
|
17
15
|
import { createFakeAdapter } from "@agentswf/harness/testing";
|
|
18
16
|
import Type from "typebox";
|
|
17
|
+
import type { DecisionInstallation } from "./decisions/seam";
|
|
19
18
|
import { writeJson } from "./files";
|
|
20
19
|
import { OPERATOR_ALIASES } from "./operator-aliases";
|
|
21
|
-
import {
|
|
20
|
+
import { listOps } from "./ops";
|
|
21
|
+
import { claimRunLines, readRunLines } from "./run-lines";
|
|
22
|
+
import { createRun, openRun, type Run, readAttemptFiles } from "./runs";
|
|
23
|
+
import type { RunnableWorkflow } from "./workflow-runner";
|
|
22
24
|
import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
|
|
23
25
|
|
|
24
26
|
export function createTempRunDirs(): {
|
|
@@ -62,13 +64,28 @@ export async function until(check: () => Promise<boolean>, ms = 20_000): Promise
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Attempt `attempt`'s writer on the run's `run.jsonl`, claimed now, and its listing of `ops/`, as
|
|
69
|
+
* `awf run` claims them.
|
|
70
|
+
*/
|
|
71
|
+
export async function claimLines(runDir: string, attempt: number) {
|
|
72
|
+
const ops = await listOps(runDir);
|
|
73
|
+
return { lines: await claimRunLines(runDir, attempt, new Date().toISOString()), ops };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Each stage's latest line in the run's `run.jsonl`, by name, in the order first written. */
|
|
77
|
+
export async function lastLines(runDir: string): Promise<Map<string, StageLine>> {
|
|
78
|
+
const { stages } = (await readRunLines(runDir)).view;
|
|
79
|
+
return new Map(Object.entries(stages).map(([name, { last }]) => [name, last]));
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
/** Writes the run's next attempt record, as `awf run` claims one, with `fields` over defaults. */
|
|
66
83
|
export async function writeAttempt(run: Run, fields: Partial<AttemptRecord>): Promise<void> {
|
|
67
|
-
const n = (await
|
|
84
|
+
const n = (await readAttemptFiles(run)).length + 1;
|
|
68
85
|
mkdirSync(join(run.dir, "attempts"), { recursive: true });
|
|
69
86
|
await writeJson(join(run.dir, "attempts", `${n}.json`), {
|
|
70
|
-
version: 1,
|
|
71
87
|
attempt: n,
|
|
88
|
+
kind: "attempt",
|
|
72
89
|
file: "/flows/case.ts",
|
|
73
90
|
flags: {},
|
|
74
91
|
started: new Date().toISOString(),
|
|
@@ -81,11 +98,11 @@ const Changes: OutputSchema<{ case: string }> = {
|
|
|
81
98
|
properties: { case: { type: "string" } },
|
|
82
99
|
required: ["case"],
|
|
83
100
|
};
|
|
84
|
-
const caseEvents = { "dd.changes": Changes };
|
|
101
|
+
export const caseEvents = { "dd.changes": Changes };
|
|
85
102
|
|
|
86
103
|
/** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
|
|
87
104
|
export const caseRun = (
|
|
88
|
-
run: (workflow:
|
|
105
|
+
run: (workflow: WorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
|
|
89
106
|
) =>
|
|
90
107
|
defineWorkflow({
|
|
91
108
|
meta: { name: "dd-case", description: "A case." },
|
|
@@ -140,7 +157,7 @@ export const DOC = Type.Object({ path: Type.String() }, { additionalProperties:
|
|
|
140
157
|
export function workflowOf<Result extends JsonValue>(
|
|
141
158
|
run: (workflow: WorkflowContext) => Promise<Result>,
|
|
142
159
|
meta: { name?: string; version?: string } = {},
|
|
143
|
-
):
|
|
160
|
+
): RunnableWorkflow<null, Result> {
|
|
144
161
|
const { name = "staged", version } = meta;
|
|
145
162
|
return {
|
|
146
163
|
meta: { name, description: name, ...(version === undefined ? {} : { version }) },
|
|
@@ -165,20 +182,23 @@ export function answering(): AgentSessionAdapter {
|
|
|
165
182
|
|
|
166
183
|
/**
|
|
167
184
|
* One attempt of run r1 of `workflow` under `runRoot`, created by the first, its agents answered by
|
|
168
|
-
* `answering()`. Its folder is `{runRoot}/{workflow}/r1`.
|
|
185
|
+
* `answering()`. Its folder is `{runRoot}/{workflow}/r1`. It claims `run.jsonl` as a claim does, and
|
|
186
|
+
* writes its attempt's file once it ends, as a later attempt reads its version there.
|
|
169
187
|
*/
|
|
170
188
|
export async function runAttempt<Result extends JsonValue>(
|
|
171
|
-
workflow:
|
|
189
|
+
workflow: RunnableWorkflow<null, Result>,
|
|
172
190
|
options: {
|
|
173
191
|
runRoot: string;
|
|
174
192
|
attempt?: number;
|
|
175
193
|
fromStage?: string;
|
|
176
194
|
values?: Record<string, JsonValue>;
|
|
177
195
|
adapter?: AgentSessionAdapter;
|
|
196
|
+
decisions?: DecisionInstallation;
|
|
178
197
|
signal?: AbortSignal;
|
|
179
198
|
},
|
|
180
199
|
) {
|
|
181
200
|
const { runRoot, attempt = 1, fromStage, values, adapter = answering(), signal } = options;
|
|
201
|
+
const { decisions } = options;
|
|
182
202
|
const name = workflow.meta.name;
|
|
183
203
|
const run = existsSync(join(runRoot, name, "r1"))
|
|
184
204
|
? await openRun(runRoot, name, "r1")
|
|
@@ -189,24 +209,51 @@ export async function runAttempt<Result extends JsonValue>(
|
|
|
189
209
|
cwd: process.cwd(),
|
|
190
210
|
sandbox: null,
|
|
191
211
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
const started = new Date().toISOString();
|
|
213
|
+
const ops = await listOps(run.dir);
|
|
214
|
+
const lines = await claimRunLines(run.dir, attempt, started);
|
|
215
|
+
const ended = (outcome: AttemptRecord["outcome"]) =>
|
|
216
|
+
writeJson(join(run.dir, "attempts", `${attempt}.json`), {
|
|
197
217
|
attempt,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
kind: "attempt",
|
|
219
|
+
file: name,
|
|
220
|
+
...(workflow.meta.version === undefined ? {} : { workflowVersion: workflow.meta.version }),
|
|
221
|
+
flags: fromStage === undefined ? {} : { fromStage },
|
|
222
|
+
pid: process.pid,
|
|
223
|
+
processStart: started,
|
|
224
|
+
started,
|
|
225
|
+
ended: new Date().toISOString(),
|
|
226
|
+
outcome,
|
|
227
|
+
} satisfies AttemptRecord);
|
|
228
|
+
mkdirSync(join(run.dir, "attempts"), { recursive: true });
|
|
229
|
+
try {
|
|
230
|
+
const result = await runWorkflow(workflow, null, {
|
|
231
|
+
runRoot,
|
|
232
|
+
run: {
|
|
233
|
+
dir: run.dir,
|
|
234
|
+
id: run.record.id,
|
|
235
|
+
|
|
236
|
+
lines,
|
|
237
|
+
ops,
|
|
238
|
+
...(fromStage === undefined ? {} : { fromStage }),
|
|
239
|
+
...(values === undefined ? {} : { values: new Map(Object.entries(values)) }),
|
|
240
|
+
},
|
|
241
|
+
runtime: { aliases: OPERATOR_ALIASES, host: createSingleSessionHostFactory(adapter) },
|
|
242
|
+
deadline: future(),
|
|
243
|
+
...(decisions === undefined ? {} : { decisions }),
|
|
244
|
+
...(signal === undefined ? {} : { signal }),
|
|
245
|
+
});
|
|
246
|
+
await ended("completed");
|
|
247
|
+
return result;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
await ended("failed");
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
205
252
|
}
|
|
206
253
|
|
|
207
254
|
/** `startWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
|
|
208
255
|
export async function startNew<Args extends JsonValue, Result extends JsonValue>(
|
|
209
|
-
definition:
|
|
256
|
+
definition: RunnableWorkflow<Args, Result>,
|
|
210
257
|
args: Args,
|
|
211
258
|
options: Omit<RunWorkflowOptions, "run">,
|
|
212
259
|
) {
|
|
@@ -218,13 +265,13 @@ export async function startNew<Args extends JsonValue, Result extends JsonValue>
|
|
|
218
265
|
});
|
|
219
266
|
return startWorkflow(definition, args, {
|
|
220
267
|
...options,
|
|
221
|
-
run: { dir: run.dir, id: run.record.id,
|
|
268
|
+
run: { dir: run.dir, id: run.record.id, ...(await claimLines(run.dir, 1)) },
|
|
222
269
|
});
|
|
223
270
|
}
|
|
224
271
|
|
|
225
272
|
/** `runWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
|
|
226
273
|
export async function runNew<Args extends JsonValue, Result extends JsonValue>(
|
|
227
|
-
definition:
|
|
274
|
+
definition: RunnableWorkflow<Args, Result>,
|
|
228
275
|
args: Args,
|
|
229
276
|
options: Omit<RunWorkflowOptions, "run">,
|
|
230
277
|
) {
|
package/src/tick-command.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
1
3
|
import type { TickResultJson } from "@agentswf/contract/records";
|
|
4
|
+
import { type Out, parsePlace } from "./command-line";
|
|
2
5
|
import { messageOf } from "./errors";
|
|
3
6
|
import { exists } from "./files";
|
|
7
|
+
import { ENGINE_VARIABLE, type InstalledEngine, type ProjectEngine } from "./hand-over";
|
|
4
8
|
import help from "./help/tick.txt" with { type: "text" };
|
|
9
|
+
import { ENGINE_VERSION, EngineRefused, engineOf } from "./project-engine";
|
|
5
10
|
import { listProjects, projectRunRoot } from "./projects";
|
|
6
|
-
import
|
|
7
|
-
import { parsePlace } from "./run-commands";
|
|
11
|
+
import { asksJson, printRefusal } from "./refusals";
|
|
8
12
|
import { runLockFile, withRunLock } from "./run-lock";
|
|
9
|
-
import { listRuns } from "./run-state";
|
|
13
|
+
import { listRunEntries, listRuns } from "./run-state";
|
|
14
|
+
import { RunUnreadable, readAttempts } from "./runs";
|
|
10
15
|
import { type Launch, wake } from "./wake";
|
|
11
16
|
|
|
17
|
+
/** `awf tick --json`'s `"v"`, which its refusals carry too. */
|
|
18
|
+
export const TICK_V = 1 satisfies TickResultJson["v"];
|
|
19
|
+
|
|
12
20
|
export const tickUsage = help.trimEnd();
|
|
13
21
|
|
|
14
22
|
export async function tickCommand(
|
|
@@ -22,10 +30,6 @@ export async function tickCommand(
|
|
|
22
30
|
now?: () => number;
|
|
23
31
|
},
|
|
24
32
|
): Promise<number> {
|
|
25
|
-
if (argv.includes("-h") || argv.includes("--help")) {
|
|
26
|
-
out.stdout(tickUsage);
|
|
27
|
-
return 0;
|
|
28
|
-
}
|
|
29
33
|
let parsed: ReturnType<typeof parsePlace>;
|
|
30
34
|
try {
|
|
31
35
|
parsed = parsePlace(argv, shellCwd);
|
|
@@ -34,24 +38,72 @@ export async function tickCommand(
|
|
|
34
38
|
if (parsed.rest.includes("--all") && parsed.place.repeat.length > 0)
|
|
35
39
|
throw new Error("--all wakes every project's runs; it takes no --cwd or --run-root");
|
|
36
40
|
} catch (error) {
|
|
37
|
-
out
|
|
41
|
+
printRefusal(out, { json: asksJson(argv), v: TICK_V }, error, "usage", tickUsage);
|
|
38
42
|
return 2;
|
|
39
43
|
}
|
|
40
44
|
const { place, json } = parsed;
|
|
41
45
|
const woke: TickResultJson["woke"] = [];
|
|
46
|
+
const unreadable: string[] = [];
|
|
42
47
|
let failed = false;
|
|
43
48
|
const roots: string[] = [];
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
/** Each project's runs on its own engine (ADR 0015): this one, or its own in a child. */
|
|
50
|
+
const onItsEngine = async (project: string, root: string) => {
|
|
51
|
+
let engine: ProjectEngine;
|
|
52
|
+
try {
|
|
53
|
+
engine = engineOf(project, await runFiles(root));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!(error instanceof EngineRefused)) throw error;
|
|
56
|
+
failed = true;
|
|
57
|
+
out.stderr(`awf: ${error.message}; its runs are not woken`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (engine.kind === "declared") {
|
|
61
|
+
failed = true;
|
|
62
|
+
out.stderr(
|
|
63
|
+
`awf: ${project} declares agentswf in ${engine.manifest} and has none installed, so its runs are not woken: run bun install there`,
|
|
64
|
+
);
|
|
65
|
+
} else if (engine.kind === "installed" && engine.version !== ENGINE_VERSION) {
|
|
66
|
+
// This is the engine the project's agentswf runs, and they disagree: another child would find
|
|
67
|
+
// the same, without end.
|
|
68
|
+
if (isOwnEngine(engine)) {
|
|
69
|
+
failed = true;
|
|
70
|
+
out.stderr(
|
|
71
|
+
`awf: ${project}'s agentswf is ${engine.version} and its engine ${ENGINE_VERSION}, so its runs are not woken: run bun install there`,
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const ticked = await tickElsewhere(project, root, engine, context.environment, out);
|
|
76
|
+
woke.push(...ticked.woke);
|
|
77
|
+
if (!ticked.ok) failed = true;
|
|
78
|
+
} else roots.push(root);
|
|
79
|
+
};
|
|
80
|
+
if (!parsed.rest.includes("--all")) {
|
|
81
|
+
// A project's own run root is its engine's to tick; any other, the operator's, this one's.
|
|
82
|
+
const root = resolve(place.runRoot);
|
|
83
|
+
if (basename(root) === "runs" && basename(dirname(root)) === ".awf")
|
|
84
|
+
await onItsEngine(dirname(dirname(root)), root);
|
|
85
|
+
else roots.push(root);
|
|
86
|
+
} else
|
|
46
87
|
for (const project of await listProjects(context.home)) {
|
|
47
|
-
if (await exists(project))
|
|
48
|
-
else {
|
|
88
|
+
if (!(await exists(project))) {
|
|
49
89
|
failed = true;
|
|
50
90
|
out.stderr(`awf: ${project}, in ~/.awf/projects, is gone; its runs are not woken`);
|
|
91
|
+
continue;
|
|
51
92
|
}
|
|
93
|
+
// A project a plain run registered (ADR 0016) has nothing for a tick to wake; one on another
|
|
94
|
+
// engine is that engine's to judge, whose records this one may not read.
|
|
95
|
+
const root = projectRunRoot(project);
|
|
96
|
+
if (!(await listRuns(root)).some((run) => run.record.events) && onThisEngine(project))
|
|
97
|
+
continue;
|
|
98
|
+
await onItsEngine(project, root);
|
|
52
99
|
}
|
|
53
100
|
for (const root of roots) {
|
|
54
|
-
for (const run of await
|
|
101
|
+
for (const run of await listRunEntries(root)) {
|
|
102
|
+
if (run instanceof RunUnreadable) {
|
|
103
|
+
// One with events is one a tick would have woken: it is named, not an error.
|
|
104
|
+
if (run.events) unreadable.push(run.ref);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
55
107
|
if (!run.record.events) continue;
|
|
56
108
|
const ref = `${run.record.workflow}/${run.record.id}`;
|
|
57
109
|
try {
|
|
@@ -70,7 +122,85 @@ export async function tickCommand(
|
|
|
70
122
|
}
|
|
71
123
|
}
|
|
72
124
|
}
|
|
73
|
-
if (
|
|
74
|
-
|
|
125
|
+
if (unreadable.length > 0 && !json) {
|
|
126
|
+
const runs = unreadable.length === 1 ? "1 run" : `${unreadable.length} runs`;
|
|
127
|
+
out.stderr(
|
|
128
|
+
`awf: ${runs} with events can't be read and ${unreadable.length === 1 ? "is" : "are"} not woken; awf runs says why`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (json) {
|
|
132
|
+
const result: TickResultJson = {
|
|
133
|
+
v: TICK_V,
|
|
134
|
+
woke,
|
|
135
|
+
...(unreadable.length ? { unreadable } : {}),
|
|
136
|
+
};
|
|
137
|
+
out.stdout(JSON.stringify(result));
|
|
138
|
+
} else for (const each of woke) out.stdout(`${each.did} ${each.ref}: ${each.why}`);
|
|
75
139
|
return failed ? 1 : 0;
|
|
76
140
|
}
|
|
141
|
+
|
|
142
|
+
/** The files the event runs under `root` last ran, by which a project with no engine finds one. */
|
|
143
|
+
async function runFiles(root: string): Promise<string[]> {
|
|
144
|
+
const files: string[] = [];
|
|
145
|
+
for (const run of await listRuns(root)) {
|
|
146
|
+
if (!run.record.events) continue;
|
|
147
|
+
const file = (await readAttempts(run).catch(() => [])).at(-1)?.file ?? run.record.file;
|
|
148
|
+
if (file !== undefined) files.push(file);
|
|
149
|
+
}
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `awf tick` of one project's runs on its own engine, as the hand-over runs it: in the project,
|
|
155
|
+
* with `AWF_ENGINE` naming it, so it never hands over by its directory.
|
|
156
|
+
*/
|
|
157
|
+
async function tickElsewhere(
|
|
158
|
+
project: string,
|
|
159
|
+
root: string,
|
|
160
|
+
engine: InstalledEngine,
|
|
161
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
162
|
+
out: Out,
|
|
163
|
+
): Promise<{ ok: boolean; woke: TickResultJson["woke"] }> {
|
|
164
|
+
const child = Bun.spawn({
|
|
165
|
+
cmd: [process.execPath, "--no-env-file", engine.bin, "tick", "--run-root", root, "--json"],
|
|
166
|
+
cwd: project,
|
|
167
|
+
env: { ...environment, [ENGINE_VARIABLE]: engine.dir },
|
|
168
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
169
|
+
});
|
|
170
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
171
|
+
new Response(child.stdout).text(),
|
|
172
|
+
new Response(child.stderr).text(),
|
|
173
|
+
child.exited,
|
|
174
|
+
]);
|
|
175
|
+
for (const line of stderr.trimEnd().split("\n")) if (line) out.stderr(line);
|
|
176
|
+
try {
|
|
177
|
+
const result = JSON.parse(stdout) as Partial<TickResultJson>;
|
|
178
|
+
if (!Array.isArray(result.woke)) throw new Error("no woke");
|
|
179
|
+
return { ok: code === 0, woke: result.woke };
|
|
180
|
+
} catch {
|
|
181
|
+
out.stderr(
|
|
182
|
+
`awf: ${project}'s agentswf ${engine.version} did not say what it woke (exit ${code})`,
|
|
183
|
+
);
|
|
184
|
+
return { ok: false, woke: [] };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether `engine`'s agentswf runs on this very engine package. */
|
|
189
|
+
function isOwnEngine(engine: InstalledEngine): boolean {
|
|
190
|
+
try {
|
|
191
|
+
const theirs = Bun.resolveSync("@agentswf/engine/package.json", engine.dir);
|
|
192
|
+
return realpathSync(theirs) === realpathSync(join(import.meta.dir, "..", "package.json"));
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Whether a project's runs would run on this engine, or none is installed there to run them. */
|
|
199
|
+
function onThisEngine(project: string): boolean {
|
|
200
|
+
try {
|
|
201
|
+
const engine = engineOf(project);
|
|
202
|
+
return engine.kind !== "installed" || engine.version === ENGINE_VERSION;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type AgentTools,
|
|
8
8
|
isJsonValue,
|
|
9
9
|
type JsonValue,
|
|
10
|
+
positiveDurationMilliseconds,
|
|
10
11
|
TOOL_LIMITS,
|
|
11
12
|
type ToolSpec,
|
|
12
13
|
} from "@agentswf/contract/workflow";
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
} from "./simple-input";
|
|
25
26
|
|
|
26
27
|
/** How long a handler that ignores its aborted signal is waited for before its call is settled. */
|
|
27
|
-
|
|
28
|
+
const TOOL_SETTLE_MS = 5_000;
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The agents whose handlers the current code runs in, innermost last: one of them can't be given
|
|
@@ -76,7 +77,14 @@ export type ToolAnswer =
|
|
|
76
77
|
| { kind: "tool-failed"; outcome: ToolFailure; error: string }
|
|
77
78
|
| { kind: "refused"; code: ToolCode | "internal-error"; error: string };
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
/** A call's budget: milliseconds from its start, or none of its own. */
|
|
81
|
+
type Budget = number | false;
|
|
82
|
+
|
|
83
|
+
type Held = {
|
|
84
|
+
specs: AgentTools;
|
|
85
|
+
schemas: Map<string, JsonSchema | undefined>;
|
|
86
|
+
budgets: Map<string, Budget>;
|
|
87
|
+
};
|
|
80
88
|
|
|
81
89
|
type Running = {
|
|
82
90
|
tool: string;
|
|
@@ -289,12 +297,11 @@ export class ToolCalls<Scope> {
|
|
|
289
297
|
const controller = new AbortController();
|
|
290
298
|
let cause: ToolFailure | undefined;
|
|
291
299
|
let handlerEnded = false;
|
|
292
|
-
const
|
|
300
|
+
const budget = tools.budgets.get(request.tool)!;
|
|
293
301
|
const deadline = operation.deadline();
|
|
294
302
|
const startedAt = this.#now();
|
|
295
|
-
const budgetEnd = minimum(
|
|
296
|
-
const bound =
|
|
297
|
-
deadline !== undefined && budgetEnd === deadline ? ("deadline" as const) : timeoutMs;
|
|
303
|
+
const budgetEnd = minimum(budget === false ? undefined : startedAt + budget, deadline);
|
|
304
|
+
const bound = deadline !== undefined && budgetEnd === deadline ? ("deadline" as const) : budget;
|
|
298
305
|
const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
|
|
299
306
|
const { promise: answered, resolve: answer } = Promise.withResolvers<ToolAnswer>();
|
|
300
307
|
const running: Running = {
|
|
@@ -479,7 +486,7 @@ function aborted(
|
|
|
479
486
|
tool: string,
|
|
480
487
|
cause: ToolFailure,
|
|
481
488
|
/** What bounded it: its own budget, or the turn's deadline. */
|
|
482
|
-
bound:
|
|
489
|
+
bound: Budget | "deadline",
|
|
483
490
|
): { kind: "tool-failed"; outcome: ToolFailure; error: string } {
|
|
484
491
|
const what =
|
|
485
492
|
cause === "cancelled"
|
|
@@ -573,6 +580,7 @@ function held(agent: AgentKey, tools: AgentTools): Held {
|
|
|
573
580
|
throw new Error(`agent ${agent} has ${names.length} tools; at most ${TOOL_LIMITS.tools}`);
|
|
574
581
|
}
|
|
575
582
|
const schemas = new Map<string, JsonSchema | undefined>();
|
|
583
|
+
const budgets = new Map<string, Budget>();
|
|
576
584
|
for (const name of names) {
|
|
577
585
|
const spec = tools[name]!;
|
|
578
586
|
const where = `agent ${agent}'s tool ${JSON.stringify(name)}`;
|
|
@@ -586,14 +594,7 @@ function held(agent: AgentKey, tools: AgentTools): Held {
|
|
|
586
594
|
if (Buffer.byteLength(spec.description) > TOOL_LIMITS.descriptionBytes) {
|
|
587
595
|
throw new Error(`${where}: its description is over ${TOOL_LIMITS.descriptionBytes} bytes`);
|
|
588
596
|
}
|
|
589
|
-
|
|
590
|
-
if (
|
|
591
|
-
timeout !== undefined &&
|
|
592
|
-
timeout !== false &&
|
|
593
|
-
!(Number.isSafeInteger(timeout) && timeout > 0)
|
|
594
|
-
) {
|
|
595
|
-
throw new Error(`${where}: timeoutMs is a positive integer, or false for none`);
|
|
596
|
-
}
|
|
597
|
+
budgets.set(name, budgetOf(where, spec));
|
|
597
598
|
if (spec.input === undefined) {
|
|
598
599
|
schemas.set(name, undefined);
|
|
599
600
|
continue;
|
|
@@ -619,7 +620,15 @@ function held(agent: AgentKey, tools: AgentTools): Held {
|
|
|
619
620
|
// Its JSON, in the author's order for the lesson, without TypeBox's own keys.
|
|
620
621
|
schemas.set(name, JSON.parse(JSON.stringify(input)) as JsonSchema);
|
|
621
622
|
}
|
|
622
|
-
return { specs: { ...tools }, schemas };
|
|
623
|
+
return { specs: { ...tools }, schemas, budgets };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** A tool's `timeout`, checked; absent, the default. */
|
|
627
|
+
function budgetOf(where: string, spec: ToolSpec): Budget {
|
|
628
|
+
const { timeout } = spec;
|
|
629
|
+
if (timeout == null) return TOOL_LIMITS.defaultTimeoutMs;
|
|
630
|
+
if (timeout === false) return false;
|
|
631
|
+
return positiveDurationMilliseconds(timeout, `${where}: its timeout`);
|
|
623
632
|
}
|
|
624
633
|
|
|
625
634
|
function sameTools(a: Held, b: Held): boolean {
|
package/src/turn-ends.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RunLine } from "@agentswf/contract/records";
|
|
2
|
+
import type { AgentExecution, OperationRecord } from "@agentswf/contract/workflow";
|
|
3
|
+
import { endIdOf } from "./event-ids";
|
|
4
|
+
import type { TurnStartedLine } from "./run-policy/waits";
|
|
5
|
+
|
|
6
|
+
/** The usage of a turn no delivery settled: one refused, cancelled while queued, or cut off by a crash. */
|
|
7
|
+
export function unrunUsage(
|
|
8
|
+
agent: string,
|
|
9
|
+
operationId: string,
|
|
10
|
+
execution: AgentExecution,
|
|
11
|
+
): OperationRecord {
|
|
12
|
+
return { callPath: [], agent, operationId, execution, sessions: [] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The failed `turn-ended` attempt `attempt`'s claim writes for `turn`, which its attempt left running. */
|
|
16
|
+
export function crashedEnd(turn: TurnStartedLine, attempt: number, at: string): RunLine {
|
|
17
|
+
return {
|
|
18
|
+
kind: "turn-ended",
|
|
19
|
+
attempt,
|
|
20
|
+
at,
|
|
21
|
+
id: endIdOf(turn.agent, turn.turn, turn.attempt),
|
|
22
|
+
agent: turn.agent,
|
|
23
|
+
turn: turn.turn,
|
|
24
|
+
began: turn.attempt,
|
|
25
|
+
end: {
|
|
26
|
+
turn: turn.turn,
|
|
27
|
+
meta: turn.meta,
|
|
28
|
+
outcome: {
|
|
29
|
+
answered: false,
|
|
30
|
+
kind: "failed",
|
|
31
|
+
reason: `attempt ${turn.attempt} ended before this turn did; its work, if any, is in the agent's session`,
|
|
32
|
+
retryable: true,
|
|
33
|
+
usage: unrunUsage(turn.agent, turn.turn, turn.execution),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|