@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
package/src/operator-runtime.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
readClaudeBilling,
|
|
24
24
|
readCodexBilling,
|
|
25
25
|
readCursorLogin,
|
|
26
|
+
runAttached,
|
|
26
27
|
runProcess,
|
|
27
28
|
withholding,
|
|
28
29
|
} from "@agentswf/harness";
|
|
@@ -37,7 +38,7 @@ import { createSrtProvider, findSrt } from "@agentswf/sandbox/srt";
|
|
|
37
38
|
import { createOpenRouterProvider } from "./decisions/openrouter";
|
|
38
39
|
import type { DecisionInstallation } from "./decisions/seam";
|
|
39
40
|
import { messageOf } from "./errors";
|
|
40
|
-
import { findOrigin,
|
|
41
|
+
import { findOrigin, workspaceLock } from "./herdr-placement";
|
|
41
42
|
import {
|
|
42
43
|
ensureRunSession,
|
|
43
44
|
parseSessions,
|
|
@@ -48,6 +49,7 @@ import { markWorkspace } from "./herdr-workspace-marks";
|
|
|
48
49
|
|
|
49
50
|
export type { RunSession } from "./herdr-run-session";
|
|
50
51
|
|
|
52
|
+
import type { Attach } from "./open-command";
|
|
51
53
|
import { OPERATOR_ALIASES } from "./operator-aliases";
|
|
52
54
|
|
|
53
55
|
export type OperatorRuntimeInstallation = {
|
|
@@ -75,12 +77,12 @@ export type OperatorRuntimeOptions = {
|
|
|
75
77
|
shellCwd?: string;
|
|
76
78
|
/** The operator's home. */
|
|
77
79
|
home?: string;
|
|
78
|
-
/** Told once, when the first
|
|
80
|
+
/** Told once, when the first TUI agent's session is ready. */
|
|
79
81
|
onRunSession?: (session: RunSession) => void;
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
|
-
* One run host for every agent: a Herdr tab for a
|
|
85
|
+
* One run host for every agent: a Herdr tab for a TUI agent, a subprocess per turn for a
|
|
84
86
|
* headless one. `WITHHELD_ENVIRONMENT` is cleared from both.
|
|
85
87
|
*/
|
|
86
88
|
export async function installOperatorRuntime(
|
|
@@ -98,7 +100,7 @@ export async function installOperatorRuntime(
|
|
|
98
100
|
} = options;
|
|
99
101
|
const unmetered = withholding(run, WITHHELD_ENVIRONMENT);
|
|
100
102
|
refuseMeteredCredentials(environment);
|
|
101
|
-
// Checked now, before any stage spends anything; made ready only for the first
|
|
103
|
+
// Checked now, before any stage spends anything; made ready only for the first TUI agent.
|
|
102
104
|
const name = runSessionName(environment);
|
|
103
105
|
const session = runSessionOnce(() => name, { run, environment, home }, onRunSession);
|
|
104
106
|
const runConfig = (session: string): HerdrConfig => ({
|
|
@@ -119,9 +121,9 @@ export async function installOperatorRuntime(
|
|
|
119
121
|
const host = loginChecked(
|
|
120
122
|
unmetered,
|
|
121
123
|
createPlacementHostFactory({
|
|
122
|
-
// The session is made ready when the first
|
|
124
|
+
// The session is made ready when the first TUI agent opens, so an all-headless run never
|
|
123
125
|
// calls Herdr.
|
|
124
|
-
|
|
126
|
+
tui: {
|
|
125
127
|
accounting: createSessionAccounting(unmetered),
|
|
126
128
|
openRun: async (spec) => {
|
|
127
129
|
const { name } = await session();
|
|
@@ -129,7 +131,6 @@ export async function installOperatorRuntime(
|
|
|
129
131
|
const config: HerdrConfig = {
|
|
130
132
|
...runConfig(name),
|
|
131
133
|
onRunWorkspace: mark.bind,
|
|
132
|
-
sessionFor: (named) => namedSession(named, { run, environment, home }),
|
|
133
134
|
origin: () =>
|
|
134
135
|
findOrigin({
|
|
135
136
|
run,
|
|
@@ -424,6 +425,27 @@ const WITHHELD_ENVIRONMENT = [
|
|
|
424
425
|
"AWF_CALLER_SESSIONS",
|
|
425
426
|
];
|
|
426
427
|
|
|
428
|
+
/**
|
|
429
|
+
* How `awf open` runs a harness on an agent's session: its fork and `-p` turn, and its TUI on this
|
|
430
|
+
* terminal, each withholding what no agent inherits (story 042).
|
|
431
|
+
*/
|
|
432
|
+
export function openRuntime(environment: Readonly<Record<string, string | undefined>>): {
|
|
433
|
+
run: RunProcess;
|
|
434
|
+
attach: Attach;
|
|
435
|
+
providers: () => Promise<SandboxProviders>;
|
|
436
|
+
} {
|
|
437
|
+
const withheld = Object.fromEntries(WITHHELD_ENVIRONMENT.map((name) => [name, undefined]));
|
|
438
|
+
return {
|
|
439
|
+
run: withholding(runProcess, WITHHELD_ENVIRONMENT),
|
|
440
|
+
// A sandbox's command has exactly its own environment; on the host, the operator's less these.
|
|
441
|
+
attach: (input) =>
|
|
442
|
+
input.exact
|
|
443
|
+
? runAttached(input, {})
|
|
444
|
+
: runAttached({ ...input, env: { ...input.env, ...withheld } }, environment),
|
|
445
|
+
providers: () => installSandboxes(environment),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
427
449
|
/**
|
|
428
450
|
* Unset in a run's panes besides: the config the run session's server starts with, which a `herdr`
|
|
429
451
|
* started in a pane would otherwise read in place of the operator's.
|
package/src/ops.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type Candidate,
|
|
6
|
+
isKnownOpFile,
|
|
7
|
+
OP_FILE_VERSIONS,
|
|
8
|
+
type OpFile,
|
|
9
|
+
type OpRecord,
|
|
10
|
+
type ResultRecord,
|
|
11
|
+
} from "@agentswf/contract/records";
|
|
12
|
+
import { nameProblem } from "@agentswf/contract/workflow";
|
|
13
|
+
import { messageOf } from "./errors";
|
|
14
|
+
import { isCode, linkNew, writeWholeSync } from "./files";
|
|
15
|
+
import { appendLine, readLines } from "./jsonl";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A run's `ops/`: one folder per operation, `ops/{key}.{n}/`, the `n`th turn, message operation or
|
|
19
|
+
* compaction of the agent keyed `{key}`, or the `n`th asking of the decision keyed `{key}`. It holds
|
|
20
|
+
* `op.json`, `answers.jsonl`, `result.json`, `decision.json`, `spend.json`, `live.jsonl` and
|
|
21
|
+
* `screen.txt`. The only module that knows where they are.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** An operation's folder. */
|
|
25
|
+
function opDir(runDir: string, op: string): string {
|
|
26
|
+
return join(runDir, "ops", op);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the operation is: `OpRecord`. */
|
|
30
|
+
export function opFile(runDir: string, op: string): string {
|
|
31
|
+
return join(opDir(runDir, op), "op.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Its settled usage: billing, spend per model, reported cost. */
|
|
35
|
+
export function spendFile(runDir: string, op: string): string {
|
|
36
|
+
return join(opDir(runDir, op), "spend.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Its delivery and nudge diagnostics, `OperationLivenessRecord` lines. */
|
|
40
|
+
export function liveFile(runDir: string, op: string): string {
|
|
41
|
+
return join(opDir(runDir, op), "live.jsonl");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A decision's `DecisionArtifact`, relative to the run's folder, as `DecisionRecord.artifact` names it. */
|
|
45
|
+
export function decisionArtifact(op: string): string {
|
|
46
|
+
return join("ops", op, "decision.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The keys earlier attempts numbered operations under: each one's last `n`, and whose it is when its
|
|
51
|
+
* `op.json` says.
|
|
52
|
+
*/
|
|
53
|
+
export type EarlierOps = ReadonlyMap<string, { last: number; of?: "agent" | "decision" }>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lists `ops/` once, as an attempt claims its run: `n` is the digits after a folder's last `.`.
|
|
57
|
+
* Whose a key is is read from its highest-numbered `op.json` that can be read.
|
|
58
|
+
*/
|
|
59
|
+
export async function listOps(runDir: string): Promise<EarlierOps> {
|
|
60
|
+
let names: string[];
|
|
61
|
+
try {
|
|
62
|
+
names = await readdir(join(runDir, "ops"));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (isCode(error, "ENOENT")) return new Map();
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const numbers = new Map<string, number[]>();
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const match = /^(.+)\.(\d+)$/.exec(name);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
const list = numbers.get(match[1]!) ?? [];
|
|
72
|
+
list.push(Number(match[2]));
|
|
73
|
+
numbers.set(match[1]!, list);
|
|
74
|
+
}
|
|
75
|
+
const earlier = new Map<string, { last: number; of?: "agent" | "decision" }>();
|
|
76
|
+
await Promise.all(
|
|
77
|
+
[...numbers].map(async ([key, list]) => {
|
|
78
|
+
const descending = list.sort((a, b) => b - a);
|
|
79
|
+
let kind: OpRecord["kind"] | undefined;
|
|
80
|
+
for (const n of descending) {
|
|
81
|
+
kind = (await readOpFile<OpRecord>(opFile(runDir, `${key}.${n}`), "op"))?.kind;
|
|
82
|
+
if (kind !== undefined) break;
|
|
83
|
+
}
|
|
84
|
+
earlier.set(key, {
|
|
85
|
+
last: descending[0]!,
|
|
86
|
+
...(kind === undefined ? {} : { of: kind === "decision" ? "decision" : "agent" }),
|
|
87
|
+
});
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
return earlier;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Why `key` can't be an agent's or a decision's: it names folders, so it is a name, as an event's
|
|
95
|
+
* or stage's is, and has no colon, which awf's own event names, `{key}:ended`, split at.
|
|
96
|
+
*/
|
|
97
|
+
function keyProblem(key: string): string | undefined {
|
|
98
|
+
if (key.includes(":"))
|
|
99
|
+
return `a colon is kept for awf's own event names, such as {key}:ended; take another key, such as ${key.replaceAll(":", "-")}`;
|
|
100
|
+
return nameProblem(key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* An attempt's operation ids, `{key}.{n}`, numbered on from the last `n` its claim listed, so no
|
|
105
|
+
* id is reused across attempts. Agents and decisions share the keys: `{key}.{n}` is one operation.
|
|
106
|
+
*/
|
|
107
|
+
export class OpIds {
|
|
108
|
+
readonly #last = new Map<string, number>();
|
|
109
|
+
readonly #agents = new Set<string>();
|
|
110
|
+
readonly #decisions = new Set<string>();
|
|
111
|
+
|
|
112
|
+
constructor(
|
|
113
|
+
readonly runDir: string,
|
|
114
|
+
readonly attempt: number,
|
|
115
|
+
earlier: EarlierOps,
|
|
116
|
+
) {
|
|
117
|
+
for (const [key, { last, of }] of earlier) {
|
|
118
|
+
this.#last.set(key, last);
|
|
119
|
+
if (of) (of === "decision" ? this.#decisions : this.#agents).add(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Refuses a key that isn't a name, or that the other kind already holds in the run. */
|
|
124
|
+
claim(key: string, as: "agent" | "decision"): void {
|
|
125
|
+
const problem = keyProblem(key);
|
|
126
|
+
if (problem) throw new Error(`${as} key ${key} is not a name: ${problem}`);
|
|
127
|
+
const [mine, other] =
|
|
128
|
+
as === "agent" ? [this.#agents, this.#decisions] : [this.#decisions, this.#agents];
|
|
129
|
+
if (other.has(key))
|
|
130
|
+
throw new Error(
|
|
131
|
+
`${key} is ${as === "agent" ? "a decision's" : "an agent's"} key: agents and decisions share one set of names, as each operation's folder is ops/{key}.{n}; ${as === "agent" ? "an agent" : "a decision"} takes another key`,
|
|
132
|
+
);
|
|
133
|
+
mine.add(key);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The key's next operation id, its folder and `op.json` written at once, before any line names it,
|
|
138
|
+
* so a later claim numbers past it however the operation ends. Synchronous, so nothing waits
|
|
139
|
+
* between an operation's start and its cancellation being registered. `lost` says why `op.json`
|
|
140
|
+
* wasn't written, which fails the operation.
|
|
141
|
+
*/
|
|
142
|
+
draw(key: string, what: OpStart): { id: string; lost?: string } {
|
|
143
|
+
const n = (this.#last.get(key) ?? 0) + 1;
|
|
144
|
+
this.#last.set(key, n);
|
|
145
|
+
const id = `${key}.${n}`;
|
|
146
|
+
const record = {
|
|
147
|
+
version: OP_FILE_VERSIONS.op,
|
|
148
|
+
id,
|
|
149
|
+
attempt: this.attempt,
|
|
150
|
+
at: new Date().toISOString(),
|
|
151
|
+
...what,
|
|
152
|
+
} as OpRecord;
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(opDir(this.runDir, id), { recursive: true });
|
|
155
|
+
writeWholeSync(opFile(this.runDir, id), `${JSON.stringify(record, null, 2)}\n`);
|
|
156
|
+
return { id };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { id, lost: `op.json not written: ${messageOf(error)}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What an operation is, as it is drawn: its `op.json` but its version, id, attempt and time. */
|
|
164
|
+
type OpStart = OpRecord extends infer Each
|
|
165
|
+
? Each extends OpRecord
|
|
166
|
+
? Omit<Each, "version" | "id" | "attempt" | "at">
|
|
167
|
+
: never
|
|
168
|
+
: never;
|
|
169
|
+
|
|
170
|
+
/** Appends an answer offered, accepted or not, to its operation's `answers.jsonl`. */
|
|
171
|
+
export async function recordAnswer(runDir: string, op: string, answer: Candidate): Promise<void> {
|
|
172
|
+
await mkdir(opDir(runDir, op), { recursive: true });
|
|
173
|
+
await appendLine(answersFile(runDir, op), JSON.stringify(answer));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** An operation's answers, as tests check them; a run never reads them back. */
|
|
177
|
+
export async function readAnswers(runDir: string, op: string): Promise<Candidate[]> {
|
|
178
|
+
return readLines<Candidate>(answersFile(runDir, op));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Claims an operation's one accepted result with `value`; false when another holds it. */
|
|
182
|
+
export async function writeAcceptedExclusive(
|
|
183
|
+
runDir: string,
|
|
184
|
+
op: string,
|
|
185
|
+
value: unknown,
|
|
186
|
+
): Promise<boolean> {
|
|
187
|
+
await mkdir(opDir(runDir, op), { recursive: true });
|
|
188
|
+
const record = {
|
|
189
|
+
version: OP_FILE_VERSIONS.result,
|
|
190
|
+
value,
|
|
191
|
+
at: new Date().toISOString(),
|
|
192
|
+
} as ResultRecord;
|
|
193
|
+
return linkNew(join(opDir(runDir, op), "result.json"), record);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* An operation's accepted result, as tests check it; a run never reads it back. Null
|
|
198
|
+
* distinguishes "no value yet" from an operation whose accepted value happens to be null. A
|
|
199
|
+
* result.json of another version throws: unlike a cost, an answer has no unknown to fall back on.
|
|
200
|
+
*/
|
|
201
|
+
export async function readAccepted(runDir: string, op: string): Promise<{ value: unknown } | null> {
|
|
202
|
+
let text: string;
|
|
203
|
+
try {
|
|
204
|
+
text = await readFile(join(opDir(runDir, op), "result.json"), "utf8");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isCode(error, "ENOENT")) return null;
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
const record: unknown = JSON.parse(text);
|
|
210
|
+
if (!isKnownOpFile("result", record))
|
|
211
|
+
throw new Error(`${op}'s result.json is not version ${OP_FILE_VERSIONS.result}`);
|
|
212
|
+
return { value: (record as ResultRecord).value };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* An operation's JSON `file` at `path`, or undefined when it is missing, can't be parsed, or has no
|
|
217
|
+
* version or one this reader doesn't know: each is read as a file that isn't there. With `refuse`,
|
|
218
|
+
* a file that parses at another version throws instead, for a reader whose figures it would
|
|
219
|
+
* otherwise leave looking complete.
|
|
220
|
+
*/
|
|
221
|
+
export async function readOpFile<T>(
|
|
222
|
+
path: string,
|
|
223
|
+
file: OpFile,
|
|
224
|
+
options: { refuse?: boolean } = {},
|
|
225
|
+
): Promise<T | undefined> {
|
|
226
|
+
let value: unknown;
|
|
227
|
+
try {
|
|
228
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
229
|
+
} catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
if (isKnownOpFile(file, value)) return value as T;
|
|
233
|
+
if (options.refuse && typeof value === "object" && value !== null) {
|
|
234
|
+
const version = (value as { version?: unknown }).version;
|
|
235
|
+
throw new Error(
|
|
236
|
+
`${path} is ${version === undefined ? "of no version" : `version ${JSON.stringify(version)}`}; this awf reads version ${OP_FILE_VERSIONS[file]}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The most of a screen kept: its last lines, where a pane's scrollback came with it. */
|
|
243
|
+
const SCREEN_BYTES = 64 * 1024;
|
|
244
|
+
|
|
245
|
+
/** An operation's `screen.txt`: what its pane showed as it ended without an answer. */
|
|
246
|
+
export function screenFile(runDir: string, op: string): string {
|
|
247
|
+
return join(opDir(runDir, op), "screen.txt");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function writeScreen(runDir: string, op: string, screen: string): Promise<void> {
|
|
251
|
+
const bytes = Buffer.from(screen);
|
|
252
|
+
const kept = bytes.length <= SCREEN_BYTES ? screen : tailOf(bytes);
|
|
253
|
+
await mkdir(opDir(runDir, op), { recursive: true });
|
|
254
|
+
await writeFile(screenFile(runDir, op), kept, { mode: 0o600 });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Its last whole lines within the bound, or its last bytes where one line is longer. */
|
|
258
|
+
function tailOf(bytes: Buffer): string {
|
|
259
|
+
const tail = bytes.subarray(bytes.length - SCREEN_BYTES);
|
|
260
|
+
const line = tail.indexOf(10);
|
|
261
|
+
const whole = line === -1 || line === tail.length - 1 ? tail : tail.subarray(line + 1);
|
|
262
|
+
// A cut inside a character drops its continuation bytes.
|
|
263
|
+
let start = 0;
|
|
264
|
+
while (start < whole.length && (whole[start]! & 0xc0) === 0x80) start += 1;
|
|
265
|
+
return whole.subarray(start).toString();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function answersFile(runDir: string, op: string): string {
|
|
269
|
+
return join(opDir(runDir, op), "answers.jsonl");
|
|
270
|
+
}
|
package/src/pane-layout.ts
CHANGED
|
@@ -1,58 +1,69 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RunPanes } from "@agentswf/contract/records";
|
|
2
|
+
import type {
|
|
3
|
+
KeepPane,
|
|
4
|
+
PaneLayout,
|
|
5
|
+
PaneSpec,
|
|
6
|
+
PaneWorkspace,
|
|
7
|
+
TurnOutcome,
|
|
8
|
+
} from "@agentswf/contract/workflow";
|
|
2
9
|
|
|
3
10
|
/** A terminal session's name: Herdr makes `sessions/{name}` from it, and a leading `-` reads as an option. */
|
|
4
11
|
export const SESSION_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
5
12
|
|
|
6
13
|
const SHARE = { min: 0.2, max: 0.8 };
|
|
7
|
-
const NEW_TAB_FIELDS = new Set(["session", "workspace", "tab"]);
|
|
8
14
|
const BESIDE_FIELDS = new Set(["beside", "side", "share"]);
|
|
9
|
-
const
|
|
15
|
+
export const KEEP_PANES: readonly KeepPane[] = ["never", "on-failure", "always"];
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
|
-
* Refuses, before anything is placed, what
|
|
13
|
-
*
|
|
14
|
-
*
|
|
18
|
+
* Refuses, before anything is placed, what a workflow, untyped at run time, could get wrong in an
|
|
19
|
+
* agent's `pane`, and a `pane` on an agent with no pane of awf's to place
|
|
20
|
+
* (docs/design/pane-layout.md, "Refused at open").
|
|
15
21
|
*/
|
|
16
22
|
export function checkPaneOptions(
|
|
17
23
|
key: string,
|
|
18
|
-
spec: {
|
|
24
|
+
spec: { pane?: unknown },
|
|
19
25
|
agent: { headless: boolean; sandboxed: boolean },
|
|
20
26
|
): void {
|
|
21
27
|
const refuse = (why: string): never => {
|
|
22
28
|
throw new Error(`agent ${key}: ${why}`);
|
|
23
29
|
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
30
|
+
// Removed in 0.1.0-beta.6; refused, as they would place or keep a pane otherwise than written.
|
|
31
|
+
const given = spec as { layout?: unknown; keepPane?: unknown };
|
|
32
|
+
if (given.layout !== undefined)
|
|
27
33
|
refuse(
|
|
28
|
-
"layout
|
|
34
|
+
"layout was removed: give pane: { tab } or pane: { beside, side }; the workspace is awf run --workspace",
|
|
29
35
|
);
|
|
36
|
+
if (given.keepPane !== undefined)
|
|
37
|
+
refuse("keepPane was removed: keeping panes is the operator's, awf run --keep-panes");
|
|
38
|
+
const { pane } = spec;
|
|
39
|
+
if (pane === undefined) return;
|
|
40
|
+
if (agent.headless) refuse("pane places a pane, and a headless agent has none");
|
|
41
|
+
if (agent.sandboxed) {
|
|
42
|
+
refuse("pane is not yet for an agent in a sandbox, whose pane is in its own Herdr");
|
|
30
43
|
}
|
|
31
|
-
if (
|
|
32
|
-
refuse(`
|
|
33
|
-
}
|
|
34
|
-
if (spec.layout === undefined) return;
|
|
35
|
-
const layout = spec.layout;
|
|
36
|
-
if (typeof layout !== "object" || layout === null || Array.isArray(layout)) {
|
|
37
|
-
refuse(`a layout is an object, not ${JSON.stringify(layout)}`);
|
|
44
|
+
if (typeof pane !== "object" || pane === null || Array.isArray(pane)) {
|
|
45
|
+
refuse(`a pane is an object, not ${JSON.stringify(pane)}`);
|
|
38
46
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
47
|
+
const shape = pane as Record<string, unknown>;
|
|
48
|
+
const fields = Object.keys(shape).filter(
|
|
49
|
+
(field) => shape[field] !== undefined && field !== "front",
|
|
41
50
|
);
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
if (shape.front !== undefined && typeof shape.front !== "boolean") {
|
|
52
|
+
refuse(`front is true or false, not ${JSON.stringify(shape.front)}`);
|
|
53
|
+
}
|
|
54
|
+
if (shape.beside !== undefined) {
|
|
44
55
|
const others = fields.filter((field) => !BESIDE_FIELDS.has(field));
|
|
45
56
|
if (others.length > 0) {
|
|
46
|
-
refuse(`a
|
|
57
|
+
refuse(`a pane beside another pane takes side and share, not ${others.join(", ")}`);
|
|
47
58
|
}
|
|
48
|
-
if (typeof
|
|
49
|
-
refuse(`beside names an agent's key, not ${JSON.stringify(
|
|
59
|
+
if (typeof shape.beside !== "string" || shape.beside === "") {
|
|
60
|
+
refuse(`beside names an agent's key, not ${JSON.stringify(shape.beside)}`);
|
|
50
61
|
}
|
|
51
|
-
if (
|
|
52
|
-
if (
|
|
53
|
-
refuse(`side is right or below, not ${JSON.stringify(
|
|
62
|
+
if (shape.beside === key) refuse("a pane cannot be placed beside itself");
|
|
63
|
+
if (shape.side !== "right" && shape.side !== "below") {
|
|
64
|
+
refuse(`side is right or below, not ${JSON.stringify(shape.side)}`);
|
|
54
65
|
}
|
|
55
|
-
const share =
|
|
66
|
+
const share = shape.share;
|
|
56
67
|
if (
|
|
57
68
|
share !== undefined &&
|
|
58
69
|
(typeof share !== "number" || !(share >= SHARE.min && share <= SHARE.max))
|
|
@@ -61,46 +72,29 @@ export function checkPaneOptions(
|
|
|
61
72
|
}
|
|
62
73
|
return;
|
|
63
74
|
}
|
|
64
|
-
const others = fields.filter((field) =>
|
|
75
|
+
const others = fields.filter((field) => field !== "tab");
|
|
65
76
|
if (others.length > 0) {
|
|
66
77
|
refuse(
|
|
67
|
-
`a
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
const { session, workspace, tab } = given;
|
|
71
|
-
if (session !== undefined && (typeof session !== "string" || !SESSION_NAME.test(session))) {
|
|
72
|
-
refuse(
|
|
73
|
-
`session ${JSON.stringify(session)} is not a session name: lowercase letters, digits and -, up to 32, not starting with -`,
|
|
78
|
+
`a pane takes tab, or beside and side, and front; not ${others.join(", ")}${others.some((field) => field === "workspace" || field === "session") ? ": the workspace is the operator's, awf run --workspace" : ""}`,
|
|
74
79
|
);
|
|
75
80
|
}
|
|
76
|
-
if (
|
|
77
|
-
refuse(
|
|
78
|
-
}
|
|
79
|
-
if (
|
|
80
|
-
workspace !== undefined &&
|
|
81
|
-
workspace !== "run" &&
|
|
82
|
-
workspace !== "origin" &&
|
|
83
|
-
!(
|
|
84
|
-
typeof workspace === "object" &&
|
|
85
|
-
workspace !== null &&
|
|
86
|
-
!Array.isArray(workspace) &&
|
|
87
|
-
Object.keys(workspace).join() === "name" &&
|
|
88
|
-
nonEmpty((workspace as { name?: unknown }).name)
|
|
89
|
-
)
|
|
90
|
-
) {
|
|
91
|
-
refuse(`workspace is "run", "origin" or { name }, not ${JSON.stringify(workspace)}`);
|
|
81
|
+
if (shape.tab !== undefined && !nonEmpty(shape.tab)) {
|
|
82
|
+
refuse(`tab is a label, not ${JSON.stringify(shape.tab)}`);
|
|
92
83
|
}
|
|
93
|
-
if (tab !== undefined && !nonEmpty(tab)) refuse(`tab is a label, not ${JSON.stringify(tab)}`);
|
|
94
84
|
}
|
|
95
85
|
|
|
96
86
|
/**
|
|
97
|
-
* A checked
|
|
98
|
-
* that leaves one out matches.
|
|
87
|
+
* A checked `pane` as it is kept and compared: fields given as `undefined` dropped, and
|
|
88
|
+
* `front: false`, which is its absence, so a reopen that leaves one out matches.
|
|
99
89
|
*/
|
|
100
|
-
export function
|
|
90
|
+
export function storedPane(pane: PaneSpec): PaneSpec {
|
|
91
|
+
// Not an object, it is left for `checkPaneOptions` to refuse by name.
|
|
92
|
+
if (typeof pane !== "object" || pane === null || Array.isArray(pane)) return pane;
|
|
101
93
|
return Object.fromEntries(
|
|
102
|
-
Object.entries(structuredClone(
|
|
103
|
-
|
|
94
|
+
Object.entries(structuredClone(pane)).filter(
|
|
95
|
+
([field, value]) => value !== undefined && !(field === "front" && value === false),
|
|
96
|
+
),
|
|
97
|
+
) as PaneSpec;
|
|
104
98
|
}
|
|
105
99
|
|
|
106
100
|
function nonEmpty(value: unknown): value is string {
|
|
@@ -112,6 +106,52 @@ export function besideOf(layout: PaneLayout | undefined): string | undefined {
|
|
|
112
106
|
return layout && "beside" in layout ? layout.beside : undefined;
|
|
113
107
|
}
|
|
114
108
|
|
|
109
|
+
/** What the host is asked for one agent's pane. */
|
|
110
|
+
export type PaneAsked = {
|
|
111
|
+
layout?: PaneLayout;
|
|
112
|
+
front?: true;
|
|
113
|
+
keepPane?: KeepPane;
|
|
114
|
+
/** For a pane beside another: where its own tab opens if its target has no pane. */
|
|
115
|
+
fallbackWorkspace?: PaneWorkspace;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What the host is asked for an agent's pane: the workflow's tab or neighbour and `front`, in the
|
|
120
|
+
* operator's workspace and kept as the operator says. An agent with no pane of awf's, headless or
|
|
121
|
+
* in a sandbox, is asked nothing.
|
|
122
|
+
*/
|
|
123
|
+
export function paneAsked(
|
|
124
|
+
pane: PaneSpec | undefined,
|
|
125
|
+
run: RunPanes | undefined,
|
|
126
|
+
hasPane: boolean,
|
|
127
|
+
): PaneAsked {
|
|
128
|
+
if (!hasPane) return {};
|
|
129
|
+
const keepPane = run?.keep;
|
|
130
|
+
const workspace = run?.workspace;
|
|
131
|
+
const layout = placedLayout(pane, workspace);
|
|
132
|
+
return {
|
|
133
|
+
...(layout === undefined ? {} : { layout }),
|
|
134
|
+
...(pane?.front ? { front: true as const } : {}),
|
|
135
|
+
...(keepPane === undefined ? {} : { keepPane }),
|
|
136
|
+
...(besideOf(layout) !== undefined && workspace !== undefined
|
|
137
|
+
? { fallbackWorkspace: workspace }
|
|
138
|
+
: {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function placedLayout(
|
|
143
|
+
pane: PaneSpec | undefined,
|
|
144
|
+
workspace: PaneWorkspace | undefined,
|
|
145
|
+
): PaneLayout | undefined {
|
|
146
|
+
if (pane?.beside !== undefined) {
|
|
147
|
+
const { beside, side, share } = pane;
|
|
148
|
+
return { beside, side, ...(share === undefined ? {} : { share }) };
|
|
149
|
+
}
|
|
150
|
+
const tab = pane?.tab;
|
|
151
|
+
if (workspace !== undefined) return { workspace, ...(tab === undefined ? {} : { tab }) };
|
|
152
|
+
return tab === undefined ? undefined : { tab };
|
|
153
|
+
}
|
|
154
|
+
|
|
115
155
|
/**
|
|
116
156
|
* Whether a done agent's pane stays, by `keepPane` and how its last operation ended: `on-failure`
|
|
117
157
|
* keeps it unless that was answered, or there was none.
|
|
@@ -123,3 +163,9 @@ export function keepsPane(
|
|
|
123
163
|
if (keepPane === "always") return true;
|
|
124
164
|
return keepPane === "on-failure" && last !== undefined && last !== "answered";
|
|
125
165
|
}
|
|
166
|
+
|
|
167
|
+
/** `--workspace`'s value: the two keywords, else a workspace's label. */
|
|
168
|
+
export function parseWorkspace(value: string): PaneWorkspace {
|
|
169
|
+
if (value === "run" || value === "origin") return value;
|
|
170
|
+
return { name: value };
|
|
171
|
+
}
|
package/src/progress-view.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function renderProgress(
|
|
|
40
40
|
paint: Paint;
|
|
41
41
|
/** Once the run is over: what each stage's agents cost, by stage. */
|
|
42
42
|
figures?: ReadonlyMap<string, string>;
|
|
43
|
-
/** The Herdr session
|
|
43
|
+
/** The Herdr session TUI agents opened in, once one has. */
|
|
44
44
|
herdrSession?: string;
|
|
45
45
|
},
|
|
46
46
|
): string[] {
|
|
@@ -168,7 +168,7 @@ export function progressEvents(
|
|
|
168
168
|
turn.phase === "waiting"
|
|
169
169
|
? `waiting (agent-reported): ${oneLine(turn.waitingReason ?? "")}${turn.checkInAt === undefined ? "" : ` · check-in at ${clock(turn.checkInAt - view.startedAt)}`}`
|
|
170
170
|
: turn.phase === "awaiting-reply"
|
|
171
|
-
? `awaiting
|
|
171
|
+
? `awaiting-reply${turn.waitingReason === undefined ? "" : ` from ${oneLine(turn.waitingReason)}`}${turn.checkInAt === undefined ? "" : ` · until ${clock(turn.checkInAt - view.startedAt)}`}`
|
|
172
172
|
: oneLine(turn.phase.replaceAll("-", " "));
|
|
173
173
|
add(view.now, "agent phase", `… ${oneLine(agent.key)} · ${note}`);
|
|
174
174
|
}
|
|
@@ -316,7 +316,7 @@ const GAP = " ";
|
|
|
316
316
|
|
|
317
317
|
const HOUR_MS = 60 * 60_000;
|
|
318
318
|
|
|
319
|
-
/** The attempt that
|
|
319
|
+
/** The attempt that ran a reused stage, and its line's age once that may make it stale. */
|
|
320
320
|
function reusedTail(stage: StageProgress, now: number): string {
|
|
321
321
|
const age = stage.recordedAt === undefined ? 0 : now - stage.recordedAt;
|
|
322
322
|
return [
|
|
@@ -400,11 +400,11 @@ function agentState(
|
|
|
400
400
|
function awaitingNote(turn: NonNullable<Agent["turn"]>, now: number): string {
|
|
401
401
|
const since = turn.phaseAt ?? turn.startedAt;
|
|
402
402
|
const of = turn.checkInAt === undefined ? "" : ` of ${duration(turn.checkInAt - since)}`;
|
|
403
|
-
return `awaiting
|
|
403
|
+
return `awaiting-reply${turn.waitingReason === undefined ? "" : ` from ${oneLine(turn.waitingReason)}`} · ${duration(Math.max(0, now - since))}${of}`;
|
|
404
404
|
}
|
|
405
405
|
|
|
406
406
|
/** A message admitted: who to whom, its size, and whether it asks or answers. */
|
|
407
|
-
|
|
407
|
+
function messageLine(message: WorkflowRunSnapshot["messages"][number]): string {
|
|
408
408
|
const size =
|
|
409
409
|
message.bytes < 1024 ? `${message.bytes} B` : `${(message.bytes / 1024).toFixed(1)} KB`;
|
|
410
410
|
const kind = [message.answers ? "reply" : "", message.asks ? "reply owed" : ""].filter(Boolean);
|
|
@@ -412,20 +412,20 @@ export function messageLine(message: WorkflowRunSnapshot["messages"][number]): s
|
|
|
412
412
|
}
|
|
413
413
|
|
|
414
414
|
/**
|
|
415
|
-
* Where to see a
|
|
415
|
+
* Where to see a TUI agent Herdr read as blocked on a prompt: its session plays no sound, so this
|
|
416
416
|
* is the one sign. Not the calling session's, which is the operator's own pane.
|
|
417
417
|
*/
|
|
418
418
|
function whereBlocked(agent: Agent, herdrSession: string | undefined): string {
|
|
419
419
|
return agent.turn?.outcome === "blocked" &&
|
|
420
420
|
herdrSession !== undefined &&
|
|
421
|
-
placementOf(agent.execution) === "
|
|
421
|
+
placementOf(agent.execution) === "tui" &&
|
|
422
422
|
!agent.execution.caller
|
|
423
423
|
? ` · herdr session attach ${herdrSession}`
|
|
424
424
|
: "";
|
|
425
425
|
}
|
|
426
426
|
|
|
427
427
|
/**
|
|
428
|
-
* Where a run's
|
|
428
|
+
* Where a run's TUI agents are, said once as the first opens; the workspace closes with the run.
|
|
429
429
|
* A client inside Herdr can't attach to another session, so from there it says where to.
|
|
430
430
|
*/
|
|
431
431
|
export function describeRunSession(
|
|
@@ -519,7 +519,7 @@ export function watchProgress(
|
|
|
519
519
|
let handle: WorkflowRunHandle<JsonValue> | undefined;
|
|
520
520
|
let last: WorkflowRunSnapshot | undefined;
|
|
521
521
|
let herdrSession: string | undefined;
|
|
522
|
-
// A
|
|
522
|
+
// A TUI agent's session can be ready after the run has ended, as a cancel lands while it starts:
|
|
523
523
|
// nothing is said under the closing block.
|
|
524
524
|
let stopped = false;
|
|
525
525
|
let drawn = 0;
|
|
@@ -594,7 +594,7 @@ export function watchProgress(
|
|
|
594
594
|
if (under) clear();
|
|
595
595
|
else tick();
|
|
596
596
|
},
|
|
597
|
-
/** The Herdr session
|
|
597
|
+
/** The Herdr session TUI agents opened in, which a blocked one's line names. */
|
|
598
598
|
placedIn(session: string) {
|
|
599
599
|
if (!stopped) herdrSession = session;
|
|
600
600
|
},
|