agent-accelerator 0.5.3 → 0.5.5
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/README.md +84 -37
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +14 -20
- package/package.json +1 -1
- package/src/agent/agent.ts +181 -56
- package/src/agent/config-blocks.ts +80 -0
- package/src/agent/context.ts +1 -1
- package/src/agent/delegation.ts +208 -524
- package/src/agent/loop.ts +18 -14
- package/src/agent/subagents-tool.ts +87 -0
- package/src/agent/subagents.md +31 -0
- package/src/agent/turns.ts +60 -0
- package/src/agent/worker.ts +43 -33
- package/src/agent/workers.ts +405 -0
- package/src/index.ts +6 -5
- package/src/providers/openrouter.ts +49 -225
- package/src/providers/shared.ts +2 -2
- package/src/scheduler/scheduled-actions.md +4 -2
- package/src/scheduler/scheduler.ts +87 -50
- package/src/scheduler/store.ts +23 -1
- package/src/scheduler/tool.ts +9 -8
- package/src/session/journal.ts +12 -1
- package/src/session/store.ts +24 -10
- package/src/session/usage.ts +201 -0
- package/src/streaming/event-stream.ts +7 -2
- package/src/tools/executor.ts +17 -2
- package/src/tools/subagent-result.ts +1 -1
- package/src/tools/tool.ts +0 -16
- package/src/types/agent.ts +44 -16
- package/src/types/scheduler.ts +9 -2
- package/src/types/tool.ts +1 -1
- package/src/utils/headers.ts +1 -1
|
@@ -14,14 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Agent } from "../agent/agent.ts";
|
|
16
16
|
import { createChildSessionId } from "../agent/delegation.ts";
|
|
17
|
-
import { buildDynamicWorkerConfig, grantWorkerTools,
|
|
17
|
+
import { buildDynamicWorkerConfig, grantWorkerTools, scheduledRunTimeoutMs } from "../agent/worker.ts";
|
|
18
18
|
import { telemetryBus } from "../telemetry.ts";
|
|
19
|
+
import { readUsage, usageLine, type UsageTotals } from "../session/usage.ts";
|
|
19
20
|
import { lockFolder } from "../utils/durable.ts";
|
|
20
21
|
import { ConfigError, ValidationError } from "../types/errors.ts";
|
|
21
22
|
import type { ToolDefinition } from "../types/tool.ts";
|
|
22
23
|
import type { AgentResponse } from "../types/response.ts";
|
|
23
24
|
import type {
|
|
24
|
-
|
|
25
|
+
DeleteResult,
|
|
25
26
|
ScheduledActionInput,
|
|
26
27
|
ScheduledActionPatch,
|
|
27
28
|
ScheduledActionRecord,
|
|
@@ -68,16 +69,43 @@ export interface HomeOptions {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
type PoolTools = Record<string, ToolDefinition>;
|
|
71
|
-
type Outcome = { res: AgentResponse } | { error: string };
|
|
72
|
+
type Outcome = ({ res: AgentResponse } | { error: string }) & { usage?: UsageTotals };
|
|
72
73
|
interface RunningEntry {
|
|
73
|
-
/** Aborted by `
|
|
74
|
+
/** Aborted by `delete`; every attempt of the run listens to it. */
|
|
74
75
|
controller: AbortController;
|
|
75
76
|
done: Promise<void>;
|
|
76
|
-
|
|
77
|
+
deleted: boolean;
|
|
78
|
+
/** Started by `run_now` or `onMissed: "run"`: due when it finishes. */
|
|
79
|
+
manual: boolean;
|
|
80
|
+
/** A manual run that replaces a missed occurrence: always `late`. A plain `run_now` is never late. */
|
|
81
|
+
missed: boolean;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
80
85
|
|
|
86
|
+
// A run's exact spending as run-header fields, the numbers `usageReport()` keeps for workers (tokens split, cache hit rate, cost and where the price came from); in: the one-shot worker's totals over all attempts, out: finish's RunHeader.
|
|
87
|
+
function usageFields(totals: UsageTotals | undefined): Partial<RunHeader> {
|
|
88
|
+
if (!totals || totals.turns === 0) return {};
|
|
89
|
+
const u = usageLine(totals);
|
|
90
|
+
return {
|
|
91
|
+
turns: u.turns,
|
|
92
|
+
tokens: u.tokens.total,
|
|
93
|
+
inputTokens: u.tokens.input,
|
|
94
|
+
freshInputTokens: u.tokens.freshInput,
|
|
95
|
+
cacheReadTokens: u.tokens.cacheRead,
|
|
96
|
+
cacheWriteTokens: u.tokens.cacheWrite,
|
|
97
|
+
outputTokens: u.tokens.output,
|
|
98
|
+
thinkingTokens: u.tokens.thinking,
|
|
99
|
+
...(u.cacheHitRate !== null ? { cacheHitRate: Math.round(u.cacheHitRate * 10_000) / 10_000 } : {}),
|
|
100
|
+
costUsd: u.cost.totalUsd === null ? "unknown" : Number(u.cost.totalUsd.toFixed(6)),
|
|
101
|
+
costSource: u.cost.source,
|
|
102
|
+
...(u.cost.unpricedTurns > 0 ? { unpricedTurns: u.cost.unpricedTurns } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Whole seconds since the epoch: stored times have no milliseconds, so lateness is compared at this precision; in: a clock reading or stored time, out: finish and deliver.
|
|
107
|
+
const secondOf = (d: Date): number => Math.floor(d.getTime() / 1000);
|
|
108
|
+
|
|
81
109
|
function emit(code: string, message: string, data: Record<string, unknown>, level: "info" | "warn" = "info"): void {
|
|
82
110
|
telemetryBus.emit({ code: `scheduled_action.${code}`, message: `[Agent Accelerator] ${message}`, level, data });
|
|
83
111
|
}
|
|
@@ -141,6 +169,9 @@ function checkSchedule(
|
|
|
141
169
|
if (sa.retries !== undefined && !(Number.isInteger(sa.retries) && sa.retries >= 0)) {
|
|
142
170
|
problems.push("scheduled_action.retries: must be a whole number >= 0.");
|
|
143
171
|
}
|
|
172
|
+
if (sa.onMissed !== undefined && sa.onMissed !== "skip" && sa.onMissed !== "run") {
|
|
173
|
+
problems.push(`scheduled_action.onMissed: must be "skip" or "run" (omit it to decide later with run_now or skip); got ${JSON.stringify(sa.onMissed)}.`);
|
|
174
|
+
}
|
|
144
175
|
}
|
|
145
176
|
|
|
146
177
|
function checkOptionalFields(input: Pick<ScheduledActionPatch, "role" | "tools" | "timeoutMs">, pool: PoolTools | undefined, problems: string[]): void {
|
|
@@ -227,7 +258,7 @@ export class Scheduler {
|
|
|
227
258
|
this.swept = true;
|
|
228
259
|
this.startupSweep(now);
|
|
229
260
|
}
|
|
230
|
-
this.deliverDue(now
|
|
261
|
+
this.deliverDue(now);
|
|
231
262
|
this.startDue(now);
|
|
232
263
|
if (!this.hasPending()) this.stop();
|
|
233
264
|
} catch (err) {
|
|
@@ -277,7 +308,7 @@ export class Scheduler {
|
|
|
277
308
|
for (const t of this.snapshot().live) {
|
|
278
309
|
if (t.state === "cooking") this.recoverCooking(t);
|
|
279
310
|
else if (t.state === "waiting" && t.nextDeliverAt && parseLocalIso(t.nextDeliverAt).getTime() <= now.getTime()) {
|
|
280
|
-
this.markMissed(t.id, "process_down");
|
|
311
|
+
this.markMissed(t.id, "process_down", t.schedule.onMissed);
|
|
281
312
|
}
|
|
282
313
|
}
|
|
283
314
|
}
|
|
@@ -288,7 +319,7 @@ export class Scheduler {
|
|
|
288
319
|
const file = findRunFile(this.home, t.id, parseLocalIso(current.deliverAt), current.cookStartedAt);
|
|
289
320
|
const run = file ? readRun(this.home, file) : undefined;
|
|
290
321
|
if (!file || !run) {
|
|
291
|
-
this.markMissed(t.id, "interrupted");
|
|
322
|
+
this.markMissed(t.id, "interrupted", t.schedule.onMissed);
|
|
292
323
|
return;
|
|
293
324
|
}
|
|
294
325
|
updateDeliveries(this.home, (pending) => {
|
|
@@ -299,6 +330,7 @@ export class Scheduler {
|
|
|
299
330
|
status: run.header["status"] === "failed" ? "failed" : "ok",
|
|
300
331
|
readyAt: run.header["finishedAt"] ?? current.deliverAt,
|
|
301
332
|
deliverAt: current.deliverAt,
|
|
333
|
+
...(run.header["late"] === "true" ? { late: true } : {}),
|
|
302
334
|
});
|
|
303
335
|
});
|
|
304
336
|
updateTasks(this.home, (tasks) => {
|
|
@@ -309,7 +341,8 @@ export class Scheduler {
|
|
|
309
341
|
});
|
|
310
342
|
}
|
|
311
343
|
|
|
312
|
-
|
|
344
|
+
// Marks a run whose moment passed as missed, then applies the action's onMissed policy: `skip` moves on to the next time, `run` runs it now, unset waits for run_now / skip; in: the startup sweep, out: tasks.json + the missed (and skipped / started) events.
|
|
345
|
+
private markMissed(id: string, reason: string, policy?: "skip" | "run"): void {
|
|
313
346
|
let name = id;
|
|
314
347
|
updateTasks(this.home, (tasks) => {
|
|
315
348
|
const t = tasks.find((x) => x.id === id);
|
|
@@ -318,25 +351,28 @@ export class Scheduler {
|
|
|
318
351
|
t.state = "missed";
|
|
319
352
|
delete t.current;
|
|
320
353
|
});
|
|
321
|
-
|
|
354
|
+
const next = policy === "skip" ? "Skipping it (onMissed: skip)." : policy === "run" ? "Running it now (onMissed: run)." : "Use run_now or skip.";
|
|
355
|
+
emit("missed", `"${name}" (${id}) missed its run (${reason}). ${next}`, { id, name, reason, ...(policy ? { onMissed: policy } : {}) }, "warn");
|
|
356
|
+
if (policy === "skip") this.skip(id);
|
|
357
|
+
else if (policy === "run") this.startCook(id, true);
|
|
322
358
|
}
|
|
323
359
|
|
|
324
360
|
// ----- delivery ----------------------------------------------------------
|
|
325
361
|
|
|
326
|
-
private deliverDue(now: Date
|
|
362
|
+
private deliverDue(now: Date): void {
|
|
327
363
|
for (const entry of this.snapshot().pending) {
|
|
328
|
-
if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now
|
|
364
|
+
if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now);
|
|
329
365
|
}
|
|
330
366
|
}
|
|
331
367
|
|
|
332
|
-
|
|
333
|
-
private deliver(entry: PendingDelivery, now: Date
|
|
368
|
+
// Removes the entry, records `lastRun`, THEN emits (whether or not anyone listens); late = it replaced a missed occurrence, was delivered over two ticks after its time, or finished after it; in: deliverDue, out: tasks.json + the delivered event.
|
|
369
|
+
private deliver(entry: PendingDelivery, now: Date): void {
|
|
334
370
|
updateDeliveries(this.home, (pending) => {
|
|
335
371
|
const i = pending.findIndex((p) => p.id === entry.id && p.file === entry.file);
|
|
336
372
|
if (i >= 0) pending.splice(i, 1);
|
|
337
373
|
});
|
|
338
374
|
const deliverAt = parseLocalIso(entry.deliverAt).getTime();
|
|
339
|
-
const late =
|
|
375
|
+
const late = entry.late === true || now.getTime() - deliverAt > 2 * this.tickMs || parseLocalIso(entry.readyAt).getTime() > deliverAt;
|
|
340
376
|
let name = entry.id;
|
|
341
377
|
updateTasks(this.home, (tasks) => {
|
|
342
378
|
const t = tasks.find((x) => x.id === entry.id);
|
|
@@ -382,7 +418,7 @@ export class Scheduler {
|
|
|
382
418
|
*/
|
|
383
419
|
private startCook(id: string, manual: boolean): ScheduledActionRecord | undefined {
|
|
384
420
|
const now = this.now();
|
|
385
|
-
const ctx: { agent?: Agent; noAgent?: boolean } = {};
|
|
421
|
+
const ctx: { agent?: Agent; noAgent?: boolean; replacesMissed?: boolean } = {};
|
|
386
422
|
const started = updateTasks(this.home, (tasks) => {
|
|
387
423
|
const t = tasks.find((x) => x.id === id);
|
|
388
424
|
if (!t) return undefined;
|
|
@@ -392,6 +428,7 @@ export class Scheduler {
|
|
|
392
428
|
return undefined;
|
|
393
429
|
}
|
|
394
430
|
if (t.state === "cooking") return undefined;
|
|
431
|
+
ctx.replacesMissed = manual && t.state === "missed";
|
|
395
432
|
ctx.agent = this.agents.get(t.agent)?.deref();
|
|
396
433
|
if (!ctx.agent) {
|
|
397
434
|
t.state = "missed";
|
|
@@ -427,7 +464,7 @@ export class Scheduler {
|
|
|
427
464
|
cookStartedAt: started.current!.cookStartedAt,
|
|
428
465
|
deliverAt: started.current!.deliverAt,
|
|
429
466
|
});
|
|
430
|
-
this.launch(started, ctx.agent);
|
|
467
|
+
this.launch(started, ctx.agent, manual, ctx.replacesMissed === true);
|
|
431
468
|
return started;
|
|
432
469
|
}
|
|
433
470
|
|
|
@@ -458,8 +495,9 @@ export class Scheduler {
|
|
|
458
495
|
}
|
|
459
496
|
}
|
|
460
497
|
|
|
461
|
-
|
|
462
|
-
|
|
498
|
+
// Starts one run without awaiting it and tracks it until it ends; in: startCook, out: this.running + the run's files.
|
|
499
|
+
private launch(task: ScheduledActionRecord, agent: Agent, manual: boolean, missed: boolean): void {
|
|
500
|
+
const entry: RunningEntry = { controller: new AbortController(), done: Promise.resolve(), deleted: false, manual, missed };
|
|
463
501
|
this.running.set(task.id, entry);
|
|
464
502
|
entry.done = this.runWorker(task, agent, entry)
|
|
465
503
|
.catch((err) => {
|
|
@@ -476,8 +514,8 @@ export class Scheduler {
|
|
|
476
514
|
private async runWorker(task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<void> {
|
|
477
515
|
const deliverAt = parseLocalIso(task.current!.deliverAt);
|
|
478
516
|
const outcome = await this.execute(task, agent, entry, deliverAt);
|
|
479
|
-
if (entry.
|
|
480
|
-
this.finish(task, outcome, task.current!.cookStartedAt, deliverAt);
|
|
517
|
+
if (entry.deleted) return; // deleted: no run file, nothing delivered
|
|
518
|
+
this.finish(task, outcome, task.current!.cookStartedAt, deliverAt, entry);
|
|
481
519
|
}
|
|
482
520
|
|
|
483
521
|
/** A fresh stateless worker built from the saved fields; retries only while there is time before delivery. */
|
|
@@ -502,13 +540,13 @@ export class Scheduler {
|
|
|
502
540
|
const attempts = 1 + (task.schedule.retries ?? 0);
|
|
503
541
|
for (let attempt = 1; ; attempt++) {
|
|
504
542
|
try {
|
|
505
|
-
return { res: await this.attempt(worker, task, agent, entry) };
|
|
543
|
+
return { res: await this.attempt(worker, task, agent, entry), usage: readUsage(worker.usageTotals) };
|
|
506
544
|
} catch (err) {
|
|
507
|
-
if (entry.
|
|
545
|
+
if (entry.deleted || attempt >= attempts || this.now().getTime() >= deliverAt.getTime()) throw err;
|
|
508
546
|
}
|
|
509
547
|
}
|
|
510
548
|
} catch (err) {
|
|
511
|
-
return { error: errText(err) };
|
|
549
|
+
return { error: errText(err), ...(worker ? { usage: readUsage(worker.usageTotals) } : {}) };
|
|
512
550
|
} finally {
|
|
513
551
|
try {
|
|
514
552
|
worker?.reset();
|
|
@@ -519,14 +557,14 @@ export class Scheduler {
|
|
|
519
557
|
}
|
|
520
558
|
|
|
521
559
|
private async attempt(worker: Agent, task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<AgentResponse> {
|
|
522
|
-
const ms =
|
|
523
|
-
// One controller per attempt (the timeout is per attempt), fed by the entry's
|
|
560
|
+
const ms = scheduledRunTimeoutMs(agent.dynamicSubagents?.workerTimeoutMs ?? Infinity, task.timeoutMs);
|
|
561
|
+
// One controller per attempt (the timeout is per attempt), fed by the entry's delete controller.
|
|
524
562
|
const controller = new AbortController();
|
|
525
|
-
const
|
|
526
|
-
if (entry.controller.signal.aborted)
|
|
527
|
-
else entry.controller.signal.addEventListener("abort",
|
|
563
|
+
const onDelete = () => controller.abort(entry.controller.signal.reason ?? new Error("deleted"));
|
|
564
|
+
if (entry.controller.signal.aborted) onDelete();
|
|
565
|
+
else entry.controller.signal.addEventListener("abort", onDelete, { once: true });
|
|
528
566
|
const timer = ms > 0 ? setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms) : undefined;
|
|
529
|
-
// Raced so a timeout or
|
|
567
|
+
// Raced so a timeout or delete ends the attempt even if the provider ignores the signal.
|
|
530
568
|
const aborted = new Promise<never>((_, reject) => {
|
|
531
569
|
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new Error("aborted")), { once: true });
|
|
532
570
|
});
|
|
@@ -534,35 +572,33 @@ export class Scheduler {
|
|
|
534
572
|
return await Promise.race([worker.run(task.task, { signal: controller.signal }), aborted]);
|
|
535
573
|
} finally {
|
|
536
574
|
clearTimeout(timer);
|
|
537
|
-
entry.controller.signal.removeEventListener("abort",
|
|
575
|
+
entry.controller.signal.removeEventListener("abort", onDelete);
|
|
538
576
|
}
|
|
539
577
|
}
|
|
540
578
|
|
|
541
|
-
|
|
542
|
-
private finish(task: ScheduledActionRecord, outcome: Outcome, startedAt: string, deliverAt: Date): void {
|
|
579
|
+
// Writes a finished run: run file, then the `deliveries.json` entry, then `tasks.json` (a crash in between is repaired on start); a manual run is due when it finishes and is late only if it replaced a missed occurrence, a scheduled one is late only past its second; in: runWorker, out: the files and the ready/failed event.
|
|
580
|
+
private finish(task: ScheduledActionRecord, outcome: Outcome, startedAt: string, deliverAt: Date, run: Pick<RunningEntry, "manual" | "missed">): void {
|
|
543
581
|
const now = this.now();
|
|
544
582
|
const ok = "res" in outcome;
|
|
545
583
|
const finishedAt = toLocalIso(now);
|
|
584
|
+
const due = run.manual ? now : deliverAt;
|
|
585
|
+
const late = run.missed || secondOf(now) > secondOf(due);
|
|
546
586
|
const header: RunHeader = {
|
|
547
587
|
id: task.id,
|
|
548
588
|
name: task.name,
|
|
549
589
|
status: ok ? "ok" : "failed",
|
|
550
590
|
startedAt,
|
|
551
591
|
finishedAt,
|
|
552
|
-
deliverAt: toLocalIso(
|
|
553
|
-
late
|
|
554
|
-
...(ok
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
tokens: outcome.res.usage.totalTokens,
|
|
558
|
-
costUsd: outcome.res.usage.cost?.totalCost ?? "unknown",
|
|
559
|
-
}
|
|
560
|
-
: { error: outcome.error }),
|
|
592
|
+
deliverAt: toLocalIso(due),
|
|
593
|
+
late,
|
|
594
|
+
...(ok ? { model: `${outcome.res.provider}/${outcome.res.model}` } : {}),
|
|
595
|
+
...usageFields(outcome.usage),
|
|
596
|
+
...(ok ? {} : { error: outcome.error }),
|
|
561
597
|
};
|
|
562
598
|
const body = ok ? outcome.res.text.trim() || "[The worker returned no text.]" : `The run failed: ${outcome.error}`;
|
|
563
599
|
const file = writeRunFile(this.home, task.id, deliverAt, header, body);
|
|
564
600
|
updateDeliveries(this.home, (pending) => {
|
|
565
|
-
pending.push({ id: task.id, file, status: header.status, readyAt: finishedAt, deliverAt: header.deliverAt });
|
|
601
|
+
pending.push({ id: task.id, file, status: header.status, readyAt: finishedAt, deliverAt: header.deliverAt, ...(run.missed ? { late: true } : {}) });
|
|
566
602
|
});
|
|
567
603
|
updateTasks(this.home, (tasks) => {
|
|
568
604
|
const t = tasks.find((x) => x.id === task.id);
|
|
@@ -611,7 +647,7 @@ export class Scheduler {
|
|
|
611
647
|
return rec;
|
|
612
648
|
}
|
|
613
649
|
|
|
614
|
-
async
|
|
650
|
+
async delete(id: string, opts: { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
|
|
615
651
|
const rec = readTasks(this.home).find((t) => t.id === id);
|
|
616
652
|
if (!rec) return { ok: false, reason: "not_found" };
|
|
617
653
|
const run = this.running.get(id);
|
|
@@ -619,12 +655,12 @@ export class Scheduler {
|
|
|
619
655
|
return {
|
|
620
656
|
ok: false,
|
|
621
657
|
reason: "running",
|
|
622
|
-
message: `"${id}" is cooking right now, so nothing was changed. Ask the user whether to abort it; if they agree, call
|
|
658
|
+
message: `"${id}" is cooking right now, so nothing was changed. Ask the user whether to abort it; if they agree, call delete again with force: true.`,
|
|
623
659
|
};
|
|
624
660
|
}
|
|
625
661
|
if (run) {
|
|
626
|
-
run.
|
|
627
|
-
run.controller.abort(new Error("
|
|
662
|
+
run.deleted = true;
|
|
663
|
+
run.controller.abort(new Error("deleted"));
|
|
628
664
|
await run.done;
|
|
629
665
|
}
|
|
630
666
|
updateTasks(this.home, (tasks) => {
|
|
@@ -635,7 +671,7 @@ export class Scheduler {
|
|
|
635
671
|
for (let i = pending.length - 1; i >= 0; i--) if (pending[i]!.id === id) pending.splice(i, 1);
|
|
636
672
|
});
|
|
637
673
|
if (opts.purge) removeTaskFolder(this.home, id);
|
|
638
|
-
emit("
|
|
674
|
+
emit("deleted", `"${rec.name}" (${id}) was deleted.`, { id, name: rec.name, forced: !!run, purged: !!opts.purge });
|
|
639
675
|
return { ok: true };
|
|
640
676
|
}
|
|
641
677
|
}
|
|
@@ -712,6 +748,7 @@ export function createScheduledAction(agent: Agent, input: ScheduledActionInput)
|
|
|
712
748
|
startCookingInAdvance: lead,
|
|
713
749
|
...(sa.repeatEvery !== undefined ? { repeatEvery: sa.repeatEvery } : {}),
|
|
714
750
|
...(sa.retries !== undefined ? { retries: sa.retries } : {}),
|
|
751
|
+
...(sa.onMissed !== undefined ? { onMissed: sa.onMissed } : {}),
|
|
715
752
|
},
|
|
716
753
|
state: "waiting",
|
|
717
754
|
nextCookAt: toLocalIso(cookTime(deliverAt, durationMs(lead, "scheduled_action.startCookingInAdvance"))),
|
|
@@ -811,6 +848,6 @@ export function skipScheduledAction(id: string, opts?: HomeOptions): ScheduledAc
|
|
|
811
848
|
* `force: true` (the run is aborted, then deleted). Past run files are kept
|
|
812
849
|
* unless `purge: true`.
|
|
813
850
|
*/
|
|
814
|
-
export function
|
|
815
|
-
return schedulerFor(opts).
|
|
851
|
+
export function deleteScheduledAction(id: string, opts?: HomeOptions & { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
|
|
852
|
+
return schedulerFor(opts).delete(id, { force: opts?.force, purge: opts?.purge });
|
|
816
853
|
}
|
package/src/scheduler/store.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { telemetryBus } from "../telemetry.ts";
|
|
|
17
17
|
import { resolveHome } from "../utils/home.ts";
|
|
18
18
|
export { resolveHome };
|
|
19
19
|
import { ValidationError } from "../types/errors.ts";
|
|
20
|
+
import type { CostSource } from "../session/usage.ts";
|
|
20
21
|
import type { ScheduledActionRecord, ScheduledRunStatus } from "../types/scheduler.ts";
|
|
21
22
|
import { durationMs, runFileName } from "./time.ts";
|
|
22
23
|
|
|
@@ -33,6 +34,8 @@ export interface PendingDelivery {
|
|
|
33
34
|
status: ScheduledRunStatus;
|
|
34
35
|
readyAt: string;
|
|
35
36
|
deliverAt: string;
|
|
37
|
+
/** Set when the run replaced a missed occurrence: it is late whatever the clock says (a `run_now` on a `missed` action, or `onMissed: "run"`). */
|
|
38
|
+
late?: boolean;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** Header written at the top of every run file. */
|
|
@@ -46,9 +49,26 @@ export interface RunHeader {
|
|
|
46
49
|
/** The run finished after its delivery time. */
|
|
47
50
|
late: boolean;
|
|
48
51
|
model?: string;
|
|
52
|
+
/** Model calls the run made (all attempts), and what they spent: the same numbers `usageReport()` keeps for workers. */
|
|
53
|
+
turns?: number;
|
|
54
|
+
/** Input + output tokens. */
|
|
49
55
|
tokens?: number;
|
|
50
|
-
/**
|
|
56
|
+
/** Everything sent; `freshInputTokens + cacheReadTokens + cacheWriteTokens`. */
|
|
57
|
+
inputTokens?: number;
|
|
58
|
+
freshInputTokens?: number;
|
|
59
|
+
cacheReadTokens?: number;
|
|
60
|
+
cacheWriteTokens?: number;
|
|
61
|
+
/** Everything generated, thinking included. */
|
|
62
|
+
outputTokens?: number;
|
|
63
|
+
thinkingTokens?: number;
|
|
64
|
+
/** `cacheReadTokens / inputTokens`, 0 to 1; absent when nothing was sent. */
|
|
65
|
+
cacheHitRate?: number;
|
|
66
|
+
/** `unknown` when no turn had a known price (never a made-up 0). */
|
|
51
67
|
costUsd?: number | "unknown";
|
|
68
|
+
/** Where the price came from: the `provider`'s reported cost, the `catalog`, `mixed`, or `unknown`. */
|
|
69
|
+
costSource?: CostSource;
|
|
70
|
+
/** Turns with no known price; when present, `costUsd` is a lower bound. */
|
|
71
|
+
unpricedTurns?: number;
|
|
52
72
|
error?: string;
|
|
53
73
|
}
|
|
54
74
|
|
|
@@ -163,6 +183,7 @@ const recordSchema = z
|
|
|
163
183
|
startCookingInAdvance: z.string(),
|
|
164
184
|
repeatEvery: z.string().optional(),
|
|
165
185
|
retries: z.number().int().min(0).optional(),
|
|
186
|
+
onMissed: z.enum(["skip", "run"]).optional(),
|
|
166
187
|
}),
|
|
167
188
|
state: z.enum(["waiting", "cooking", "missed", "done"]),
|
|
168
189
|
nextCookAt: iso.nullable(),
|
|
@@ -195,6 +216,7 @@ const deliverySchema = z.object({
|
|
|
195
216
|
status: z.enum(["ok", "failed"]),
|
|
196
217
|
readyAt: iso,
|
|
197
218
|
deliverAt: iso,
|
|
219
|
+
late: z.boolean().optional(),
|
|
198
220
|
});
|
|
199
221
|
|
|
200
222
|
/** `undefined` when a `deliveries.json` entry is usable, else a one-line reason. Never throws. */
|
package/src/scheduler/tool.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ScheduledActionInput, ScheduledActionPatch } from "../types/schedu
|
|
|
14
14
|
import {
|
|
15
15
|
createScheduledAction,
|
|
16
16
|
getScheduler,
|
|
17
|
-
|
|
17
|
+
deleteScheduledAction,
|
|
18
18
|
listScheduledActions,
|
|
19
19
|
runScheduledAction,
|
|
20
20
|
skipScheduledAction,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { readRun } from "./store.ts";
|
|
25
25
|
import { formatDatetime } from "./time.ts";
|
|
26
26
|
|
|
27
|
-
const COMMANDS = ["create", "list", "get", "update", "
|
|
27
|
+
const COMMANDS = ["create", "list", "get", "update", "delete", "run_now", "skip"] as const;
|
|
28
28
|
|
|
29
29
|
let manual: string | undefined;
|
|
30
30
|
/** Read on first use (not at import), so a program that never enables scheduling never touches the file. */
|
|
@@ -50,9 +50,10 @@ const scheduledActionSchema = z.object({
|
|
|
50
50
|
startCookingInAdvance: z.string().optional().describe('Duration "DD:HH:MM" before delivery when the run starts.'),
|
|
51
51
|
repeatEvery: z.string().optional().describe('Duration "DD:HH:MM" between deliveries. Omit to run once.'),
|
|
52
52
|
retries: z.number().optional().describe("Extra attempts if a run fails."),
|
|
53
|
+
onMissed: z.enum(["skip", "run"]).optional().describe('If the host program was not running at delivery time: "skip" = move on to the next time, "run" = run when it is back. Omit to decide later.'),
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
const describedId = "Full action id including its 6-character suffix (get, update,
|
|
56
|
+
const describedId = "Full action id including its 6-character suffix (get, update, delete, run_now, skip).";
|
|
56
57
|
|
|
57
58
|
/** Builds the `scheduled_actions` tool for `agent`; its description is the manual with the worker pool filled in. */
|
|
58
59
|
export function createScheduledActionsTool(agent: Agent): ToolDefinition {
|
|
@@ -85,10 +86,10 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
|
|
|
85
86
|
instructions: z.string().optional().describe("The worker's system prompt (create, update)."),
|
|
86
87
|
task: z.string().optional().describe("The concrete assignment for every run (create, update)."),
|
|
87
88
|
tools: z.array(z.string()).optional().describe(`Worker tools to grant, from: ${poolNames.join(", ") || "none"}.`),
|
|
88
|
-
timeoutMs: z.number().optional().describe("
|
|
89
|
+
timeoutMs: z.number().optional().describe("Stop a run that takes longer than this many ms (> 0). Omit it for no limit of your own; the host program may cap it."),
|
|
89
90
|
scheduled_action: scheduledActionSchema.optional(),
|
|
90
|
-
force: z.boolean().optional().describe("
|
|
91
|
-
purge: z.boolean().optional().describe("
|
|
91
|
+
force: z.boolean().optional().describe("delete: abort a run that is cooking. Ask the user first."),
|
|
92
|
+
purge: z.boolean().optional().describe("delete: also delete past run files."),
|
|
92
93
|
}),
|
|
93
94
|
execute: async (a) => {
|
|
94
95
|
const sa = a.scheduled_action ?? {};
|
|
@@ -137,8 +138,8 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
|
|
|
137
138
|
}
|
|
138
139
|
return updateScheduledAction(id, patch, { home });
|
|
139
140
|
}
|
|
140
|
-
case "
|
|
141
|
-
return
|
|
141
|
+
case "delete":
|
|
142
|
+
return deleteScheduledAction(needId(), { home, force: a.force, purge: a.purge });
|
|
142
143
|
case "run_now":
|
|
143
144
|
return runScheduledAction(needId(), { home });
|
|
144
145
|
case "skip":
|
package/src/session/journal.ts
CHANGED
|
@@ -51,6 +51,8 @@ export class SessionJournal {
|
|
|
51
51
|
private lastRun: unknown;
|
|
52
52
|
/** JSON of the last journaled waiting prompts. */
|
|
53
53
|
private pending = EMPTY_PENDING;
|
|
54
|
+
/** JSON of the last journaled spending totals. */
|
|
55
|
+
private usage = "";
|
|
54
56
|
/** Run ids whose ending is on disk. */
|
|
55
57
|
private readonly endsOnDisk = new Set<string>();
|
|
56
58
|
/** How many of the current run's finished tool results are on disk, and the last one. */
|
|
@@ -90,6 +92,8 @@ export class SessionJournal {
|
|
|
90
92
|
if (meta !== this.meta) records.push(`{"t":"meta","m":${meta}}`);
|
|
91
93
|
const pending = this.pendingOf(agent);
|
|
92
94
|
if (pending !== this.pending) records.push(`{"t":"pending","p":${pending}}`);
|
|
95
|
+
const usage = this.usageOf(agent);
|
|
96
|
+
if (usage !== this.usage) records.push(`{"t":"usage","u":${usage}}`);
|
|
93
97
|
if (records.length === 0) return;
|
|
94
98
|
const text = records.join("\n") + "\n";
|
|
95
99
|
try {
|
|
@@ -110,6 +114,7 @@ export class SessionJournal {
|
|
|
110
114
|
this.runCount = runs.length;
|
|
111
115
|
this.lastRun = runs[runs.length - 1];
|
|
112
116
|
this.pending = pending;
|
|
117
|
+
this.usage = usage;
|
|
113
118
|
this.toolCount = tools.length;
|
|
114
119
|
this.lastTool = tools[tools.length - 1];
|
|
115
120
|
for (const mark of runs) if (mark.end) this.endsOnDisk.add(mark.id);
|
|
@@ -134,6 +139,7 @@ export class SessionJournal {
|
|
|
134
139
|
writeFileDurable(path.join(this.dir, JOURNAL_FILE), header, { backup: false });
|
|
135
140
|
this.epoch = epoch;
|
|
136
141
|
this.pending = pending;
|
|
142
|
+
this.usage = this.usageOf(agent); // the snapshot holds it
|
|
137
143
|
this.runs(agent);
|
|
138
144
|
this.count = messages.length;
|
|
139
145
|
this.last = messages[messages.length - 1];
|
|
@@ -167,9 +173,14 @@ export class SessionJournal {
|
|
|
167
173
|
return JSON.stringify({ steer: slim(waiting.steer), queue: slim(waiting.queue) } satisfies SessionPending);
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// JSON of the agent's spending totals (`null` before its first model call); in: the live agent, out: change detection + the `usage` record.
|
|
177
|
+
private usageOf(agent: SessionAgentLike): string {
|
|
178
|
+
return agent.usageTotals && agent.usageTotals.turns > 0 ? JSON.stringify(agent.usageTotals) : "null";
|
|
179
|
+
}
|
|
180
|
+
|
|
170
181
|
// JSON of everything in the snapshot that is not a message, identity or totals; in: the live agent, out: change detection + the `meta` record.
|
|
171
182
|
private metaOf(agent: SessionAgentLike): string {
|
|
172
|
-
const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, ...rest } = buildSessionData(
|
|
183
|
+
const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, usage: _u, ...rest } = buildSessionData(
|
|
173
184
|
{ ...agent, context: { ...agent.context, messages: [] } },
|
|
174
185
|
null
|
|
175
186
|
);
|
package/src/session/store.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type { SubAgentStep } from "../types/response.ts";
|
|
|
33
33
|
import { SessionLoadError } from "../types/errors.ts";
|
|
34
34
|
import { createSessionId } from "../utils/session.ts";
|
|
35
35
|
import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
|
|
36
|
+
import type { UsageTotals } from "./usage.ts";
|
|
36
37
|
|
|
37
38
|
/** Cumulative per-session token + cost totals (JSON-safe). */
|
|
38
39
|
export interface SessionTotals {
|
|
@@ -118,6 +119,8 @@ export interface PersistedAgentSession {
|
|
|
118
119
|
epoch?: string;
|
|
119
120
|
/** Where each run began, oldest first. */
|
|
120
121
|
runs?: SessionRunMark[];
|
|
122
|
+
/** What this agent spent on its own model calls (see `usage.ts`); the journal keeps it current with `usage` records. */
|
|
123
|
+
usage?: UsageTotals;
|
|
121
124
|
/** Read from the journal only (never in `session.json`): prompts that were still waiting. */
|
|
122
125
|
pending?: SessionPending;
|
|
123
126
|
/** Read from the journal only: results of tool calls finished in the current run (used to resume without repeating them). */
|
|
@@ -335,6 +338,8 @@ export interface SessionAgentLike {
|
|
|
335
338
|
pendingPrompts?: () => SessionPending;
|
|
336
339
|
/** Tool calls finished in the current run (cleared when the next run starts). */
|
|
337
340
|
toolRecords?: SessionToolRecord[];
|
|
341
|
+
/** What this agent spent on its own model calls. */
|
|
342
|
+
usageTotals?: UsageTotals;
|
|
338
343
|
context: { systemPrompt?: string; messages: Message[] };
|
|
339
344
|
}
|
|
340
345
|
|
|
@@ -482,6 +487,7 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
|
|
|
482
487
|
...(parentSessionId ? { parentSessionId } : {}),
|
|
483
488
|
...(subagents ? { subagents } : {}),
|
|
484
489
|
...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
|
|
490
|
+
...(agent.usageTotals && agent.usageTotals.turns > 0 ? { usage: JSON.parse(JSON.stringify(agent.usageTotals)) as UsageTotals } : {}),
|
|
485
491
|
};
|
|
486
492
|
}
|
|
487
493
|
|
|
@@ -671,6 +677,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
671
677
|
subagents: obj.subagents,
|
|
672
678
|
...(typeof obj.epoch === "string" ? { epoch: obj.epoch } : {}),
|
|
673
679
|
...(Array.isArray(obj.runs) ? { runs: obj.runs as SessionRunMark[] } : {}),
|
|
680
|
+
...(obj.usage && typeof obj.usage === "object" ? { usage: obj.usage as UsageTotals } : {}),
|
|
674
681
|
};
|
|
675
682
|
} catch {
|
|
676
683
|
return null;
|
|
@@ -985,7 +992,7 @@ export const JOURNAL_FILE = "journal.ndjson";
|
|
|
985
992
|
// Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
|
|
986
993
|
const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
|
|
987
994
|
|
|
988
|
-
// Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
|
|
995
|
+
// Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `usage` replaces the agent's spending totals, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
|
|
989
996
|
function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
|
|
990
997
|
if (!snapshot.epoch) return snapshot;
|
|
991
998
|
const lines = text.split("\n");
|
|
@@ -997,7 +1004,7 @@ function replayJournal(snapshot: PersistedAgentSession, text: string, file: stri
|
|
|
997
1004
|
for (let k = 0; k < lines.length; k++) {
|
|
998
1005
|
const line = lines[k]!.trim();
|
|
999
1006
|
if (!line) continue;
|
|
1000
|
-
let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
|
|
1007
|
+
let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; u?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
|
|
1001
1008
|
try {
|
|
1002
1009
|
rec = JSON.parse(line);
|
|
1003
1010
|
} catch {
|
|
@@ -1023,6 +1030,8 @@ function replayJournal(snapshot: PersistedAgentSession, text: string, file: stri
|
|
|
1023
1030
|
if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
|
|
1024
1031
|
} else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
|
|
1025
1032
|
session.tools = [...(session.tools ?? []).filter((x) => x.id !== rec.call), { id: rec.call, name: rec.name, result: rec.result, ...(rec.isError ? { isError: true } : {}), ...(rec.stopped ? { stopped: true } : {}) }];
|
|
1033
|
+
} else if (rec.t === "usage" && rec.u && typeof rec.u === "object") {
|
|
1034
|
+
session.usage = rec.u as UsageTotals; // latest record wins
|
|
1026
1035
|
} else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
|
|
1027
1036
|
const p = rec.p as SessionPending; // latest record wins
|
|
1028
1037
|
if (Array.isArray(p.steer) && Array.isArray(p.queue)) {
|
|
@@ -1328,15 +1337,16 @@ function toDollars(microUSD: number): number {
|
|
|
1328
1337
|
* `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
|
|
1329
1338
|
* `pricing.*` (falling back to legacy `cost.*`) applies.
|
|
1330
1339
|
*/
|
|
1340
|
+
// Cache prices stay undefined when the catalog has none: priceUsage then bills those tokens at the input rate (never free).
|
|
1331
1341
|
function resolveEffectivePricing(
|
|
1332
1342
|
spec: ModelSpec | undefined,
|
|
1333
1343
|
inputTokens: number
|
|
1334
|
-
): { input: number; output: number; cacheRead: number; cacheWrite: number; audio: number; tier: string } {
|
|
1344
|
+
): { input: number; output: number; cacheRead: number | undefined; cacheWrite: number | undefined; audio: number; tier: string } {
|
|
1335
1345
|
const base = {
|
|
1336
1346
|
input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
|
|
1337
1347
|
output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
|
|
1338
|
-
cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined)
|
|
1339
|
-
cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined)
|
|
1348
|
+
cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined),
|
|
1349
|
+
cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined),
|
|
1340
1350
|
audio:
|
|
1341
1351
|
spec?.pricing?.inputAudioPerMillion ?? (spec?.cost as { input_audio?: number } | undefined)?.input_audio ?? 0,
|
|
1342
1352
|
};
|
|
@@ -1388,8 +1398,9 @@ function resolveEffectivePricing(
|
|
|
1388
1398
|
* - Audio tokens (`usage.audioTokens` / `usage.inputAudioTokens` when present)
|
|
1389
1399
|
* price at `pricing.inputAudioPerMillion` / `cost.input_audio` and fold into
|
|
1390
1400
|
* the total (separately itemized in `breakdown.audioMicroUSD`).
|
|
1391
|
-
* - `opts.serviceTier` (`flex`
|
|
1401
|
+
* - `opts.serviceTier` (`flex` 0.5x, `priority` 2x, estimates) scales the
|
|
1392
1402
|
* catalog total; provider-reported costs are never rescaled.
|
|
1403
|
+
* - Cache reads and writes are subsets of the input tokens; a missing cache price bills at the input rate.
|
|
1393
1404
|
*/
|
|
1394
1405
|
export function priceUsage(
|
|
1395
1406
|
usage: TokenUsage,
|
|
@@ -1438,12 +1449,15 @@ export function priceUsage(
|
|
|
1438
1449
|
return undefined;
|
|
1439
1450
|
}
|
|
1440
1451
|
const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
|
|
1441
|
-
|
|
1452
|
+
// Cache reads and writes are both a SUBSET of the input tokens (OpenRouter `prompt_tokens` includes them), so the
|
|
1453
|
+
// plain input rate applies only to what is neither read from nor written to the cache.
|
|
1454
|
+
const freshInput = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens);
|
|
1442
1455
|
// Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
|
|
1443
1456
|
// ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
const
|
|
1457
|
+
// A catalog entry without a cache price bills those tokens at the input rate: cache reads/writes are never free.
|
|
1458
|
+
const inputMicroUSD = Math.round(freshInput * pricing.input * multiplier);
|
|
1459
|
+
const cacheReadMicroUSD = Math.round(cachedTokens * (pricing.cacheRead ?? pricing.input) * multiplier);
|
|
1460
|
+
const cacheWriteMicroUSD = Math.round(cacheWriteTokens * (pricing.cacheWrite ?? pricing.input) * multiplier);
|
|
1447
1461
|
const outputMicroUSD = Math.round(outputTokens * pricing.output * multiplier);
|
|
1448
1462
|
const audioMicroUSD = Math.round(audioCount * pricing.audio * multiplier);
|
|
1449
1463
|
const totalMicroUSD = inputMicroUSD + cacheReadMicroUSD + cacheWriteMicroUSD + outputMicroUSD + audioMicroUSD;
|