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.
- package/README.md +34 -17
- 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 +6 -4
- 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/google.ts +6 -39
- package/src/providers/openai-compat.ts +31 -104
- package/src/providers/openai.ts +30 -103
- package/src/providers/openrouter.ts +151 -400
- package/src/providers/shared.ts +136 -7
- package/src/providers.ts +21 -6
- package/src/scheduler/scheduled-actions.md +4 -2
- package/src/scheduler/scheduler.ts +87 -50
- package/src/scheduler/store.ts +23 -1
- package/src/scheduler/tool.ts +9 -8
- package/src/session/store.ts +3 -3
- 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/message.ts +1 -1
- package/src/types/scheduler.ts +9 -2
- package/src/utils/errors.ts +4 -1
- package/src/utils/headers.ts +1 -1
- package/src/utils/media.ts +10 -1
package/src/providers/shared.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
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
|
-
-
|
|
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,
|
|
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
|
}
|