agent-accelerator 0.5.4 → 0.5.6

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.
@@ -9,6 +9,8 @@ import { toConciseProviderError } from "../utils/errors.ts";
9
9
  import { withRetries } from "../utils/retry.ts";
10
10
  import { redactHeadersCentral } from "../utils/cache.ts";
11
11
  import { TRUNCATED_ARGS_KEY, isTruncatedArguments } from "../types/tool.ts";
12
+ import { SSEParser } from "../streaming/sse-parser.ts";
13
+ import type { AssistantMessageEventStream } from "../streaming/event-stream.ts";
12
14
 
13
15
  /** Parses a JSON tool-argument string; non-objects degrade to the truncated-arguments marker. */
14
16
  export function parseArguments(raw: string | undefined): Record<string, unknown> {
@@ -43,17 +45,17 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
43
45
  }
44
46
 
45
47
  /**
46
- * Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
47
- * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
48
+ * Extracts `{ message, code, errorType, raw, limitSource }` from a non-2xx JSON body. Handles
49
+ * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw` + `metadata.limit_source`, and Google
48
50
  * `status` code shapes; unknown bodies degrade to a 300-char slice.
49
51
  */
50
52
  export function readErrorPayload(
51
53
  bodyText: string
52
- ): { message: string; code?: string | number; errorType?: string; raw?: string } {
54
+ ): { message: string; code?: string | number; errorType?: string; raw?: string; limitSource?: string } {
53
55
  try {
54
56
  const parsed: unknown = JSON.parse(bodyText);
55
57
  const first = Array.isArray(parsed) ? parsed[0] : parsed;
56
- const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
58
+ const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown } } })?.error;
57
59
  if (err && typeof err.message === "string") {
58
60
  const code = err.code ?? err.status;
59
61
  const errorType =
@@ -67,6 +69,7 @@ export function readErrorPayload(
67
69
  ...(code !== undefined ? { code } : {}),
68
70
  ...(typeof errorType === "string" ? { errorType } : {}),
69
71
  ...(raw ? { raw } : {}),
72
+ ...(typeof err.metadata?.limit_source === "string" ? { limitSource: err.metadata.limit_source } : {}),
70
73
  };
71
74
  }
72
75
  return { message: bodyText.slice(0, 300) };
@@ -135,11 +138,11 @@ export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { sign
135
138
  if (signal) {
136
139
  if (signal.aborted) {
137
140
  cleanup();
138
- ctrl.abort();
141
+ ctrl.abort(signal.reason); // the caller's reason survives (`interrupt(new Error("..."))`)
139
142
  } else {
140
143
  signal.addEventListener("abort", () => {
141
144
  cleanup();
142
- try { ctrl.abort(); } catch {}
145
+ try { ctrl.abort(signal.reason); } catch {}
143
146
  }, { once: true });
144
147
  }
145
148
  }
@@ -180,6 +183,12 @@ export function hasTruncatedToolArguments(args: unknown): boolean {
180
183
  return isTruncatedArguments(args);
181
184
  }
182
185
 
186
+ // True when a stream with no terminal event left a tool call whose arguments never fully arrived (none at all, or cut mid-JSON); in: the adapter's assembled calls and their records, out: its incomplete_stream guard.
187
+ export function hasUnfinishedToolCall(calls: Iterable<{ startArgs: string; deltaArgs: string }>, toolCalls: Array<{ arguments: unknown }>): boolean {
188
+ for (const c of calls) if (!c.startArgs.trim() && !c.deltaArgs.trim()) return true;
189
+ return toolCalls.some((c) => isTruncatedArguments(c.arguments));
190
+ }
191
+
183
192
  /** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
184
193
  export function shortHashId(value: string): string {
185
194
  let h = 0x811c9dc5;
@@ -198,7 +207,10 @@ export function shortHashId(value: string): string {
198
207
  export function clampToolCallId(id: string): string {
199
208
  const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
200
209
  if (!clean.includes("|")) {
201
- return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
210
+ if (clean.length <= 40) return clean || "call_unknown";
211
+ // A hard cut would give two long ids with the same first 40 characters the same id; a hash of the whole id keeps them apart.
212
+ const hash = shortHashId(id);
213
+ return `${clean.slice(0, 40 - hash.length - 1)}_${hash}`;
202
214
  }
203
215
  const sep = clean.indexOf("|");
204
216
  const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
@@ -210,6 +222,16 @@ export function clampToolCallId(id: string): string {
210
222
  return `${prefix}_${hash}`;
211
223
  }
212
224
 
225
+ // The finish reason of a turn that produced tool calls: `tool_calls`, except that a turn cut off by the output limit (`length`) keeps saying so when one of its calls was cut mid-JSON; in: the wire finish reason + the assembled calls, out: each streaming adapter.
226
+ export function toolTurnFinishReason(finishReason: string, toolCalls: Array<{ arguments: unknown }>): string {
227
+ return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments)) ? "length" : "tool_calls";
228
+ }
229
+
230
+ // The JSON text of a tool call's arguments for a history message: `{}` for a call whose streamed arguments were cut off (the internal `__truncated_raw` marker was never the model's own output and is never resent); in: a stored tool call, out: each adapter's history builder.
231
+ export function toolArgumentsJson(args: unknown): string {
232
+ return isTruncatedArguments(args) ? "{}" : JSON.stringify(args || {});
233
+ }
234
+
213
235
  /** Plain lower-cased header record from a fetch `Headers` object. */
214
236
  export function headersToRecord(headers: Headers): Record<string, string> {
215
237
  const out: Record<string, string> = {};
@@ -282,3 +304,110 @@ export function retryOptionsFrom(options: {
282
304
  retryBudgetMs: options?.retryBudgetMs,
283
305
  };
284
306
  }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Streaming scaffold shared by the SSE adapters (abort linking, request timer, read loop, end-of-stream check, failure)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ // Ties a provider stream to the caller's signal and to the stream's own cancel: one controller that aborts when either does; in: an adapter's stream(), out: the controller its request uses and the `release` its finally block calls.
313
+ export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal: AbortSignal | undefined): { linked: AbortController; release: () => void } {
314
+ const linked = new AbortController();
315
+ const forwardUserAbort = (): void => {
316
+ try {
317
+ linked.abort((signal as { reason?: unknown } | undefined)?.reason);
318
+ } catch {
319
+ try {
320
+ linked.abort();
321
+ } catch {}
322
+ }
323
+ };
324
+ if (signal?.aborted) forwardUserAbort();
325
+ else signal?.addEventListener("abort", forwardUserAbort, { once: true });
326
+ const removeStreamCancel = eventStream.onCancel(() => {
327
+ try {
328
+ linked.abort();
329
+ } catch {}
330
+ });
331
+ return {
332
+ linked,
333
+ release: () => {
334
+ try {
335
+ signal?.removeEventListener("abort", forwardUserAbort);
336
+ } catch {}
337
+ try {
338
+ removeStreamCancel();
339
+ } catch {}
340
+ },
341
+ };
342
+ }
343
+
344
+ // Runs one request attempt under a timer that aborts `controller` after `ms` (none when unset); in: an adapter's fetchOnce, out: its Response.
345
+ export async function withAbortTimer<T>(controller: AbortController, ms: number | undefined, run: () => Promise<T>): Promise<T> {
346
+ const timer =
347
+ ms && ms > 0
348
+ ? setTimeout(() => {
349
+ try {
350
+ controller.abort();
351
+ } catch {}
352
+ }, ms)
353
+ : undefined;
354
+ try {
355
+ return await run();
356
+ } finally {
357
+ if (timer) clearTimeout(timer);
358
+ }
359
+ }
360
+
361
+ // Reads an SSE response body to its end, handing each `data:` payload to `onData`; stops with an AbortError as soon as the stream is aborted or cancelled (cancelling the body, whose rejection is absorbed); in: the opened Response body of an SSE adapter, out: calls to onData.
362
+ export async function readSseData(
363
+ body: ReadableStream<Uint8Array>,
364
+ linked: AbortController,
365
+ eventStream: AssistantMessageEventStream,
366
+ onData: (data: string) => void
367
+ ): Promise<void> {
368
+ const parser = new SSEParser();
369
+ const reader = body.getReader();
370
+ const decoder = new TextDecoder();
371
+ const abort = (): Error => Object.assign(new Error("Stream aborted"), { name: "AbortError" });
372
+ linked.signal.addEventListener("abort", () => void reader.cancel().catch(() => {}), { once: true }); // an aborted body is already errored: its cancel() rejects, and nobody else listens
373
+ while (true) {
374
+ if (linked.signal.aborted || eventStream.isCancelled()) {
375
+ try {
376
+ await reader.cancel();
377
+ } catch {}
378
+ throw abort();
379
+ }
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+ for (const m of parser.feed(decoder.decode(value, { stream: true }))) onData(m.data);
383
+ }
384
+ for (const m of parser.flush()) onData(m.data);
385
+ try {
386
+ reader.releaseLock();
387
+ } catch {}
388
+ if (linked.signal.aborted || eventStream.isCancelled()) throw abort();
389
+ }
390
+
391
+ // The error to fail with when an SSE stream ended without its final event while carrying no content or an unfinished tool call (checked only for real SSE payloads, not JSON mocks); in: the adapter's state after reading, out: `eventStream.fail`, else undefined.
392
+ export function incompleteStreamCheck(a: {
393
+ provider: string;
394
+ modelId: string;
395
+ hasTerminal: boolean;
396
+ empty: boolean;
397
+ unfinishedCall: boolean;
398
+ responseHeaders: Record<string, string>;
399
+ framesSeen: number;
400
+ badFrames: number;
401
+ }): Error | undefined {
402
+ if (a.hasTerminal || !(a.empty || a.unfinishedCall)) return undefined;
403
+ const contentType = Object.entries(a.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
404
+ const isSSEPayload = (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) || a.framesSeen > 0 || a.badFrames > 0;
405
+ return isSSEPayload ? incompleteStreamError(a.provider, a.modelId) : undefined;
406
+ }
407
+
408
+ // Fails a stream with the right error: an abort (the signal, the stream's cancel, or an AbortError) becomes the plain `AbortError`, anything else passes through; in: an adapter's catch block, out: the stream's result() rejection.
409
+ export function failStream(eventStream: AssistantMessageEventStream, linked: AbortController, err: unknown): void {
410
+ const raw = err instanceof Error ? err : new Error(String(err));
411
+ const isAbort = linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
412
+ eventStream.fail(isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw);
413
+ }
package/src/providers.ts CHANGED
@@ -248,17 +248,17 @@ export function resolveCache(
248
248
  return undefined;
249
249
  }
250
250
 
251
- /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire); in: an adapter's request messages, out: telemetry bus only. */
252
- export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string): void {
251
+ /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire for the given kinds of media); in: an adapter's request messages, out: telemetry bus only. */
252
+ export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string, kinds: Array<"image" | "video"> = ["image", "video"]): void {
253
253
  const used = messages.some(
254
- (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && p.resolution)
254
+ (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && kinds.includes(p.type) && p.resolution)
255
255
  );
256
256
  if (!used) return;
257
257
  emitProviderWarning({
258
258
  provider,
259
259
  capability: "media resolution",
260
260
  requested: modelRef,
261
- reason: "this provider has no per-media detail control (Google only).",
261
+ reason: "this provider has no per-media detail control for this kind of media.",
262
262
  fallback: "the provider's default detail",
263
263
  });
264
264
  }
@@ -399,6 +399,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
399
399
  * @example `parseStreamedToolArguments("{}", '{"location":"Paris"}')`
400
400
  */
401
401
  export function parseStreamedToolArguments(startText: string, deltaText: string): Record<string, unknown> {
402
+ if (!startText.trim() && !deltaText.trim()) return {}; // no argument text at all is a call that takes none
402
403
  const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
403
404
  for (const candidate of candidates) {
404
405
  try {
@@ -422,6 +423,7 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
422
423
  * @example `selectRawArguments("{}", '{"location":"Paris"}') // '{"location":"Paris"}'`
423
424
  */
424
425
  export function selectRawArguments(startText: string, deltaText: string): string {
426
+ if (!startText.trim() && !deltaText.trim()) return "{}";
425
427
  const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
426
428
  for (const candidate of candidates) {
427
429
  try {
@@ -445,6 +447,8 @@ export interface GenerationParams {
445
447
  seed?: number;
446
448
  }
447
449
 
450
+ /** Most stop sequences a Chat Completions request may carry (OpenRouter chat-completion api-ref `maxItems`, OpenAI the same). */
451
+ const MAX_CHAT_STOP = 4;
448
452
  const finiteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
449
453
 
450
454
  /** Normalizes the public `stop` option (`string | string[]`) to a clean array, or undefined. */
@@ -460,11 +464,22 @@ export function normalizeStop(stop: string | string[] | undefined): string[] | u
460
464
  * `max_tokens`, `stop`, `seed`. Only set values are
461
465
  * emitted, so strict endpoints never see anything the caller did not ask for.
462
466
  */
463
- export function mapGenerationParamsToChat(params: GenerationParams | undefined): Record<string, unknown> {
467
+ export function mapGenerationParamsToChat(params: GenerationParams | undefined, provider = "chat", modelRef = ""): Record<string, unknown> {
464
468
  const out: Record<string, unknown> = {};
465
469
  if (!params) return out;
466
470
  if (finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0) out["max_tokens"] = Math.floor(params.maxOutputTokens);
467
- if (params.stop && params.stop.length > 0) out["stop"] = params.stop;
471
+ if (params.stop && params.stop.length > 0) {
472
+ out["stop"] = params.stop.slice(0, MAX_CHAT_STOP);
473
+ if (params.stop.length > MAX_CHAT_STOP) {
474
+ emitProviderWarning({
475
+ provider,
476
+ capability: "stop sequences",
477
+ requested: `${params.stop.length} sequences (${modelRef})`,
478
+ reason: `Chat Completions allows at most ${MAX_CHAT_STOP} stop sequences.`,
479
+ fallback: `the first ${MAX_CHAT_STOP}`,
480
+ });
481
+ }
482
+ }
468
483
  if (finiteNumber(params.seed)) out["seed"] = Math.floor(params.seed);
469
484
  return out;
470
485
  }
@@ -3,11 +3,11 @@ Schedules a sub-agent (a "scheduled action") to run LATER, once or on repeat, on
3
3
  Call get_datetime first whenever you create or reschedule, then pick DD from today's date.
4
4
 
5
5
  COMMANDS (one tool, the `command` field)
6
- - create: schedule a new action. Needs instructions, task and scheduled_action.{name, id, timeToDeliver, meridiem, startCookingInAdvance}. Optional: role, tools, timeoutMs, scheduled_action.repeatEvery, scheduled_action.retries.
6
+ - create: schedule a new action. Needs instructions, task and scheduled_action.{name, id, timeToDeliver, meridiem, startCookingInAdvance}. Optional: role, tools, timeoutMs, scheduled_action.repeatEvery, scheduled_action.retries, scheduled_action.onMissed.
7
7
  - list: every action with its metadata and state (waiting, cooking, missed, done).
8
8
  - get: one action by `id`, with its latest run result.
9
9
  - update: change instructions, task, tools, timeoutMs, role or any scheduled_action field of the action `id`.
10
- - kill: delete the action `id` for good. If the answer says it is running, ask the user before retrying with force: true. purge: true also deletes its past run files.
10
+ - delete: delete the action `id` for good. If the answer says it is running, ask the user before retrying with force: true. purge: true also deletes its past run files.
11
11
  - run_now: run the action `id` immediately, outside its schedule. Also fixes a missed run.
12
12
  - skip: ignore a missed run of `id` and wait for the next scheduled time.
13
13
 
@@ -18,7 +18,9 @@ SCHEDULED_ACTION FIELDS
18
18
  - meridiem: "AM" or "PM" for timeToDeliver.
19
19
  - startCookingInAdvance: "DD:HH:MM" as a duration (HH 00-23): how long before delivery the run starts. "00:00:10" = 10 minutes, "00:01:00" = 1 hour.
20
20
  - repeatEvery (optional): "DD:HH:MM" gap between deliveries. "01:00:00" = every day, "07:00:00" = every week. Omit to run once.
21
+ - timeoutMs (optional, a top-level field of create and update, not inside scheduled_action): stop a run that takes longer than this many milliseconds. Omit it for no limit of your own; the host program may set a maximum that this cannot exceed.
21
22
  - retries (optional): extra attempts if a run fails, used only while there is still time before delivery. Default 0.
23
+ - onMissed (optional): what to do when the host program was not running at delivery time. "skip" = move on to the next time. "run" = run it when the program is back. Omit it to be told (state missed) and decide with run_now or skip.
22
24
 
23
25
  EXAMPLES
24
26
  Daily brief, first delivery tomorrow at 7:00 AM, starting 30 minutes ahead:
@@ -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
  }