agent-accelerator 0.5.4 → 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":
@@ -14,6 +14,8 @@ export interface EventStreamOptions {
14
14
  includePartial?: boolean;
15
15
  /** Max buffered events when no iterator is attached. Default 1000. Oldest dropped. */
16
16
  maxBufferedEvents?: number;
17
+ /** `false`: events go to listeners and waiting iterators only, never into the buffer (for a caller that only wants `result()`). Default true. */
18
+ buffer?: boolean;
17
19
  /** Max approx bytes for a single delta event. Default 1MB. Oversized dropped. */
18
20
  maxEventBytes?: number;
19
21
  }
@@ -61,6 +63,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
61
63
  private resolveFinalResult!: (result: AgentResponse) => void;
62
64
  private rejectFinalResult!: (err: Error) => void;
63
65
  private readonly includePartial: boolean;
66
+ private readonly buffered: boolean;
64
67
  private readonly maxBufferedEvents: number;
65
68
  private readonly maxEventBytes: number;
66
69
  /** First listener exception captured for inspection (Q-29/Q-41). */
@@ -80,6 +83,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
80
83
  */
81
84
  constructor(opts?: EventStreamOptions) {
82
85
  this.includePartial = opts?.includePartial !== false;
86
+ this.buffered = opts?.buffer !== false;
83
87
  this.maxBufferedEvents =
84
88
  typeof opts?.maxBufferedEvents === "number" && Number.isFinite(opts.maxBufferedEvents) && opts.maxBufferedEvents > 0
85
89
  ? Math.floor(opts.maxBufferedEvents)
@@ -151,8 +155,9 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
151
155
  } else {
152
156
  // No waiter right now (typical between `next()` polls of a burst):
153
157
  // buffer for the iterator with a hard cap (Q-29). Overflow drops the
154
- // oldest with an anomaly report — see `enqueueBounded`.
155
- this.enqueueBounded(out);
158
+ // oldest with an anomaly report — see `enqueueBounded`. A stream built
159
+ // with `buffer: false` has no iterator to feed and keeps nothing.
160
+ if (this.buffered) this.enqueueBounded(out);
156
161
  }
157
162
  }
158
163
 
@@ -422,6 +422,21 @@ function waitForRetry(ms: number, signal?: AbortSignal): Promise<void> {
422
422
  });
423
423
  }
424
424
 
425
+ // Settles with `work`, or rejects "Tool execution aborted" the moment `signal` aborts, without waiting for a tool that never looks at its signal (it finishes in the background, its result is dropped); in: executeAttempt, out: the attempt's result or error.
426
+ function raceAbort<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
427
+ if (!signal) return work;
428
+ return new Promise<T>((resolve, reject) => {
429
+ const onAbort = () => reject(new Error("Tool execution aborted"));
430
+ if (signal.aborted) {
431
+ work.catch(() => {}); // the abandoned tool may still fail later: nobody listens
432
+ onAbort();
433
+ return;
434
+ }
435
+ signal.addEventListener("abort", onAbort, { once: true });
436
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
437
+ });
438
+ }
439
+
425
440
  async function executeAttempt(
426
441
  toolDef: ToolDefinition,
427
442
  parsedInput: unknown,
@@ -429,7 +444,7 @@ async function executeAttempt(
429
444
  timeoutMs: number | undefined
430
445
  ): Promise<unknown> {
431
446
  if (!timeoutMs || timeoutMs <= 0) {
432
- return toolDef.execute(parsedInput as any, context);
447
+ return raceAbort(Promise.resolve(toolDef.execute(parsedInput as any, context)), context.signal);
433
448
  }
434
449
 
435
450
  const controller = new AbortController();
@@ -453,7 +468,7 @@ async function executeAttempt(
453
468
  }, timeoutMs);
454
469
  });
455
470
  const result = await Promise.race([
456
- Promise.resolve(toolDef.execute(parsedInput as any, executionContext)),
471
+ raceAbort(Promise.resolve(toolDef.execute(parsedInput as any, executionContext)), context.signal),
457
472
  timeout,
458
473
  ]);
459
474
  // A resolved promise can beat a timer callback even when the event loop
package/src/tools/tool.ts CHANGED
@@ -61,22 +61,6 @@ export function resolveWhenBusy(
61
61
  return v === "steer" || v === "queue" ? v : "auto";
62
62
  }
63
63
 
64
- /**
65
- * Maps the Q-52 canonical worker-timeout knobs to the legacy `timeout` value
66
- * (`0` = no limit, `-1` = model sets per-task `timeoutMs`, `>0` = fixed ms).
67
- * An explicit `timeout`/`workerTimeoutMs` wins over `modelMaySetTimeout`.
68
- *
69
- * @example `normalizeWorkerTimeout({ modelMaySetTimeout: true }); // -1`
70
- */
71
- export function normalizeWorkerTimeout(
72
- config?: { timeout?: number; workerTimeoutMs?: number; modelMaySetTimeout?: boolean }
73
- ): number {
74
- const explicit = config?.workerTimeoutMs ?? config?.timeout;
75
- if (Number.isFinite(explicit as number)) return Math.floor(explicit as number);
76
- if (config?.modelMaySetTimeout === true) return -1;
77
- return 0;
78
- }
79
-
80
64
  /**
81
65
  * Normalizes the Q-52 `maxAttempts` alias to a `maxTries` value.
82
66
  * `maxTries` wins when both are set.
@@ -121,7 +121,8 @@ export interface AgentConfig {
121
121
  * - `{ mode: "steer" }`: only steer (inject into current turn) is allowed.
122
122
  * - `{ mode: "queue" }`: only queue (next-turn follow-up) is allowed.
123
123
  * - `{ mode: "auto" }` (default): caller picks per message via `enqueue` or `steer()`/`queue()`.
124
- * `interrupt` (cancel stream now) is always available via `interrupt()`/`cancel()`/`AbortSignal`.
124
+ * `interrupt` (stop the active run now, optionally start a new message) is always available, whatever the mode:
125
+ * `interrupt()`, `interrupt(message)`, `enqueue: "interrupt"`, `cancel()` and `AbortSignal`.
125
126
  */
126
127
  midSession?: MidSessionConfig;
127
128
  /**
@@ -227,6 +228,25 @@ export interface AgentConfig {
227
228
  budget?: Budget;
228
229
  /** Run + network limits (Q-22). Turn/budget caps unlimited by default; network timeouts finite. */
229
230
  limits?: AgentRunLimits;
231
+ /**
232
+ * The only models this agent may use, as `provider/model` strings compared as written. Unset (default) = any model.
233
+ * Checked when the Agent is built (also for a config an agent edited through {@link Agent.fromConfig}); a model
234
+ * outside the list throws a `ConfigError`. Workers and `subagentModel` are not affected.
235
+ */
236
+ allowedModels?: string[];
237
+ }
238
+
239
+ /**
240
+ * The part of an agent that an agent may edit (self-improvement): see {@link Agent.toConfig} and {@link Agent.fromConfig}.
241
+ * Plain JSON. Every other setting (limits, budget, cache, thinking, keys, ...) stays with the developer.
242
+ */
243
+ export interface EditableAgentConfig {
244
+ /** `provider/model`; must be in `allowedModels` when the developer set it. */
245
+ model?: string;
246
+ /** System instructions. */
247
+ instructions?: string;
248
+ /** Names of tools, picked from the developer's tool pool (`base.tools` of `fromConfig`). */
249
+ tools?: string[];
230
250
  }
231
251
 
232
252
  /** A run that a crash cut off (see {@link Agent.interrupted}). */
@@ -246,6 +266,7 @@ export interface AgentRunLimits {
246
266
  deadlineMs?: number;
247
267
  maxTotalTokens?: number;
248
268
  maxToolCallsPerRun?: number;
269
+ /** Network timeouts in ms. Defaults 300 000 / 120 000 / 300 000. `0` or `Infinity` turns one off. */
249
270
  requestTimeoutMs?: number;
250
271
  firstByteTimeoutMs?: number;
251
272
  streamIdleTimeoutMs?: number;
@@ -288,7 +309,7 @@ export interface DynamicSubagentsConfig {
288
309
  allowScheduledActions?: boolean;
289
310
  /** Developer-only worker model override. Never choosable by the Main Agent LLM. Falls back to top-level `subagentModel`, then `SUB_AGENT_MODEL` env, then the parent model. */
290
311
  model?: string | ModelSpec | ModelProviderInstance;
291
- /** Max workers that may be open at once on this agent (the model must close one before spawning another). Defaults to 4. */
312
+ /** Max workers that may be open at once on this agent (the model must close one before spawning another). Default: no limit; set a whole number >= 1 to cap it. */
292
313
  maxSpawn?: number;
293
314
  /** Fixed reasoning level for all dynamic workers. The Main Agent cannot override it. Falls back to the parent level when omitted. */
294
315
  thinkingLevel?: ThinkingLevel;
@@ -300,27 +321,19 @@ export interface DynamicSubagentsConfig {
300
321
  */
301
322
  workerBudget?: Pick<Budget, "maxCostUsd" | "maxTotalTokens">;
302
323
  /**
303
- * How many levels of workers may exist below this agent (default 3). `1` = workers cannot start workers of their own;
304
- * `3` = a worker may start workers that start workers. A worker with levels left gets its own `subagents` tool, and
305
- * its children can only be granted the tools the worker itself was granted. Whole number >= 1.
324
+ * How many levels of workers may exist below this agent. Default: no limit. `1` = workers cannot start workers of their
325
+ * own; `3` = a worker may start workers that start workers. A worker with levels left gets its own `subagents` tool, and
326
+ * its children can only be granted the tools the worker itself was granted. A whole number >= 1, or omit it for no limit.
306
327
  */
307
328
  maxDepth?: number;
308
329
  /** Tool pool available to dynamic workers. Workers receive zero tools unless the Main Agent grants a per-task `tools` subset by name. */
309
330
  tools?: Record<string, ToolDefinition> | ToolDefinition[];
310
- /** Per-run timeout in ms of a SCHEDULED ACTION's worker (the `subagents` tool and `agent.workers` have no timeouts). `0` (default) = no limit. `-1` = the action sets its own `timeoutMs`. `>0` = fixed. */
311
- timeout?: number;
312
331
  /**
313
- * Canonical timeout in ms for scheduled-action workers (Q-52, preferred over `timeout`).
314
- * Maps to `timeout`: `workerTimeoutMs ?? timeout ?? 0`. See
315
- * `normalizeWorkerTimeout` (re-exported from the root entry).
332
+ * Longest a SCHEDULED ACTION's run may take, in ms (the `subagents` tool and `agent.workers` have no timeouts). An
333
+ * action can set its own shorter `timeoutMs`; this is the ceiling for it, and the limit for actions that set none.
334
+ * Default: no limit (`0` and `Infinity` mean the same).
316
335
  */
317
336
  workerTimeoutMs?: number;
318
- /**
319
- * Scheduled actions only: when true, each action sets its own `timeoutMs` (maps to
320
- * `timeout: -1`). When both are set, an explicit `timeout`/`workerTimeoutMs`
321
- * wins over `modelMaySetTimeout`.
322
- */
323
- modelMaySetTimeout?: boolean;
324
337
  }
325
338
 
326
339
  /** Per-run overrides for {@link Agent.run}, {@link Agent.ask}, and {@link Agent.stream}. */
@@ -346,8 +359,10 @@ export interface AgentRunOptions {
346
359
  * enforces `"steer"` or `"queue"`, that policy wins and this field is ignored.
347
360
  * When idle, this field is ignored and the call runs immediately.
348
361
  * Defaults to `"queue"` (finish current turn, run as next turn).
362
+ * `"interrupt"` is honored in every mode: it stops the active run (provider request, tools, workers) and starts this
363
+ * prompt as the next run, ahead of queued follow-ups.
349
364
  */
350
- enqueue?: "steer" | "queue";
365
+ enqueue?: "steer" | "queue" | "interrupt";
351
366
  /** Headers merged for this run only. */
352
367
  headers?: Record<string, string>;
353
368
  /** Called for each text delta as it streams (only when stream:true). */