@agentswf/engine 0.0.0-stage → 0.1.0-beta.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/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +569 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +241 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +516 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3665 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountingFigures,
|
|
3
|
+
AttemptAccounting,
|
|
4
|
+
DecisionFigures,
|
|
5
|
+
ModelFigures,
|
|
6
|
+
RunAccounting,
|
|
7
|
+
SettledDecision,
|
|
8
|
+
SettledOperation,
|
|
9
|
+
TokenUsage,
|
|
10
|
+
} from "@agentswf/contract/records";
|
|
11
|
+
import { costOf, type PriceTable } from "./prices";
|
|
12
|
+
import { addTokens } from "./tokens";
|
|
13
|
+
|
|
14
|
+
const NONE: TokenUsage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
15
|
+
|
|
16
|
+
/** What ran between a run's stages, as a row of `byStage`. */
|
|
17
|
+
export const NO_STAGE = "(no stage)";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a run cost and how long it took, from its usage and decision records alone, so a finished
|
|
21
|
+
* run can be priced again with another table. `entered` are the workflow stages entered, in order:
|
|
22
|
+
* each is a row timed by its own span, a reused one at zero; without them, a row per call path and
|
|
23
|
+
* key prefix, timed by what ran in it.
|
|
24
|
+
*/
|
|
25
|
+
export function summarizeRun(
|
|
26
|
+
usage: readonly SettledOperation[],
|
|
27
|
+
prices: PriceTable,
|
|
28
|
+
times: { startedAt: string; finishedAt: string },
|
|
29
|
+
decisions: readonly SettledDecision[],
|
|
30
|
+
entered: readonly { stage: string; spanMs: number }[] = [],
|
|
31
|
+
): RunAccounting {
|
|
32
|
+
const spends = usage.flatMap((record) => record.spend ?? []);
|
|
33
|
+
const agentModels = unique(spends.map((spend) => spend.model));
|
|
34
|
+
const decisionModels = unique(decisions.filter((record) => record.tokens).map(decisionModelOf));
|
|
35
|
+
const models = unique([...agentModels, ...decisionModels]).sort();
|
|
36
|
+
const agentIds = unique(usage.map(agentOf));
|
|
37
|
+
const staged =
|
|
38
|
+
entered.length > 0 ||
|
|
39
|
+
usage.some((record) => record.stage !== undefined) ||
|
|
40
|
+
decisions.some((record) => record.stage !== undefined);
|
|
41
|
+
const stageOf = staged ? workflowStageOf : prefixStageOf;
|
|
42
|
+
const decisionStageOf = staged ? workflowStageOf : decisionPrefixStageOf;
|
|
43
|
+
const stages = unique([
|
|
44
|
+
...entered.map(({ stage }) => stage),
|
|
45
|
+
...usage.map(stageOf),
|
|
46
|
+
...decisions.map(decisionStageOf),
|
|
47
|
+
]).sort(noStageLast);
|
|
48
|
+
const billings = unique(usage.map((record) => record.billing)).filter(
|
|
49
|
+
(billing) => billing !== "unknown",
|
|
50
|
+
);
|
|
51
|
+
const figures = (
|
|
52
|
+
records: readonly SettledOperation[],
|
|
53
|
+
asked: readonly SettledDecision[] = [],
|
|
54
|
+
): AccountingFigures => {
|
|
55
|
+
const agents = figuresOf(records, prices);
|
|
56
|
+
return asked.length === 0 ? agents : { ...agents, decisions: decisionFigures(asked, prices) };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
basis: prices.basis,
|
|
61
|
+
startedAt: times.startedAt,
|
|
62
|
+
finishedAt: times.finishedAt,
|
|
63
|
+
wallMs: Date.parse(times.finishedAt) - Date.parse(times.startedAt),
|
|
64
|
+
grouping: staged ? "stages" : "prefix",
|
|
65
|
+
billing: billings.length === 0 ? "unknown" : billings.length === 1 ? billings[0]! : "mixed",
|
|
66
|
+
totals: figures(usage, decisions),
|
|
67
|
+
byStage: stages.map((stage) => {
|
|
68
|
+
const records = usage.filter((record) => stageOf(record) === stage);
|
|
69
|
+
const asked = decisions.filter((record) => decisionStageOf(record) === stage);
|
|
70
|
+
const spanMs = entered.find((one) => one.stage === stage)?.spanMs ?? spanOf(records, asked);
|
|
71
|
+
return { stage, ...figures(records, asked), spanMs };
|
|
72
|
+
}),
|
|
73
|
+
byModel: models.map((model) => {
|
|
74
|
+
const agents = modelFigures(model, usage, prices);
|
|
75
|
+
const answered = decisions.filter(
|
|
76
|
+
(record) => record.tokens && decisionModelOf(record) === model,
|
|
77
|
+
);
|
|
78
|
+
return answered.length === 0 ? agents : withDecisions(agents, answered, prices);
|
|
79
|
+
}),
|
|
80
|
+
byAgent: agentIds.map((id) => {
|
|
81
|
+
const records = usage.filter((record) => agentOf(record) === id);
|
|
82
|
+
const first = records[0]!;
|
|
83
|
+
return {
|
|
84
|
+
callPath: first.callPath,
|
|
85
|
+
agent: first.agent,
|
|
86
|
+
execution: first.execution,
|
|
87
|
+
billing: first.billing,
|
|
88
|
+
...figures(records),
|
|
89
|
+
};
|
|
90
|
+
}),
|
|
91
|
+
unpriced: models.filter((model) => !prices.rate(model)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function figuresOf(records: readonly SettledOperation[], prices: PriceTable): AccountingFigures {
|
|
96
|
+
let tokens = NONE;
|
|
97
|
+
let delegated = NONE;
|
|
98
|
+
let charged: number | undefined;
|
|
99
|
+
let agentMs = 0;
|
|
100
|
+
const agents = new Map<
|
|
101
|
+
string,
|
|
102
|
+
{ known: boolean; priced: boolean; billed: boolean; cost: number }
|
|
103
|
+
>();
|
|
104
|
+
for (const record of records) {
|
|
105
|
+
const id = agentOf(record);
|
|
106
|
+
const agent = agents.get(id) ?? {
|
|
107
|
+
known: true,
|
|
108
|
+
priced: true,
|
|
109
|
+
billed: record.billing !== "unknown",
|
|
110
|
+
cost: 0,
|
|
111
|
+
};
|
|
112
|
+
agents.set(id, agent);
|
|
113
|
+
if (record.deliveredAt !== undefined && record.settledAt !== undefined) {
|
|
114
|
+
agentMs += Date.parse(record.settledAt) - Date.parse(record.deliveredAt);
|
|
115
|
+
}
|
|
116
|
+
// Another currency would need converting first; until then its charge is an unknown one.
|
|
117
|
+
if (record.charged?.currency === "USD") charged = (charged ?? 0) + record.charged.amount;
|
|
118
|
+
else if (record.charged) agent.billed = false;
|
|
119
|
+
if (!record.spend) {
|
|
120
|
+
agent.known = false;
|
|
121
|
+
agent.priced = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const spend of record.spend) {
|
|
125
|
+
tokens = addTokens(tokens, spend.tokens);
|
|
126
|
+
if (spend.delegated) delegated = addTokens(delegated, spend.tokens);
|
|
127
|
+
const rate = prices.rate(spend.model);
|
|
128
|
+
if (rate) agent.cost += costOf(spend.tokens, rate);
|
|
129
|
+
else agent.priced = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const counts = [...agents.values()];
|
|
133
|
+
let estimate: number | undefined;
|
|
134
|
+
for (const agent of counts) {
|
|
135
|
+
// A partly priced agent adds what was priced; a fully priced one makes even a zero known.
|
|
136
|
+
if (agent.priced || agent.cost > 0) estimate = (estimate ?? 0) + agent.cost;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
agents: agents.size,
|
|
140
|
+
agentMs,
|
|
141
|
+
tokens,
|
|
142
|
+
delegated,
|
|
143
|
+
...(estimate === undefined ? {} : { estimate }),
|
|
144
|
+
...(charged === undefined ? {} : { charged }),
|
|
145
|
+
known: counts.filter((agent) => agent.known).length,
|
|
146
|
+
priced: counts.filter((agent) => agent.priced).length,
|
|
147
|
+
billed: counts.filter((agent) => agent.billed).length,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function decisionFigures(records: readonly SettledDecision[], prices: PriceTable): DecisionFigures {
|
|
152
|
+
let input = 0;
|
|
153
|
+
let output = 0;
|
|
154
|
+
let estimate: number | undefined;
|
|
155
|
+
let charged: number | undefined;
|
|
156
|
+
let known = 0;
|
|
157
|
+
let priced = 0;
|
|
158
|
+
let attempts = 0;
|
|
159
|
+
for (const record of records) {
|
|
160
|
+
attempts += record.attempts;
|
|
161
|
+
// Another currency would need converting first; until then its charge is not counted.
|
|
162
|
+
if (record.charged?.currency === "USD") charged = (charged ?? 0) + record.charged.amount;
|
|
163
|
+
if (!record.tokens) continue;
|
|
164
|
+
known += 1;
|
|
165
|
+
input += record.tokens.input;
|
|
166
|
+
output += record.tokens.output;
|
|
167
|
+
const rate = prices.rate(decisionModelOf(record));
|
|
168
|
+
if (!rate) continue;
|
|
169
|
+
priced += 1;
|
|
170
|
+
estimate = (estimate ?? 0) + costOf(decisionTokens(record.tokens), rate);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
calls: records.length,
|
|
174
|
+
attempts,
|
|
175
|
+
tokens: { input, output },
|
|
176
|
+
...(estimate === undefined ? {} : { estimate }),
|
|
177
|
+
...(charged === undefined ? {} : { charged }),
|
|
178
|
+
known,
|
|
179
|
+
priced,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function withDecisions(
|
|
184
|
+
agents: ModelFigures,
|
|
185
|
+
answered: readonly SettledDecision[],
|
|
186
|
+
prices: PriceTable,
|
|
187
|
+
): ModelFigures {
|
|
188
|
+
let tokens = agents.tokens;
|
|
189
|
+
for (const record of answered) tokens = addTokens(tokens, decisionTokens(record.tokens!));
|
|
190
|
+
const rate = prices.rate(agents.model);
|
|
191
|
+
return {
|
|
192
|
+
...agents,
|
|
193
|
+
decisionCalls: answered.length,
|
|
194
|
+
tokens,
|
|
195
|
+
...(rate ? { estimate: costOf(tokens, rate) } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function decisionTokens(tokens: { input: number; output: number }): TokenUsage {
|
|
200
|
+
return { ...NONE, input: tokens.input, output: tokens.output };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Priced as the model that answered, when one did: an alias can move, and so can its price. */
|
|
204
|
+
function decisionModelOf(record: SettledDecision): string {
|
|
205
|
+
return record.snapshot ?? record.model;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decisionPrefixStageOf(record: SettledDecision): string {
|
|
209
|
+
return [...record.callPath, prefixOf(record.key)].join("/");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function modelFigures(
|
|
213
|
+
model: string,
|
|
214
|
+
usage: readonly SettledOperation[],
|
|
215
|
+
prices: PriceTable,
|
|
216
|
+
): ModelFigures {
|
|
217
|
+
const rate = prices.rate(model);
|
|
218
|
+
const agents = new Set<string>();
|
|
219
|
+
let tokens = NONE;
|
|
220
|
+
let delegated = NONE;
|
|
221
|
+
for (const record of usage) {
|
|
222
|
+
for (const spend of record.spend ?? []) {
|
|
223
|
+
if (spend.model !== model) continue;
|
|
224
|
+
agents.add(agentOf(record));
|
|
225
|
+
tokens = addTokens(tokens, spend.tokens);
|
|
226
|
+
if (spend.delegated) delegated = addTokens(delegated, spend.tokens);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
model,
|
|
231
|
+
agents: agents.size,
|
|
232
|
+
tokens,
|
|
233
|
+
delegated,
|
|
234
|
+
...(rate ? { estimate: costOf(tokens, rate) } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** An agent is its key within its call: the same key in two nested calls is two agents. */
|
|
239
|
+
function agentOf(record: SettledOperation): string {
|
|
240
|
+
return JSON.stringify([record.callPath, record.agent]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function workflowStageOf(record: { stage?: string }): string {
|
|
244
|
+
return record.stage ?? NO_STAGE;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A stable sort's order otherwise, with `(no stage)` last. */
|
|
248
|
+
function noStageLast(a: string, b: string): number {
|
|
249
|
+
return Number(a === NO_STAGE) - Number(b === NO_STAGE);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function prefixStageOf(record: SettledOperation): string {
|
|
253
|
+
return [...record.callPath, prefixOf(record.agent)].join("/");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function prefixOf(key: string): string {
|
|
257
|
+
const separator = key.indexOf(":");
|
|
258
|
+
return separator === -1 ? key : key.slice(0, separator);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function spanOf(
|
|
262
|
+
records: readonly SettledOperation[],
|
|
263
|
+
decisions: readonly SettledDecision[] = [],
|
|
264
|
+
): number {
|
|
265
|
+
const starts = [
|
|
266
|
+
...records.flatMap((record) =>
|
|
267
|
+
record.deliveredAt === undefined ? [] : [Date.parse(record.deliveredAt)],
|
|
268
|
+
),
|
|
269
|
+
...decisions.map((record) => Date.parse(record.startedAt)),
|
|
270
|
+
];
|
|
271
|
+
const ends = [
|
|
272
|
+
...records.flatMap((record) =>
|
|
273
|
+
record.settledAt === undefined ? [] : [Date.parse(record.settledAt)],
|
|
274
|
+
),
|
|
275
|
+
...decisions.map((record) => Date.parse(record.settledAt)),
|
|
276
|
+
];
|
|
277
|
+
return starts.length === 0 || ends.length === 0
|
|
278
|
+
? 0
|
|
279
|
+
: Math.max(0, Math.max(...ends) - Math.min(...starts));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function unique<T>(values: readonly T[]): T[] {
|
|
283
|
+
return [...new Set(values)];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** What an attempt's record keeps of its run's accounting: the totals and stages, not who spent it. */
|
|
287
|
+
export function attemptAccounting({
|
|
288
|
+
basis,
|
|
289
|
+
wallMs,
|
|
290
|
+
grouping,
|
|
291
|
+
billing,
|
|
292
|
+
totals,
|
|
293
|
+
byStage,
|
|
294
|
+
unpriced,
|
|
295
|
+
}: RunAccounting): AttemptAccounting {
|
|
296
|
+
return { basis, wallMs, grouping, billing, totals, byStage, unpriced };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A run's attempts summed, each estimate as its attempt recorded it, under the last one's basis:
|
|
301
|
+
* its total, and each stage's across the attempts that ran it. Once any attempt grouped by stages, what another grouped by prefix, having failed
|
|
302
|
+
* before it entered one, ran between stages.
|
|
303
|
+
*/
|
|
304
|
+
export function sumAttempts(attempts: readonly AttemptAccounting[]): AttemptAccounting {
|
|
305
|
+
const grouping = attempts.some((attempt) => attempt.grouping === "stages") ? "stages" : "prefix";
|
|
306
|
+
const byStage = new Map<string, RunAccounting["byStage"][number]>();
|
|
307
|
+
for (const attempt of attempts) {
|
|
308
|
+
for (const row of attempt.byStage) {
|
|
309
|
+
const stage = grouping === attempt.grouping ? row.stage : NO_STAGE;
|
|
310
|
+
const before = byStage.get(stage);
|
|
311
|
+
byStage.set(
|
|
312
|
+
stage,
|
|
313
|
+
before
|
|
314
|
+
? { ...addFigures(before, row), stage, spanMs: before.spanMs + row.spanMs }
|
|
315
|
+
: { ...row, stage },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const billings = unique(attempts.map((attempt) => attempt.billing)).filter(
|
|
320
|
+
(billing) => billing !== "unknown",
|
|
321
|
+
);
|
|
322
|
+
return {
|
|
323
|
+
basis: attempts.at(-1)?.basis ?? "",
|
|
324
|
+
wallMs: attempts.reduce((sum, attempt) => sum + attempt.wallMs, 0),
|
|
325
|
+
grouping,
|
|
326
|
+
billing: billings.length === 0 ? "unknown" : billings.length === 1 ? billings[0]! : "mixed",
|
|
327
|
+
totals: attempts.map((attempt) => attempt.totals).reduce(addFigures, ZERO),
|
|
328
|
+
byStage: [...byStage.values()].sort((a, b) => noStageLast(a.stage, b.stage)),
|
|
329
|
+
unpriced: unique(attempts.flatMap((attempt) => attempt.unpriced)).sort(),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const ZERO: AccountingFigures = {
|
|
334
|
+
agents: 0,
|
|
335
|
+
agentMs: 0,
|
|
336
|
+
tokens: NONE,
|
|
337
|
+
delegated: NONE,
|
|
338
|
+
known: 0,
|
|
339
|
+
priced: 0,
|
|
340
|
+
billed: 0,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
function addFigures(left: AccountingFigures, right: AccountingFigures): AccountingFigures {
|
|
344
|
+
const decisions =
|
|
345
|
+
left.decisions && right.decisions
|
|
346
|
+
? addDecisions(left.decisions, right.decisions)
|
|
347
|
+
: (left.decisions ?? right.decisions);
|
|
348
|
+
return {
|
|
349
|
+
agents: left.agents + right.agents,
|
|
350
|
+
agentMs: left.agentMs + right.agentMs,
|
|
351
|
+
tokens: addTokens(left.tokens, right.tokens),
|
|
352
|
+
delegated: addTokens(left.delegated, right.delegated),
|
|
353
|
+
...addOptional("estimate", left, right),
|
|
354
|
+
...addOptional("charged", left, right),
|
|
355
|
+
known: left.known + right.known,
|
|
356
|
+
priced: left.priced + right.priced,
|
|
357
|
+
billed: left.billed + right.billed,
|
|
358
|
+
...(decisions ? { decisions } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function addDecisions(left: DecisionFigures, right: DecisionFigures): DecisionFigures {
|
|
363
|
+
return {
|
|
364
|
+
calls: left.calls + right.calls,
|
|
365
|
+
attempts: left.attempts + right.attempts,
|
|
366
|
+
tokens: {
|
|
367
|
+
input: left.tokens.input + right.tokens.input,
|
|
368
|
+
output: left.tokens.output + right.tokens.output,
|
|
369
|
+
},
|
|
370
|
+
...addOptional("estimate", left, right),
|
|
371
|
+
...addOptional("charged", left, right),
|
|
372
|
+
known: left.known + right.known,
|
|
373
|
+
priced: left.priced + right.priced,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** A sum of what either side knows; absent only when neither does. */
|
|
378
|
+
function addOptional<K extends "estimate" | "charged">(
|
|
379
|
+
field: K,
|
|
380
|
+
left: { [P in K]?: number },
|
|
381
|
+
right: { [P in K]?: number },
|
|
382
|
+
): { [P in K]?: number } {
|
|
383
|
+
if (left[field] === undefined && right[field] === undefined) return {};
|
|
384
|
+
return { [field]: (left[field] ?? 0) + (right[field] ?? 0) } as { [P in K]?: number };
|
|
385
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ModelSpend, TokenUsage } from "@agentswf/contract/records";
|
|
2
|
+
|
|
3
|
+
/** The optional classes stay absent unless one side reports them. */
|
|
4
|
+
export function addTokens(left: TokenUsage, right: TokenUsage): TokenUsage {
|
|
5
|
+
const optional = (field: "cacheWrite1h" | "reasoning") =>
|
|
6
|
+
left[field] === undefined && right[field] === undefined
|
|
7
|
+
? {}
|
|
8
|
+
: { [field]: (left[field] ?? 0) + (right[field] ?? 0) };
|
|
9
|
+
return {
|
|
10
|
+
input: left.input + right.input,
|
|
11
|
+
cacheRead: left.cacheRead + right.cacheRead,
|
|
12
|
+
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
13
|
+
...optional("cacheWrite1h"),
|
|
14
|
+
output: left.output + right.output,
|
|
15
|
+
...optional("reasoning"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One entry per model and per who spent it, the agent or its subagents. */
|
|
20
|
+
export function spendOf(
|
|
21
|
+
records: readonly { model: string; delegated: boolean; tokens: TokenUsage }[],
|
|
22
|
+
): ModelSpend[] {
|
|
23
|
+
const groups = new Map<string, ModelSpend>();
|
|
24
|
+
for (const record of records) {
|
|
25
|
+
const group = JSON.stringify([record.model, record.delegated]);
|
|
26
|
+
const seen = groups.get(group);
|
|
27
|
+
groups.set(group, {
|
|
28
|
+
model: record.model,
|
|
29
|
+
delegated: record.delegated,
|
|
30
|
+
tokens: seen ? addTokens(seen.tokens, record.tokens) : { ...record.tokens },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return [...groups.values()];
|
|
34
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { type AgentDoor, shellQuote } from "@agentswf/sandbox";
|
|
5
|
+
|
|
6
|
+
/** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wf`'s. */
|
|
7
|
+
const NO_ENV = "--no-env-file";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The agent is given a path to run, and nothing else. Every other channel we could deliver a
|
|
11
|
+
* return address on has turned out to be a harness's private business: a Codex pane executes its
|
|
12
|
+
* tool commands in a different process from the one Herdr launched, so neither the environment nor
|
|
13
|
+
* the `PATH` of that pane reaches them. A prompt does reach them, because carrying prompts is the
|
|
14
|
+
* one thing every harness must do.
|
|
15
|
+
*
|
|
16
|
+
* The launcher holds the socket, so nothing secret has to survive that trip, and the connection
|
|
17
|
+
* is what identifies the agent — no id it types can claim to be somebody else. It is not
|
|
18
|
+
* isolation: every agent runs as the same user as the engine, so one that goes looking for a
|
|
19
|
+
* sibling's socket can still find it. Separating those needs a uid per agent.
|
|
20
|
+
*/
|
|
21
|
+
export async function installAgentLauncher(
|
|
22
|
+
directory: string,
|
|
23
|
+
endpoint: string,
|
|
24
|
+
/**
|
|
25
|
+
* The variable naming the native session, passed on with every call. The harness sets it in the
|
|
26
|
+
* shell that runs the agent's tool call, which the pane's own environment never reaches.
|
|
27
|
+
*/
|
|
28
|
+
sessionEnv?: string,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const command = await resolveAgentCommand();
|
|
31
|
+
return writeLauncher(directory, [process.execPath, NO_ENV, command], endpoint, sessionEnv);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A sandboxed agent's door: the same launcher, running the bundled `wf` with the host's bun by
|
|
36
|
+
* its real path, since a sandbox reads neither the workspace's sources nor bun's links under `~`.
|
|
37
|
+
* A box copies `boxScript` to the launcher's path, which runs the bundle with the box's own bun.
|
|
38
|
+
*/
|
|
39
|
+
export async function installSandboxedDoor(
|
|
40
|
+
directory: string,
|
|
41
|
+
endpoint: string,
|
|
42
|
+
/** The bundled `wf`'s source, written beside the launcher. */
|
|
43
|
+
source: string,
|
|
44
|
+
sessionEnv?: string,
|
|
45
|
+
): Promise<AgentDoor> {
|
|
46
|
+
const bun = await realpath(process.execPath);
|
|
47
|
+
await mkdir(directory, { recursive: true });
|
|
48
|
+
// Real paths, as a sandbox's rules match them: `/tmp` is `/private/tmp` on macOS.
|
|
49
|
+
const real = await realpath(directory);
|
|
50
|
+
const socket = join(real, basename(endpoint));
|
|
51
|
+
// In the agent's own directory: bun will not load a module from the control plane's, which is
|
|
52
|
+
// not listable so that no agent can find another's socket.
|
|
53
|
+
const bundle = join(real, "wf.js");
|
|
54
|
+
await writeFile(bundle, source, { mode: 0o500 });
|
|
55
|
+
const launcher = await writeLauncher(real, [bun, NO_ENV, bundle], socket, sessionEnv);
|
|
56
|
+
return {
|
|
57
|
+
endpoint: socket,
|
|
58
|
+
launcher,
|
|
59
|
+
// A box copies it to the launcher's path, the one it names itself by.
|
|
60
|
+
boxScript: launcherScript(["bun", NO_ENV, bundle], socket, sessionEnv, launcher),
|
|
61
|
+
bundle,
|
|
62
|
+
reads: [bun, bundle],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `wf` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
|
|
68
|
+
* a `bun build` of its own: in process, the bundler resolved packages against the test runner's
|
|
69
|
+
* state and missed the workspace's links.
|
|
70
|
+
*/
|
|
71
|
+
export async function buildAgentBundle(): Promise<string> {
|
|
72
|
+
const entry = await resolveAgentCommand();
|
|
73
|
+
const built = Bun.spawn({
|
|
74
|
+
cmd: [process.execPath, "build", entry, "--target=bun"],
|
|
75
|
+
cwd: dirname(entry),
|
|
76
|
+
stdout: "pipe",
|
|
77
|
+
stderr: "pipe",
|
|
78
|
+
});
|
|
79
|
+
const [code, bundle, stderr] = await Promise.all([
|
|
80
|
+
built.exited,
|
|
81
|
+
new Response(built.stdout).text(),
|
|
82
|
+
new Response(built.stderr).text(),
|
|
83
|
+
]);
|
|
84
|
+
if (code !== 0) throw new Error(`could not bundle wf: ${stderr.trim().slice(0, 400)}`);
|
|
85
|
+
return bundle;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function writeLauncher(
|
|
89
|
+
directory: string,
|
|
90
|
+
command: readonly string[],
|
|
91
|
+
endpoint: string,
|
|
92
|
+
sessionEnv: string | undefined,
|
|
93
|
+
): Promise<string> {
|
|
94
|
+
await mkdir(directory, { recursive: true });
|
|
95
|
+
await chmod(directory, 0o700);
|
|
96
|
+
const path = join(directory, "wf");
|
|
97
|
+
// Not writable, so an agent that redirects into its own launcher fails rather than replacing its
|
|
98
|
+
// only way to answer. A `chmod` still gets past it; a sandbox denies the write outright.
|
|
99
|
+
await writeFile(path, launcherScript(command, endpoint, sessionEnv, path), { mode: 0o500 });
|
|
100
|
+
await chmod(path, 0o500);
|
|
101
|
+
return path;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function launcherScript(
|
|
105
|
+
command: readonly string[],
|
|
106
|
+
endpoint: string,
|
|
107
|
+
sessionEnv: string | undefined,
|
|
108
|
+
/** Its own path, which `wf` names in what it prints, as an agent must type it. */
|
|
109
|
+
path: string,
|
|
110
|
+
): string {
|
|
111
|
+
if (sessionEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sessionEnv)) {
|
|
112
|
+
throw new Error(`not an environment variable name: ${sessionEnv}`);
|
|
113
|
+
}
|
|
114
|
+
const session = sessionEnv ? ` --session "\${${sessionEnv}:-}"` : "";
|
|
115
|
+
return `#!/bin/sh\nexec ${command.map(shellQuote).join(" ")} --at ${shellQuote(endpoint)} --as ${shellQuote(path)}${session} "$@"\n`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveAgentCommand(): Promise<string> {
|
|
119
|
+
const require = createRequire(import.meta.url);
|
|
120
|
+
const manifestPath = require.resolve("@agentswf/wf/package.json");
|
|
121
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
|
|
122
|
+
bin?: string | Record<string, string>;
|
|
123
|
+
};
|
|
124
|
+
const relative = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.wf;
|
|
125
|
+
if (!relative) throw new Error("@agentswf/wf does not publish the wf command");
|
|
126
|
+
return resolve(dirname(manifestPath), relative);
|
|
127
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ALLOWANCE_VERSION,
|
|
3
|
+
type AllowanceReport,
|
|
4
|
+
type AllowanceWindow,
|
|
5
|
+
type HarnessAllowance,
|
|
6
|
+
} from "@agentswf/contract/records";
|
|
7
|
+
import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
|
|
8
|
+
import { PLAN_BASIS, planPrice } from "./accounting/plans";
|
|
9
|
+
|
|
10
|
+
export const allowanceUsage = [
|
|
11
|
+
"usage: awf allowance [harness...] [--json]",
|
|
12
|
+
"",
|
|
13
|
+
"What is left of each harness's plan, as the harness itself shows it: claude's /usage, codex's",
|
|
14
|
+
"/status, and cursor's /usage, read in a Herdr pane. Nothing asks a model. Every harness when",
|
|
15
|
+
`none is named: ${HARNESS_NAMES.join(", ")}.`,
|
|
16
|
+
"",
|
|
17
|
+
"A plan's monthly price is the vendor's list price as of the table's date, not what was",
|
|
18
|
+
`charged: ${PLAN_BASIS}.`,
|
|
19
|
+
"",
|
|
20
|
+
`--json prints an ${ALLOWANCE_VERSION} record, the interface for scripts, with the plan as the`,
|
|
21
|
+
"harness names it and no price; what it prints otherwise is for reading. A harness that could",
|
|
22
|
+
"not be read says why. Exits 0 once every harness was asked, 2 on a usage error.",
|
|
23
|
+
].join("\n");
|
|
24
|
+
|
|
25
|
+
export type AllowanceCommand = { harnesses: Harness[]; json: boolean } | "help";
|
|
26
|
+
|
|
27
|
+
export function parseAllowanceCommand(argv: readonly string[]): AllowanceCommand {
|
|
28
|
+
const harnesses: Harness[] = [];
|
|
29
|
+
let json = false;
|
|
30
|
+
for (const arg of argv) {
|
|
31
|
+
if (arg === "-h" || arg === "--help") return "help";
|
|
32
|
+
if (arg === "--json") json = true;
|
|
33
|
+
else if ((HARNESS_NAMES as readonly string[]).includes(arg)) {
|
|
34
|
+
if (!harnesses.includes(arg as Harness)) harnesses.push(arg as Harness);
|
|
35
|
+
} else throw new Error(`unknown harness or option: ${arg}`);
|
|
36
|
+
}
|
|
37
|
+
return { harnesses: harnesses.length > 0 ? harnesses : [...HARNESS_NAMES], json };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every harness asked at once: cursor's pane takes seconds the others need not wait for. */
|
|
41
|
+
export async function readReport(
|
|
42
|
+
harnesses: readonly Harness[],
|
|
43
|
+
read: (harness: Harness) => Promise<HarnessAllowance>,
|
|
44
|
+
now: number,
|
|
45
|
+
): Promise<AllowanceReport> {
|
|
46
|
+
return {
|
|
47
|
+
version: ALLOWANCE_VERSION,
|
|
48
|
+
readAt: new Date(now).toISOString(),
|
|
49
|
+
harnesses: await Promise.all(harnesses.map(read)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A line per harness, its windows in the order it shows them; a reset they share, said once. */
|
|
54
|
+
export function describeReport(report: AllowanceReport, timeZone?: string): string {
|
|
55
|
+
const width = Math.max(...report.harnesses.map((each) => each.harness.length)) + 2;
|
|
56
|
+
const now = Date.parse(report.readAt);
|
|
57
|
+
return report.harnesses
|
|
58
|
+
.map((each) => {
|
|
59
|
+
const said =
|
|
60
|
+
each.read === "none"
|
|
61
|
+
? `no allowance: ${each.reason}`
|
|
62
|
+
: [
|
|
63
|
+
...(each.plan ? [describePlan(each.harness, each.plan, each.tier)] : []),
|
|
64
|
+
...sharingReset(each.windows).map((windows) => {
|
|
65
|
+
const used = windows.map((w) => `${w.label} ${w.usedPercent}%`).join(", ");
|
|
66
|
+
const resetsAt = windows[0]?.resetsAt;
|
|
67
|
+
return resetsAt
|
|
68
|
+
? `${used}, resets ${describeTime(Date.parse(resetsAt), now, timeZone)}`
|
|
69
|
+
: used;
|
|
70
|
+
}),
|
|
71
|
+
].join(" · ");
|
|
72
|
+
return `${each.harness.padEnd(width)}${said}`;
|
|
73
|
+
})
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The plan as people name it and its price, else as the harness words it. */
|
|
78
|
+
function describePlan(harness: string, plan: string, tier?: string): string {
|
|
79
|
+
const listed = planPrice(harness, plan, tier);
|
|
80
|
+
if (!listed) return tier ? `${plan} (${tier})` : plan;
|
|
81
|
+
return listed.usdPerMonth === undefined
|
|
82
|
+
? listed.name
|
|
83
|
+
: `${listed.name} ($${listed.usdPerMonth}/mo)`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Runs of windows, in order, that reset at the same time. */
|
|
87
|
+
function sharingReset(windows: readonly AllowanceWindow[]): AllowanceWindow[][] {
|
|
88
|
+
const runs: AllowanceWindow[][] = [];
|
|
89
|
+
for (const window of windows) {
|
|
90
|
+
const last = runs.at(-1);
|
|
91
|
+
if (last && last[0]?.resetsAt === window.resetsAt) last.push(window);
|
|
92
|
+
else runs.push([window]);
|
|
93
|
+
}
|
|
94
|
+
return runs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The time alone today, the day alone at midnight, as a day only resets there. */
|
|
98
|
+
function describeTime(at: number, now: number, timeZone?: string): string {
|
|
99
|
+
const format = (options: Intl.DateTimeFormatOptions) =>
|
|
100
|
+
new Intl.DateTimeFormat("en-US", { ...options, ...(timeZone ? { timeZone } : {}) }).format;
|
|
101
|
+
const day = format({ month: "short", day: "numeric" });
|
|
102
|
+
const time = format({ hour: "2-digit", minute: "2-digit", hourCycle: "h23" })(at);
|
|
103
|
+
if (day(at) === day(now)) return time;
|
|
104
|
+
return time === "00:00" ? day(at) : `${day(at)} ${time}`;
|
|
105
|
+
}
|