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.
@@ -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, normalizeWorkerTimeoutValue } from "../agent/worker.ts";
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
- KillResult,
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 `kill`; every attempt of the run listens to it. */
74
+ /** Aborted by `delete`; every attempt of the run listens to it. */
74
75
  controller: AbortController;
75
76
  done: Promise<void>;
76
- killed: boolean;
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, first);
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
- private markMissed(id: string, reason: string): void {
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
- emit("missed", `"${name}" (${id}) missed its run (${reason}). Use run_now or skip.`, { id, name, reason }, "warn");
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, first: boolean): void {
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, first);
364
+ if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now);
329
365
  }
330
366
  }
331
367
 
332
- /** Removes the entry, records `lastRun`, THEN emits (whether or not anyone listens). */
333
- private deliver(entry: PendingDelivery, now: Date, first: boolean): void {
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 = first || now.getTime() - deliverAt > 2 * this.tickMs || parseLocalIso(entry.readyAt).getTime() > deliverAt;
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
- private launch(task: ScheduledActionRecord, agent: Agent): void {
462
- const entry: RunningEntry = { controller: new AbortController(), done: Promise.resolve(), killed: false };
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.killed) return; // killed: no run file, nothing delivered
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.killed || attempt >= attempts || this.now().getTime() >= deliverAt.getTime()) throw err;
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 = normalizeWorkerTimeoutValue(agent.dynamicSubagents?.timeout ?? 0, task.timeoutMs);
523
- // One controller per attempt (the timeout is per attempt), fed by the entry's kill controller.
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 kill = () => controller.abort(entry.controller.signal.reason ?? new Error("killed"));
526
- if (entry.controller.signal.aborted) kill();
527
- else entry.controller.signal.addEventListener("abort", kill, { once: true });
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 kill ends the attempt even if the provider ignores the signal.
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", kill);
575
+ entry.controller.signal.removeEventListener("abort", onDelete);
538
576
  }
539
577
  }
540
578
 
541
- /** Write order: run file, then the `deliveries.json` entry, then `tasks.json`. A crash in between is repaired on start. */
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(deliverAt),
553
- late: now.getTime() > deliverAt.getTime(),
554
- ...(ok
555
- ? {
556
- model: `${outcome.res.provider}/${outcome.res.model}`,
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 kill(id: string, opts: { force?: boolean; purge?: boolean }): Promise<KillResult> {
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 kill again with force: true.`,
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.killed = true;
627
- run.controller.abort(new Error("killed"));
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("killed", `"${rec.name}" (${id}) was killed.`, { id, name: rec.name, forced: !!run, purged: !!opts.purge });
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 killScheduledAction(id: string, opts?: HomeOptions & { force?: boolean; purge?: boolean }): Promise<KillResult> {
815
- return schedulerFor(opts).kill(id, { force: opts?.force, purge: opts?.purge });
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
  }
@@ -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
- /** `unknown` when the model has no known pricing (never a made-up 0). */
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. */
@@ -14,7 +14,7 @@ import type { ScheduledActionInput, ScheduledActionPatch } from "../types/schedu
14
14
  import {
15
15
  createScheduledAction,
16
16
  getScheduler,
17
- killScheduledAction,
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", "kill", "run_now", "skip"] as const;
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, kill, run_now, skip).";
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("Per-run time limit in ms (> 0)."),
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("kill: abort a run that is cooking. Ask the user first."),
91
- purge: z.boolean().optional().describe("kill: also delete past run files."),
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 "kill":
141
- return killScheduledAction(needId(), { home, force: a.force, purge: a.purge });
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":
@@ -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
  );
@@ -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) ?? 0,
1339
- cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined) ?? 0,
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` ~0.5x, `priority` ~1.5x, estimates) scales the
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
- const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
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
- const inputMicroUSD = Math.round(nonCachedInput * pricing.input * multiplier);
1445
- const cacheReadMicroUSD = Math.round(cachedTokens * pricing.cacheRead * multiplier);
1446
- const cacheWriteMicroUSD = Math.round(cacheWriteTokens * pricing.cacheWrite * multiplier);
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;