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.
- package/README.md +24 -13
- package/package.json +1 -1
- package/src/agent/agent.ts +111 -35
- package/src/agent/config-blocks.ts +80 -0
- package/src/agent/loop.ts +5 -3
- package/src/agent/subagents-tool.ts +2 -2
- package/src/agent/subagents.md +2 -2
- package/src/agent/worker.ts +7 -5
- package/src/agent/workers.ts +2 -2
- package/src/index.ts +3 -3
- package/src/providers/openrouter.ts +49 -225
- package/src/providers/shared.ts +2 -2
- package/src/scheduler/scheduled-actions.md +4 -2
- package/src/scheduler/scheduler.ts +87 -50
- package/src/scheduler/store.ts +23 -1
- package/src/scheduler/tool.ts +9 -8
- package/src/streaming/event-stream.ts +7 -2
- package/src/tools/executor.ts +17 -2
- package/src/tools/tool.ts +0 -16
- package/src/types/agent.ts +32 -17
- package/src/types/scheduler.ts +9 -2
- package/src/utils/headers.ts +1 -1
|
@@ -14,14 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Agent } from "../agent/agent.ts";
|
|
16
16
|
import { createChildSessionId } from "../agent/delegation.ts";
|
|
17
|
-
import { buildDynamicWorkerConfig, grantWorkerTools,
|
|
17
|
+
import { buildDynamicWorkerConfig, grantWorkerTools, scheduledRunTimeoutMs } from "../agent/worker.ts";
|
|
18
18
|
import { telemetryBus } from "../telemetry.ts";
|
|
19
|
+
import { readUsage, usageLine, type UsageTotals } from "../session/usage.ts";
|
|
19
20
|
import { lockFolder } from "../utils/durable.ts";
|
|
20
21
|
import { ConfigError, ValidationError } from "../types/errors.ts";
|
|
21
22
|
import type { ToolDefinition } from "../types/tool.ts";
|
|
22
23
|
import type { AgentResponse } from "../types/response.ts";
|
|
23
24
|
import type {
|
|
24
|
-
|
|
25
|
+
DeleteResult,
|
|
25
26
|
ScheduledActionInput,
|
|
26
27
|
ScheduledActionPatch,
|
|
27
28
|
ScheduledActionRecord,
|
|
@@ -68,16 +69,43 @@ export interface HomeOptions {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
type PoolTools = Record<string, ToolDefinition>;
|
|
71
|
-
type Outcome = { res: AgentResponse } | { error: string };
|
|
72
|
+
type Outcome = ({ res: AgentResponse } | { error: string }) & { usage?: UsageTotals };
|
|
72
73
|
interface RunningEntry {
|
|
73
|
-
/** Aborted by `
|
|
74
|
+
/** Aborted by `delete`; every attempt of the run listens to it. */
|
|
74
75
|
controller: AbortController;
|
|
75
76
|
done: Promise<void>;
|
|
76
|
-
|
|
77
|
+
deleted: boolean;
|
|
78
|
+
/** Started by `run_now` or `onMissed: "run"`: due when it finishes. */
|
|
79
|
+
manual: boolean;
|
|
80
|
+
/** A manual run that replaces a missed occurrence: always `late`. A plain `run_now` is never late. */
|
|
81
|
+
missed: boolean;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
80
85
|
|
|
86
|
+
// A run's exact spending as run-header fields, the numbers `usageReport()` keeps for workers (tokens split, cache hit rate, cost and where the price came from); in: the one-shot worker's totals over all attempts, out: finish's RunHeader.
|
|
87
|
+
function usageFields(totals: UsageTotals | undefined): Partial<RunHeader> {
|
|
88
|
+
if (!totals || totals.turns === 0) return {};
|
|
89
|
+
const u = usageLine(totals);
|
|
90
|
+
return {
|
|
91
|
+
turns: u.turns,
|
|
92
|
+
tokens: u.tokens.total,
|
|
93
|
+
inputTokens: u.tokens.input,
|
|
94
|
+
freshInputTokens: u.tokens.freshInput,
|
|
95
|
+
cacheReadTokens: u.tokens.cacheRead,
|
|
96
|
+
cacheWriteTokens: u.tokens.cacheWrite,
|
|
97
|
+
outputTokens: u.tokens.output,
|
|
98
|
+
thinkingTokens: u.tokens.thinking,
|
|
99
|
+
...(u.cacheHitRate !== null ? { cacheHitRate: Math.round(u.cacheHitRate * 10_000) / 10_000 } : {}),
|
|
100
|
+
costUsd: u.cost.totalUsd === null ? "unknown" : Number(u.cost.totalUsd.toFixed(6)),
|
|
101
|
+
costSource: u.cost.source,
|
|
102
|
+
...(u.cost.unpricedTurns > 0 ? { unpricedTurns: u.cost.unpricedTurns } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Whole seconds since the epoch: stored times have no milliseconds, so lateness is compared at this precision; in: a clock reading or stored time, out: finish and deliver.
|
|
107
|
+
const secondOf = (d: Date): number => Math.floor(d.getTime() / 1000);
|
|
108
|
+
|
|
81
109
|
function emit(code: string, message: string, data: Record<string, unknown>, level: "info" | "warn" = "info"): void {
|
|
82
110
|
telemetryBus.emit({ code: `scheduled_action.${code}`, message: `[Agent Accelerator] ${message}`, level, data });
|
|
83
111
|
}
|
|
@@ -141,6 +169,9 @@ function checkSchedule(
|
|
|
141
169
|
if (sa.retries !== undefined && !(Number.isInteger(sa.retries) && sa.retries >= 0)) {
|
|
142
170
|
problems.push("scheduled_action.retries: must be a whole number >= 0.");
|
|
143
171
|
}
|
|
172
|
+
if (sa.onMissed !== undefined && sa.onMissed !== "skip" && sa.onMissed !== "run") {
|
|
173
|
+
problems.push(`scheduled_action.onMissed: must be "skip" or "run" (omit it to decide later with run_now or skip); got ${JSON.stringify(sa.onMissed)}.`);
|
|
174
|
+
}
|
|
144
175
|
}
|
|
145
176
|
|
|
146
177
|
function checkOptionalFields(input: Pick<ScheduledActionPatch, "role" | "tools" | "timeoutMs">, pool: PoolTools | undefined, problems: string[]): void {
|
|
@@ -227,7 +258,7 @@ export class Scheduler {
|
|
|
227
258
|
this.swept = true;
|
|
228
259
|
this.startupSweep(now);
|
|
229
260
|
}
|
|
230
|
-
this.deliverDue(now
|
|
261
|
+
this.deliverDue(now);
|
|
231
262
|
this.startDue(now);
|
|
232
263
|
if (!this.hasPending()) this.stop();
|
|
233
264
|
} catch (err) {
|
|
@@ -277,7 +308,7 @@ export class Scheduler {
|
|
|
277
308
|
for (const t of this.snapshot().live) {
|
|
278
309
|
if (t.state === "cooking") this.recoverCooking(t);
|
|
279
310
|
else if (t.state === "waiting" && t.nextDeliverAt && parseLocalIso(t.nextDeliverAt).getTime() <= now.getTime()) {
|
|
280
|
-
this.markMissed(t.id, "process_down");
|
|
311
|
+
this.markMissed(t.id, "process_down", t.schedule.onMissed);
|
|
281
312
|
}
|
|
282
313
|
}
|
|
283
314
|
}
|
|
@@ -288,7 +319,7 @@ export class Scheduler {
|
|
|
288
319
|
const file = findRunFile(this.home, t.id, parseLocalIso(current.deliverAt), current.cookStartedAt);
|
|
289
320
|
const run = file ? readRun(this.home, file) : undefined;
|
|
290
321
|
if (!file || !run) {
|
|
291
|
-
this.markMissed(t.id, "interrupted");
|
|
322
|
+
this.markMissed(t.id, "interrupted", t.schedule.onMissed);
|
|
292
323
|
return;
|
|
293
324
|
}
|
|
294
325
|
updateDeliveries(this.home, (pending) => {
|
|
@@ -299,6 +330,7 @@ export class Scheduler {
|
|
|
299
330
|
status: run.header["status"] === "failed" ? "failed" : "ok",
|
|
300
331
|
readyAt: run.header["finishedAt"] ?? current.deliverAt,
|
|
301
332
|
deliverAt: current.deliverAt,
|
|
333
|
+
...(run.header["late"] === "true" ? { late: true } : {}),
|
|
302
334
|
});
|
|
303
335
|
});
|
|
304
336
|
updateTasks(this.home, (tasks) => {
|
|
@@ -309,7 +341,8 @@ export class Scheduler {
|
|
|
309
341
|
});
|
|
310
342
|
}
|
|
311
343
|
|
|
312
|
-
|
|
344
|
+
// Marks a run whose moment passed as missed, then applies the action's onMissed policy: `skip` moves on to the next time, `run` runs it now, unset waits for run_now / skip; in: the startup sweep, out: tasks.json + the missed (and skipped / started) events.
|
|
345
|
+
private markMissed(id: string, reason: string, policy?: "skip" | "run"): void {
|
|
313
346
|
let name = id;
|
|
314
347
|
updateTasks(this.home, (tasks) => {
|
|
315
348
|
const t = tasks.find((x) => x.id === id);
|
|
@@ -318,25 +351,28 @@ export class Scheduler {
|
|
|
318
351
|
t.state = "missed";
|
|
319
352
|
delete t.current;
|
|
320
353
|
});
|
|
321
|
-
|
|
354
|
+
const next = policy === "skip" ? "Skipping it (onMissed: skip)." : policy === "run" ? "Running it now (onMissed: run)." : "Use run_now or skip.";
|
|
355
|
+
emit("missed", `"${name}" (${id}) missed its run (${reason}). ${next}`, { id, name, reason, ...(policy ? { onMissed: policy } : {}) }, "warn");
|
|
356
|
+
if (policy === "skip") this.skip(id);
|
|
357
|
+
else if (policy === "run") this.startCook(id, true);
|
|
322
358
|
}
|
|
323
359
|
|
|
324
360
|
// ----- delivery ----------------------------------------------------------
|
|
325
361
|
|
|
326
|
-
private deliverDue(now: Date
|
|
362
|
+
private deliverDue(now: Date): void {
|
|
327
363
|
for (const entry of this.snapshot().pending) {
|
|
328
|
-
if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now
|
|
364
|
+
if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now);
|
|
329
365
|
}
|
|
330
366
|
}
|
|
331
367
|
|
|
332
|
-
|
|
333
|
-
private deliver(entry: PendingDelivery, now: Date
|
|
368
|
+
// Removes the entry, records `lastRun`, THEN emits (whether or not anyone listens); late = it replaced a missed occurrence, was delivered over two ticks after its time, or finished after it; in: deliverDue, out: tasks.json + the delivered event.
|
|
369
|
+
private deliver(entry: PendingDelivery, now: Date): void {
|
|
334
370
|
updateDeliveries(this.home, (pending) => {
|
|
335
371
|
const i = pending.findIndex((p) => p.id === entry.id && p.file === entry.file);
|
|
336
372
|
if (i >= 0) pending.splice(i, 1);
|
|
337
373
|
});
|
|
338
374
|
const deliverAt = parseLocalIso(entry.deliverAt).getTime();
|
|
339
|
-
const late =
|
|
375
|
+
const late = entry.late === true || now.getTime() - deliverAt > 2 * this.tickMs || parseLocalIso(entry.readyAt).getTime() > deliverAt;
|
|
340
376
|
let name = entry.id;
|
|
341
377
|
updateTasks(this.home, (tasks) => {
|
|
342
378
|
const t = tasks.find((x) => x.id === entry.id);
|
|
@@ -382,7 +418,7 @@ export class Scheduler {
|
|
|
382
418
|
*/
|
|
383
419
|
private startCook(id: string, manual: boolean): ScheduledActionRecord | undefined {
|
|
384
420
|
const now = this.now();
|
|
385
|
-
const ctx: { agent?: Agent; noAgent?: boolean } = {};
|
|
421
|
+
const ctx: { agent?: Agent; noAgent?: boolean; replacesMissed?: boolean } = {};
|
|
386
422
|
const started = updateTasks(this.home, (tasks) => {
|
|
387
423
|
const t = tasks.find((x) => x.id === id);
|
|
388
424
|
if (!t) return undefined;
|
|
@@ -392,6 +428,7 @@ export class Scheduler {
|
|
|
392
428
|
return undefined;
|
|
393
429
|
}
|
|
394
430
|
if (t.state === "cooking") return undefined;
|
|
431
|
+
ctx.replacesMissed = manual && t.state === "missed";
|
|
395
432
|
ctx.agent = this.agents.get(t.agent)?.deref();
|
|
396
433
|
if (!ctx.agent) {
|
|
397
434
|
t.state = "missed";
|
|
@@ -427,7 +464,7 @@ export class Scheduler {
|
|
|
427
464
|
cookStartedAt: started.current!.cookStartedAt,
|
|
428
465
|
deliverAt: started.current!.deliverAt,
|
|
429
466
|
});
|
|
430
|
-
this.launch(started, ctx.agent);
|
|
467
|
+
this.launch(started, ctx.agent, manual, ctx.replacesMissed === true);
|
|
431
468
|
return started;
|
|
432
469
|
}
|
|
433
470
|
|
|
@@ -458,8 +495,9 @@ export class Scheduler {
|
|
|
458
495
|
}
|
|
459
496
|
}
|
|
460
497
|
|
|
461
|
-
|
|
462
|
-
|
|
498
|
+
// Starts one run without awaiting it and tracks it until it ends; in: startCook, out: this.running + the run's files.
|
|
499
|
+
private launch(task: ScheduledActionRecord, agent: Agent, manual: boolean, missed: boolean): void {
|
|
500
|
+
const entry: RunningEntry = { controller: new AbortController(), done: Promise.resolve(), deleted: false, manual, missed };
|
|
463
501
|
this.running.set(task.id, entry);
|
|
464
502
|
entry.done = this.runWorker(task, agent, entry)
|
|
465
503
|
.catch((err) => {
|
|
@@ -476,8 +514,8 @@ export class Scheduler {
|
|
|
476
514
|
private async runWorker(task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<void> {
|
|
477
515
|
const deliverAt = parseLocalIso(task.current!.deliverAt);
|
|
478
516
|
const outcome = await this.execute(task, agent, entry, deliverAt);
|
|
479
|
-
if (entry.
|
|
480
|
-
this.finish(task, outcome, task.current!.cookStartedAt, deliverAt);
|
|
517
|
+
if (entry.deleted) return; // deleted: no run file, nothing delivered
|
|
518
|
+
this.finish(task, outcome, task.current!.cookStartedAt, deliverAt, entry);
|
|
481
519
|
}
|
|
482
520
|
|
|
483
521
|
/** A fresh stateless worker built from the saved fields; retries only while there is time before delivery. */
|
|
@@ -502,13 +540,13 @@ export class Scheduler {
|
|
|
502
540
|
const attempts = 1 + (task.schedule.retries ?? 0);
|
|
503
541
|
for (let attempt = 1; ; attempt++) {
|
|
504
542
|
try {
|
|
505
|
-
return { res: await this.attempt(worker, task, agent, entry) };
|
|
543
|
+
return { res: await this.attempt(worker, task, agent, entry), usage: readUsage(worker.usageTotals) };
|
|
506
544
|
} catch (err) {
|
|
507
|
-
if (entry.
|
|
545
|
+
if (entry.deleted || attempt >= attempts || this.now().getTime() >= deliverAt.getTime()) throw err;
|
|
508
546
|
}
|
|
509
547
|
}
|
|
510
548
|
} catch (err) {
|
|
511
|
-
return { error: errText(err) };
|
|
549
|
+
return { error: errText(err), ...(worker ? { usage: readUsage(worker.usageTotals) } : {}) };
|
|
512
550
|
} finally {
|
|
513
551
|
try {
|
|
514
552
|
worker?.reset();
|
|
@@ -519,14 +557,14 @@ export class Scheduler {
|
|
|
519
557
|
}
|
|
520
558
|
|
|
521
559
|
private async attempt(worker: Agent, task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<AgentResponse> {
|
|
522
|
-
const ms =
|
|
523
|
-
// One controller per attempt (the timeout is per attempt), fed by the entry's
|
|
560
|
+
const ms = scheduledRunTimeoutMs(agent.dynamicSubagents?.workerTimeoutMs ?? Infinity, task.timeoutMs);
|
|
561
|
+
// One controller per attempt (the timeout is per attempt), fed by the entry's delete controller.
|
|
524
562
|
const controller = new AbortController();
|
|
525
|
-
const
|
|
526
|
-
if (entry.controller.signal.aborted)
|
|
527
|
-
else entry.controller.signal.addEventListener("abort",
|
|
563
|
+
const onDelete = () => controller.abort(entry.controller.signal.reason ?? new Error("deleted"));
|
|
564
|
+
if (entry.controller.signal.aborted) onDelete();
|
|
565
|
+
else entry.controller.signal.addEventListener("abort", onDelete, { once: true });
|
|
528
566
|
const timer = ms > 0 ? setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms) : undefined;
|
|
529
|
-
// Raced so a timeout or
|
|
567
|
+
// Raced so a timeout or delete ends the attempt even if the provider ignores the signal.
|
|
530
568
|
const aborted = new Promise<never>((_, reject) => {
|
|
531
569
|
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new Error("aborted")), { once: true });
|
|
532
570
|
});
|
|
@@ -534,35 +572,33 @@ export class Scheduler {
|
|
|
534
572
|
return await Promise.race([worker.run(task.task, { signal: controller.signal }), aborted]);
|
|
535
573
|
} finally {
|
|
536
574
|
clearTimeout(timer);
|
|
537
|
-
entry.controller.signal.removeEventListener("abort",
|
|
575
|
+
entry.controller.signal.removeEventListener("abort", onDelete);
|
|
538
576
|
}
|
|
539
577
|
}
|
|
540
578
|
|
|
541
|
-
|
|
542
|
-
private finish(task: ScheduledActionRecord, outcome: Outcome, startedAt: string, deliverAt: Date): void {
|
|
579
|
+
// Writes a finished run: run file, then the `deliveries.json` entry, then `tasks.json` (a crash in between is repaired on start); a manual run is due when it finishes and is late only if it replaced a missed occurrence, a scheduled one is late only past its second; in: runWorker, out: the files and the ready/failed event.
|
|
580
|
+
private finish(task: ScheduledActionRecord, outcome: Outcome, startedAt: string, deliverAt: Date, run: Pick<RunningEntry, "manual" | "missed">): void {
|
|
543
581
|
const now = this.now();
|
|
544
582
|
const ok = "res" in outcome;
|
|
545
583
|
const finishedAt = toLocalIso(now);
|
|
584
|
+
const due = run.manual ? now : deliverAt;
|
|
585
|
+
const late = run.missed || secondOf(now) > secondOf(due);
|
|
546
586
|
const header: RunHeader = {
|
|
547
587
|
id: task.id,
|
|
548
588
|
name: task.name,
|
|
549
589
|
status: ok ? "ok" : "failed",
|
|
550
590
|
startedAt,
|
|
551
591
|
finishedAt,
|
|
552
|
-
deliverAt: toLocalIso(
|
|
553
|
-
late
|
|
554
|
-
...(ok
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
tokens: outcome.res.usage.totalTokens,
|
|
558
|
-
costUsd: outcome.res.usage.cost?.totalCost ?? "unknown",
|
|
559
|
-
}
|
|
560
|
-
: { error: outcome.error }),
|
|
592
|
+
deliverAt: toLocalIso(due),
|
|
593
|
+
late,
|
|
594
|
+
...(ok ? { model: `${outcome.res.provider}/${outcome.res.model}` } : {}),
|
|
595
|
+
...usageFields(outcome.usage),
|
|
596
|
+
...(ok ? {} : { error: outcome.error }),
|
|
561
597
|
};
|
|
562
598
|
const body = ok ? outcome.res.text.trim() || "[The worker returned no text.]" : `The run failed: ${outcome.error}`;
|
|
563
599
|
const file = writeRunFile(this.home, task.id, deliverAt, header, body);
|
|
564
600
|
updateDeliveries(this.home, (pending) => {
|
|
565
|
-
pending.push({ id: task.id, file, status: header.status, readyAt: finishedAt, deliverAt: header.deliverAt });
|
|
601
|
+
pending.push({ id: task.id, file, status: header.status, readyAt: finishedAt, deliverAt: header.deliverAt, ...(run.missed ? { late: true } : {}) });
|
|
566
602
|
});
|
|
567
603
|
updateTasks(this.home, (tasks) => {
|
|
568
604
|
const t = tasks.find((x) => x.id === task.id);
|
|
@@ -611,7 +647,7 @@ export class Scheduler {
|
|
|
611
647
|
return rec;
|
|
612
648
|
}
|
|
613
649
|
|
|
614
|
-
async
|
|
650
|
+
async delete(id: string, opts: { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
|
|
615
651
|
const rec = readTasks(this.home).find((t) => t.id === id);
|
|
616
652
|
if (!rec) return { ok: false, reason: "not_found" };
|
|
617
653
|
const run = this.running.get(id);
|
|
@@ -619,12 +655,12 @@ export class Scheduler {
|
|
|
619
655
|
return {
|
|
620
656
|
ok: false,
|
|
621
657
|
reason: "running",
|
|
622
|
-
message: `"${id}" is cooking right now, so nothing was changed. Ask the user whether to abort it; if they agree, call
|
|
658
|
+
message: `"${id}" is cooking right now, so nothing was changed. Ask the user whether to abort it; if they agree, call delete again with force: true.`,
|
|
623
659
|
};
|
|
624
660
|
}
|
|
625
661
|
if (run) {
|
|
626
|
-
run.
|
|
627
|
-
run.controller.abort(new Error("
|
|
662
|
+
run.deleted = true;
|
|
663
|
+
run.controller.abort(new Error("deleted"));
|
|
628
664
|
await run.done;
|
|
629
665
|
}
|
|
630
666
|
updateTasks(this.home, (tasks) => {
|
|
@@ -635,7 +671,7 @@ export class Scheduler {
|
|
|
635
671
|
for (let i = pending.length - 1; i >= 0; i--) if (pending[i]!.id === id) pending.splice(i, 1);
|
|
636
672
|
});
|
|
637
673
|
if (opts.purge) removeTaskFolder(this.home, id);
|
|
638
|
-
emit("
|
|
674
|
+
emit("deleted", `"${rec.name}" (${id}) was deleted.`, { id, name: rec.name, forced: !!run, purged: !!opts.purge });
|
|
639
675
|
return { ok: true };
|
|
640
676
|
}
|
|
641
677
|
}
|
|
@@ -712,6 +748,7 @@ export function createScheduledAction(agent: Agent, input: ScheduledActionInput)
|
|
|
712
748
|
startCookingInAdvance: lead,
|
|
713
749
|
...(sa.repeatEvery !== undefined ? { repeatEvery: sa.repeatEvery } : {}),
|
|
714
750
|
...(sa.retries !== undefined ? { retries: sa.retries } : {}),
|
|
751
|
+
...(sa.onMissed !== undefined ? { onMissed: sa.onMissed } : {}),
|
|
715
752
|
},
|
|
716
753
|
state: "waiting",
|
|
717
754
|
nextCookAt: toLocalIso(cookTime(deliverAt, durationMs(lead, "scheduled_action.startCookingInAdvance"))),
|
|
@@ -811,6 +848,6 @@ export function skipScheduledAction(id: string, opts?: HomeOptions): ScheduledAc
|
|
|
811
848
|
* `force: true` (the run is aborted, then deleted). Past run files are kept
|
|
812
849
|
* unless `purge: true`.
|
|
813
850
|
*/
|
|
814
|
-
export function
|
|
815
|
-
return schedulerFor(opts).
|
|
851
|
+
export function deleteScheduledAction(id: string, opts?: HomeOptions & { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
|
|
852
|
+
return schedulerFor(opts).delete(id, { force: opts?.force, purge: opts?.purge });
|
|
816
853
|
}
|
package/src/scheduler/store.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { telemetryBus } from "../telemetry.ts";
|
|
|
17
17
|
import { resolveHome } from "../utils/home.ts";
|
|
18
18
|
export { resolveHome };
|
|
19
19
|
import { ValidationError } from "../types/errors.ts";
|
|
20
|
+
import type { CostSource } from "../session/usage.ts";
|
|
20
21
|
import type { ScheduledActionRecord, ScheduledRunStatus } from "../types/scheduler.ts";
|
|
21
22
|
import { durationMs, runFileName } from "./time.ts";
|
|
22
23
|
|
|
@@ -33,6 +34,8 @@ export interface PendingDelivery {
|
|
|
33
34
|
status: ScheduledRunStatus;
|
|
34
35
|
readyAt: string;
|
|
35
36
|
deliverAt: string;
|
|
37
|
+
/** Set when the run replaced a missed occurrence: it is late whatever the clock says (a `run_now` on a `missed` action, or `onMissed: "run"`). */
|
|
38
|
+
late?: boolean;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** Header written at the top of every run file. */
|
|
@@ -46,9 +49,26 @@ export interface RunHeader {
|
|
|
46
49
|
/** The run finished after its delivery time. */
|
|
47
50
|
late: boolean;
|
|
48
51
|
model?: string;
|
|
52
|
+
/** Model calls the run made (all attempts), and what they spent: the same numbers `usageReport()` keeps for workers. */
|
|
53
|
+
turns?: number;
|
|
54
|
+
/** Input + output tokens. */
|
|
49
55
|
tokens?: number;
|
|
50
|
-
/**
|
|
56
|
+
/** Everything sent; `freshInputTokens + cacheReadTokens + cacheWriteTokens`. */
|
|
57
|
+
inputTokens?: number;
|
|
58
|
+
freshInputTokens?: number;
|
|
59
|
+
cacheReadTokens?: number;
|
|
60
|
+
cacheWriteTokens?: number;
|
|
61
|
+
/** Everything generated, thinking included. */
|
|
62
|
+
outputTokens?: number;
|
|
63
|
+
thinkingTokens?: number;
|
|
64
|
+
/** `cacheReadTokens / inputTokens`, 0 to 1; absent when nothing was sent. */
|
|
65
|
+
cacheHitRate?: number;
|
|
66
|
+
/** `unknown` when no turn had a known price (never a made-up 0). */
|
|
51
67
|
costUsd?: number | "unknown";
|
|
68
|
+
/** Where the price came from: the `provider`'s reported cost, the `catalog`, `mixed`, or `unknown`. */
|
|
69
|
+
costSource?: CostSource;
|
|
70
|
+
/** Turns with no known price; when present, `costUsd` is a lower bound. */
|
|
71
|
+
unpricedTurns?: number;
|
|
52
72
|
error?: string;
|
|
53
73
|
}
|
|
54
74
|
|
|
@@ -163,6 +183,7 @@ const recordSchema = z
|
|
|
163
183
|
startCookingInAdvance: z.string(),
|
|
164
184
|
repeatEvery: z.string().optional(),
|
|
165
185
|
retries: z.number().int().min(0).optional(),
|
|
186
|
+
onMissed: z.enum(["skip", "run"]).optional(),
|
|
166
187
|
}),
|
|
167
188
|
state: z.enum(["waiting", "cooking", "missed", "done"]),
|
|
168
189
|
nextCookAt: iso.nullable(),
|
|
@@ -195,6 +216,7 @@ const deliverySchema = z.object({
|
|
|
195
216
|
status: z.enum(["ok", "failed"]),
|
|
196
217
|
readyAt: iso,
|
|
197
218
|
deliverAt: iso,
|
|
219
|
+
late: z.boolean().optional(),
|
|
198
220
|
});
|
|
199
221
|
|
|
200
222
|
/** `undefined` when a `deliveries.json` entry is usable, else a one-line reason. Never throws. */
|
package/src/scheduler/tool.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ScheduledActionInput, ScheduledActionPatch } from "../types/schedu
|
|
|
14
14
|
import {
|
|
15
15
|
createScheduledAction,
|
|
16
16
|
getScheduler,
|
|
17
|
-
|
|
17
|
+
deleteScheduledAction,
|
|
18
18
|
listScheduledActions,
|
|
19
19
|
runScheduledAction,
|
|
20
20
|
skipScheduledAction,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { readRun } from "./store.ts";
|
|
25
25
|
import { formatDatetime } from "./time.ts";
|
|
26
26
|
|
|
27
|
-
const COMMANDS = ["create", "list", "get", "update", "
|
|
27
|
+
const COMMANDS = ["create", "list", "get", "update", "delete", "run_now", "skip"] as const;
|
|
28
28
|
|
|
29
29
|
let manual: string | undefined;
|
|
30
30
|
/** Read on first use (not at import), so a program that never enables scheduling never touches the file. */
|
|
@@ -50,9 +50,10 @@ const scheduledActionSchema = z.object({
|
|
|
50
50
|
startCookingInAdvance: z.string().optional().describe('Duration "DD:HH:MM" before delivery when the run starts.'),
|
|
51
51
|
repeatEvery: z.string().optional().describe('Duration "DD:HH:MM" between deliveries. Omit to run once.'),
|
|
52
52
|
retries: z.number().optional().describe("Extra attempts if a run fails."),
|
|
53
|
+
onMissed: z.enum(["skip", "run"]).optional().describe('If the host program was not running at delivery time: "skip" = move on to the next time, "run" = run when it is back. Omit to decide later.'),
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
const describedId = "Full action id including its 6-character suffix (get, update,
|
|
56
|
+
const describedId = "Full action id including its 6-character suffix (get, update, delete, run_now, skip).";
|
|
56
57
|
|
|
57
58
|
/** Builds the `scheduled_actions` tool for `agent`; its description is the manual with the worker pool filled in. */
|
|
58
59
|
export function createScheduledActionsTool(agent: Agent): ToolDefinition {
|
|
@@ -85,10 +86,10 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
|
|
|
85
86
|
instructions: z.string().optional().describe("The worker's system prompt (create, update)."),
|
|
86
87
|
task: z.string().optional().describe("The concrete assignment for every run (create, update)."),
|
|
87
88
|
tools: z.array(z.string()).optional().describe(`Worker tools to grant, from: ${poolNames.join(", ") || "none"}.`),
|
|
88
|
-
timeoutMs: z.number().optional().describe("
|
|
89
|
+
timeoutMs: z.number().optional().describe("Stop a run that takes longer than this many ms (> 0). Omit it for no limit of your own; the host program may cap it."),
|
|
89
90
|
scheduled_action: scheduledActionSchema.optional(),
|
|
90
|
-
force: z.boolean().optional().describe("
|
|
91
|
-
purge: z.boolean().optional().describe("
|
|
91
|
+
force: z.boolean().optional().describe("delete: abort a run that is cooking. Ask the user first."),
|
|
92
|
+
purge: z.boolean().optional().describe("delete: also delete past run files."),
|
|
92
93
|
}),
|
|
93
94
|
execute: async (a) => {
|
|
94
95
|
const sa = a.scheduled_action ?? {};
|
|
@@ -137,8 +138,8 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
|
|
|
137
138
|
}
|
|
138
139
|
return updateScheduledAction(id, patch, { home });
|
|
139
140
|
}
|
|
140
|
-
case "
|
|
141
|
-
return
|
|
141
|
+
case "delete":
|
|
142
|
+
return deleteScheduledAction(needId(), { home, force: a.force, purge: a.purge });
|
|
142
143
|
case "run_now":
|
|
143
144
|
return runScheduledAction(needId(), { home });
|
|
144
145
|
case "skip":
|
|
@@ -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
|
-
|
|
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
|
|
package/src/tools/executor.ts
CHANGED
|
@@ -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.
|
package/src/types/agent.ts
CHANGED
|
@@ -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` (
|
|
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).
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* `
|
|
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). */
|