agent-accelerator 0.5.5 → 0.5.7
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 +16 -17
- package/package.json +1 -1
- package/src/agent/agent.ts +16 -18
- package/src/agent/architecture.md +25 -0
- package/src/agent/context.ts +4 -0
- package/src/agent/delegation.ts +18 -84
- package/src/agent/loop.ts +14 -16
- package/src/agent/worker.ts +1 -3
- package/src/architecture.md +28 -0
- package/src/index.ts +6 -73
- package/src/models/architecture.md +14 -0
- package/src/models/catalog-cache.ts +3 -0
- package/src/models/catalog.ts +7 -0
- package/src/providers/architecture.md +24 -0
- package/src/providers/google.ts +26 -39
- package/src/providers/openai-compat.ts +53 -104
- package/src/providers/openai.ts +47 -104
- package/src/providers/openrouter.ts +114 -175
- package/src/providers/registry.ts +1 -0
- package/src/providers/shared.ts +134 -5
- package/src/providers.ts +30 -59
- package/src/scheduler/architecture.md +19 -0
- package/src/scheduler/scheduler.ts +21 -0
- package/src/scheduler/store.ts +6 -0
- package/src/scheduler/time.ts +2 -0
- package/src/session/architecture.md +19 -0
- package/src/session/store.ts +18 -3
- package/src/session/usage.ts +1 -0
- package/src/streaming/architecture.md +13 -0
- package/src/streaming/event-stream.ts +5 -0
- package/src/telemetry.ts +1 -0
- package/src/tools/architecture.md +19 -0
- package/src/tools/executor.ts +13 -0
- package/src/tools/schema.ts +1 -0
- package/src/tools/subagent-result.ts +1 -0
- package/src/types/architecture.md +20 -0
- package/src/types/message.ts +1 -1
- package/src/utils/architecture.md +25 -0
- package/src/utils/base64.ts +2 -0
- package/src/utils/cache.ts +2 -0
- package/src/utils/errors.ts +8 -1
- package/src/utils/headers.ts +4 -1
- package/src/utils/media.ts +11 -1
- package/src/utils/raw-capture.ts +3 -0
- package/src/utils/retry.ts +1 -0
- package/src/utils/safe-io.ts +4 -0
- package/src/utils/session.ts +5 -161
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) };
|
|
@@ -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
|
@@ -42,6 +42,7 @@ export type ProviderCapabilityStatus =
|
|
|
42
42
|
*/
|
|
43
43
|
const emittedWarnings = new Set<string>();
|
|
44
44
|
|
|
45
|
+
/** Warns once per provider, capability and request about something that cannot be applied as asked: a `provider_warning` bus event, the legacy global sink and console; in: the mappers and adapters, out: those channels. */
|
|
45
46
|
export function emitProviderWarning(options: {
|
|
46
47
|
provider: string;
|
|
47
48
|
capability: string;
|
|
@@ -190,40 +191,6 @@ export function mapThinkingLevelToOpenRouterChat(
|
|
|
190
191
|
return {};
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
/**
|
|
194
|
-
* Maps a canonical ThinkingLevel onto OpenRouter Responses `reasoning.effort`.
|
|
195
|
-
* Documented efforts: minimal|low|medium|high (server default medium).
|
|
196
|
-
* Wire-validated extras: `none` disables reasoning output; `xhigh`/`max` are
|
|
197
|
-
* echoed but are NOT documented levels, so they clamp to `high` with a warning.
|
|
198
|
-
*
|
|
199
|
-
* @deprecated The Responses skin is discontinued for OpenRouter
|
|
200
|
-
* (removed from core). Use
|
|
201
|
-
* {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
|
|
202
|
-
* transport instead.
|
|
203
|
-
*/
|
|
204
|
-
export function mapThinkingLevelToOpenRouter(
|
|
205
|
-
level: ThinkingLevel | string | undefined
|
|
206
|
-
): { effort?: string } {
|
|
207
|
-
if (!level) return {};
|
|
208
|
-
const norm = String(level).toLowerCase().trim();
|
|
209
|
-
if (norm === "dynamic") return {}; // server default (medium)
|
|
210
|
-
if (norm === "none") return { effort: "none" };
|
|
211
|
-
if (norm === "xhigh" || norm === "max") {
|
|
212
|
-
emitProviderWarning({
|
|
213
|
-
provider: "openrouter",
|
|
214
|
-
capability: "thinking level",
|
|
215
|
-
requested: String(level),
|
|
216
|
-
reason: "OpenRouter documents reasoning efforts up to high.",
|
|
217
|
-
fallback: "reasoning effort high",
|
|
218
|
-
});
|
|
219
|
-
return { effort: "high" };
|
|
220
|
-
}
|
|
221
|
-
if (norm === "minimal" || norm === "low" || norm === "medium" || norm === "high") {
|
|
222
|
-
return { effort: norm };
|
|
223
|
-
}
|
|
224
|
-
return {};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
194
|
/** Maps canonical ServiceTier onto OpenRouter `service_tier` (omit = auto). */
|
|
228
195
|
export function mapServiceTierToOpenRouter(tier: ServiceTier | undefined): "flex" | "priority" | undefined {
|
|
229
196
|
if (tier === "flex" || tier === "priority") return tier;
|
|
@@ -248,17 +215,17 @@ export function resolveCache(
|
|
|
248
215
|
return undefined;
|
|
249
216
|
}
|
|
250
217
|
|
|
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 {
|
|
218
|
+
/** 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. */
|
|
219
|
+
export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string, kinds: Array<"image" | "video"> = ["image", "video"]): void {
|
|
253
220
|
const used = messages.some(
|
|
254
|
-
(m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && p.resolution)
|
|
221
|
+
(m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && kinds.includes(p.type) && p.resolution)
|
|
255
222
|
);
|
|
256
223
|
if (!used) return;
|
|
257
224
|
emitProviderWarning({
|
|
258
225
|
provider,
|
|
259
226
|
capability: "media resolution",
|
|
260
227
|
requested: modelRef,
|
|
261
|
-
reason: "this provider has no per-media detail control
|
|
228
|
+
reason: "this provider has no per-media detail control for this kind of media.",
|
|
262
229
|
fallback: "the provider's default detail",
|
|
263
230
|
});
|
|
264
231
|
}
|
|
@@ -287,25 +254,6 @@ export function mapToolChoiceToOpenRouterChat(
|
|
|
287
254
|
return undefined;
|
|
288
255
|
}
|
|
289
256
|
|
|
290
|
-
/** OpenRouter Responses `tool_choice` wire values.
|
|
291
|
-
* @deprecated Discontinued skin; see {@link OpenRouterChatToolChoice}. */
|
|
292
|
-
export type OpenRouterToolChoice = "auto" | "none" | "required" | { type: "function"; name: string };
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Maps canonical toolChoice onto OpenRouter Responses `tool_choice`.
|
|
296
|
-
* `auto` is the server default (omitted); `required` is natively supported
|
|
297
|
-
* (validated); a function pin passes through verbatim.
|
|
298
|
-
*/
|
|
299
|
-
export function mapToolChoiceToOpenRouter(
|
|
300
|
-
choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
301
|
-
): OpenRouterToolChoice | undefined {
|
|
302
|
-
if (!choice || choice === "auto") return undefined;
|
|
303
|
-
if (choice === "none" || choice === "required") return choice;
|
|
304
|
-
const name = choice.function?.name;
|
|
305
|
-
if (name) return { type: "function", name };
|
|
306
|
-
return undefined;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
257
|
/**
|
|
310
258
|
* Maps a canonical ThinkingLevel onto OpenAI Responses `reasoning.effort`.
|
|
311
259
|
* Documented efforts: none|minimal|low|medium|high|xhigh|max (server default
|
|
@@ -383,6 +331,7 @@ export function normalizeToolChoice(
|
|
|
383
331
|
return { mode: "any" };
|
|
384
332
|
}
|
|
385
333
|
|
|
334
|
+
// True for a non-array object; in: the argument and schema helpers, out: a type guard.
|
|
386
335
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
387
336
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
388
337
|
}
|
|
@@ -399,6 +348,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
399
348
|
* @example `parseStreamedToolArguments("{}", '{"location":"Paris"}')`
|
|
400
349
|
*/
|
|
401
350
|
export function parseStreamedToolArguments(startText: string, deltaText: string): Record<string, unknown> {
|
|
351
|
+
if (!startText.trim() && !deltaText.trim()) return {}; // no argument text at all is a call that takes none
|
|
402
352
|
const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
|
|
403
353
|
for (const candidate of candidates) {
|
|
404
354
|
try {
|
|
@@ -422,6 +372,7 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
|
|
|
422
372
|
* @example `selectRawArguments("{}", '{"location":"Paris"}') // '{"location":"Paris"}'`
|
|
423
373
|
*/
|
|
424
374
|
export function selectRawArguments(startText: string, deltaText: string): string {
|
|
375
|
+
if (!startText.trim() && !deltaText.trim()) return "{}";
|
|
425
376
|
const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
|
|
426
377
|
for (const candidate of candidates) {
|
|
427
378
|
try {
|
|
@@ -445,6 +396,9 @@ export interface GenerationParams {
|
|
|
445
396
|
seed?: number;
|
|
446
397
|
}
|
|
447
398
|
|
|
399
|
+
/** Most stop sequences a Chat Completions request may carry (OpenRouter chat-completion api-ref `maxItems`, OpenAI the same). */
|
|
400
|
+
const MAX_CHAT_STOP = 4;
|
|
401
|
+
// True for a finite number; in: the generation-param mappers, out: a type guard.
|
|
448
402
|
const finiteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
449
403
|
|
|
450
404
|
/** Normalizes the public `stop` option (`string | string[]`) to a clean array, or undefined. */
|
|
@@ -460,15 +414,27 @@ export function normalizeStop(stop: string | string[] | undefined): string[] | u
|
|
|
460
414
|
* `max_tokens`, `stop`, `seed`. Only set values are
|
|
461
415
|
* emitted, so strict endpoints never see anything the caller did not ask for.
|
|
462
416
|
*/
|
|
463
|
-
export function mapGenerationParamsToChat(params: GenerationParams | undefined): Record<string, unknown> {
|
|
417
|
+
export function mapGenerationParamsToChat(params: GenerationParams | undefined, provider = "chat", modelRef = ""): Record<string, unknown> {
|
|
464
418
|
const out: Record<string, unknown> = {};
|
|
465
419
|
if (!params) return out;
|
|
466
420
|
if (finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0) out["max_tokens"] = Math.floor(params.maxOutputTokens);
|
|
467
|
-
if (params.stop && params.stop.length > 0)
|
|
421
|
+
if (params.stop && params.stop.length > 0) {
|
|
422
|
+
out["stop"] = params.stop.slice(0, MAX_CHAT_STOP);
|
|
423
|
+
if (params.stop.length > MAX_CHAT_STOP) {
|
|
424
|
+
emitProviderWarning({
|
|
425
|
+
provider,
|
|
426
|
+
capability: "stop sequences",
|
|
427
|
+
requested: `${params.stop.length} sequences (${modelRef})`,
|
|
428
|
+
reason: `Chat Completions allows at most ${MAX_CHAT_STOP} stop sequences.`,
|
|
429
|
+
fallback: `the first ${MAX_CHAT_STOP}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
468
433
|
if (finiteNumber(params.seed)) out["seed"] = Math.floor(params.seed);
|
|
469
434
|
return out;
|
|
470
435
|
}
|
|
471
436
|
|
|
437
|
+
// Warns that `stop`/`seed` have no parameter on this wire and are dropped; in: the OpenAI and Google mappers, out: provider_warning events.
|
|
472
438
|
function warnUnsupportedStopSeed(provider: string, modelRef: string, params: GenerationParams, reasonSuffix: string): void {
|
|
473
439
|
if (params.stop && params.stop.length > 0) {
|
|
474
440
|
emitProviderWarning({
|
|
@@ -518,6 +484,7 @@ export function mapGenerationParamsToGoogle(
|
|
|
518
484
|
// Structured outputs (canonical, pure, no I/O)
|
|
519
485
|
// ---------------------------------------------------------------------------
|
|
520
486
|
|
|
487
|
+
// True when a value looks like a Zod schema (parse/safeParse/_def/~standard); in: structured-output normalization, out: a boolean.
|
|
521
488
|
function isZodLike(value: unknown): boolean {
|
|
522
489
|
if (!value || typeof value !== "object") return false;
|
|
523
490
|
const v = value as Record<string, unknown>;
|
|
@@ -529,6 +496,7 @@ function isZodLike(value: unknown): boolean {
|
|
|
529
496
|
);
|
|
530
497
|
}
|
|
531
498
|
|
|
499
|
+
// True when a plain object has JSON Schema keywords; in: structured-output normalization, out: a boolean.
|
|
532
500
|
function looksLikeJsonSchema(value: unknown): boolean {
|
|
533
501
|
if (!isPlainObject(value)) return false;
|
|
534
502
|
const v = value as Record<string, unknown>;
|
|
@@ -544,12 +512,14 @@ function looksLikeJsonSchema(value: unknown): boolean {
|
|
|
544
512
|
);
|
|
545
513
|
}
|
|
546
514
|
|
|
515
|
+
// A structured-output name limited to letters, digits, `_` and `-` (64 chars), default `structured_output`; in: normalizeStructuredOutput, out: the name.
|
|
547
516
|
function sanitizeOutputName(raw: unknown): string {
|
|
548
517
|
const base = typeof raw === "string" && raw.trim() ? raw.trim() : "structured_output";
|
|
549
518
|
const cleaned = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "structured_output";
|
|
550
519
|
return cleaned;
|
|
551
520
|
}
|
|
552
521
|
|
|
522
|
+
// A Zod schema or JSON Schema object as a JSON Schema object, else a ValidationError; in: normalizeStructuredOutput, out: the schema.
|
|
553
523
|
function schemaToJsonSchema(schema: unknown, what: string): Record<string, unknown> {
|
|
554
524
|
if (isZodLike(schema)) {
|
|
555
525
|
try {
|
|
@@ -619,6 +589,7 @@ export function extractOutputValidator(output: unknown): { safeParse: (v: unknow
|
|
|
619
589
|
return undefined;
|
|
620
590
|
}
|
|
621
591
|
|
|
592
|
+
// Removes a surrounding ```json fence from a model answer; in: parseStructuredOutput, out: the bare text.
|
|
622
593
|
function stripCodeFences(text: string): string {
|
|
623
594
|
const trimmed = text.trim();
|
|
624
595
|
const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# src/scheduler/
|
|
2
|
+
|
|
3
|
+
Scheduled actions: a worker that runs later (once or repeating) and saves its answer to a file. Off unless `dynamicSubagents.allowScheduledActions` is set.
|
|
4
|
+
|
|
5
|
+
| File | What it is |
|
|
6
|
+
| :-- | :-- |
|
|
7
|
+
| `scheduler.ts` | One `Scheduler` per home folder: a 1 s unref'd clock that runs only while something is pending, startup sweep, cook/deliver state machine, and the public functions (`createScheduledAction`, `list/update/run/skip/deleteScheduledAction`). |
|
|
8
|
+
| `store.ts` | Disk layer under `<home>/scheduled_actions/`: `tasks.json`, `deliveries.json`, run files; validation of records. |
|
|
9
|
+
| `time.ts` | Pure `DD:HH:MM` and local-clock maths. No timezone, DST or `Intl`; `now` is always passed in. |
|
|
10
|
+
| `tool.ts` + `scheduled-actions.md` | The model-facing `scheduled_actions` tool and `get_datetime`; the `.md` is the tool description. |
|
|
11
|
+
|
|
12
|
+
## Invariants
|
|
13
|
+
- The files are the truth: every write re-reads first and goes through the durable writer. The file format is a stable public contract: see `docs/scheduled-action-files.md` and add a changelog row for any change.
|
|
14
|
+
- Write order when a run ends: run file, then `deliveries.json`, then `tasks.json`. The startup sweep repairs a crash in between.
|
|
15
|
+
- `startCook` advances the next times BEFORE the run starts; a run still cooking when the next is due is skipped, never stacked.
|
|
16
|
+
- Runs are fresh stateless workers built by `buildDynamicWorkerConfig`; their spending guardrail is `workerBudget` only.
|
|
17
|
+
- The Agent registry holds `WeakRef`s by name: keep every Agent that owns actions referenced, or its runs become `missed`.
|
|
18
|
+
- Times come from `Date`, local clock only. Tests inject `scheduler.now` / `tickMs` and call `tick()`.
|
|
19
|
+
- Events: `scheduled_action.{created,updated,started,ready,failed,delivered,missed,skipped,deleted,invalid,store_corrupt,error}`.
|
|
@@ -81,6 +81,7 @@ interface RunningEntry {
|
|
|
81
81
|
missed: boolean;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// The message of any thrown value; in: catch blocks here, out: event and error text.
|
|
84
85
|
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
85
86
|
|
|
86
87
|
// 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.
|
|
@@ -106,10 +107,12 @@ function usageFields(totals: UsageTotals | undefined): Partial<RunHeader> {
|
|
|
106
107
|
// 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
108
|
const secondOf = (d: Date): number => Math.floor(d.getTime() / 1000);
|
|
108
109
|
|
|
110
|
+
// Emits one `scheduled_action.<code>` event on the telemetry bus; in: the scheduler and public functions, out: telemetryBus.
|
|
109
111
|
function emit(code: string, message: string, data: Record<string, unknown>, level: "info" | "warn" = "info"): void {
|
|
110
112
|
telemetryBus.emit({ code: `scheduled_action.${code}`, message: `[Agent Accelerator] ${message}`, level, data });
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
// The ValidationError for an unknown action id; in: lookups by id, out: a thrown error.
|
|
113
116
|
function notFound(id: string): ValidationError {
|
|
114
117
|
return new ValidationError(`No scheduled action with id "${id}".`);
|
|
115
118
|
}
|
|
@@ -118,6 +121,7 @@ function notFound(id: string): ValidationError {
|
|
|
118
121
|
// Validation (collects EVERY problem so the model can fix a call in one go)
|
|
119
122
|
// ---------------------------------------------------------------------------
|
|
120
123
|
|
|
124
|
+
// Runs one validation step and records its message instead of throwing, so one call can report every problem; in: validateInput/checkSchedule, out: the problems list.
|
|
121
125
|
function collect(problems: string[], fn: () => void): void {
|
|
122
126
|
try {
|
|
123
127
|
fn();
|
|
@@ -126,10 +130,12 @@ function collect(problems: string[], fn: () => void): void {
|
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
// Throws unless the value is a non-empty string; in: field validation, out: collect.
|
|
129
134
|
function requireText(field: string, value: unknown): void {
|
|
130
135
|
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${field}: required, a non-empty string.`);
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
// Throws unless every requested worker tool name exists in the developer's pool; in: create/update validation, out: collect.
|
|
133
139
|
function checkTools(value: unknown, pool: PoolTools): void {
|
|
134
140
|
if (!Array.isArray(value) || value.some((n) => typeof n !== "string")) {
|
|
135
141
|
throw new ValidationError("tools: must be an array of worker tool names, e.g. [\"recent_news\"].");
|
|
@@ -174,6 +180,7 @@ function checkSchedule(
|
|
|
174
180
|
}
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
// Validates role, tools and timeoutMs of a create or update; in: validateInput/updateScheduledAction, out: the problems list.
|
|
177
184
|
function checkOptionalFields(input: Pick<ScheduledActionPatch, "role" | "tools" | "timeoutMs">, pool: PoolTools | undefined, problems: string[]): void {
|
|
178
185
|
if (input.role !== undefined && typeof input.role !== "string") problems.push("role: must be a string.");
|
|
179
186
|
if (input.tools !== undefined && pool) collect(problems, () => checkTools(input.tools, pool));
|
|
@@ -208,9 +215,11 @@ export function validateInput(
|
|
|
208
215
|
return problems;
|
|
209
216
|
}
|
|
210
217
|
|
|
218
|
+
// Joins validation problems into one numbered ValidationError message; in: create/update failures, out: the error text.
|
|
211
219
|
const joinProblems = (what: string, problems: string[]): string =>
|
|
212
220
|
`${what} (${problems.length} problem${problems.length === 1 ? "" : "s"}): ${problems.map((p, i) => `(${i + 1}) ${p}`).join(" ")}`;
|
|
213
221
|
|
|
222
|
+
// Six random lowercase/digit characters appended to an action id; in: createScheduledAction, out: the unique id suffix.
|
|
214
223
|
function randomSuffix(): string {
|
|
215
224
|
const bytes = new Uint8Array(6);
|
|
216
225
|
globalThis.crypto.getRandomValues(bytes);
|
|
@@ -244,6 +253,7 @@ export class Scheduler {
|
|
|
244
253
|
(this.timer as { unref?: () => void }).unref?.();
|
|
245
254
|
}
|
|
246
255
|
|
|
256
|
+
// Stops the clock's interval; in: tick when nothing is pending and resetSchedulers, out: no more ticks.
|
|
247
257
|
stop(): void {
|
|
248
258
|
if (this.timer) clearInterval(this.timer);
|
|
249
259
|
this.timer = undefined;
|
|
@@ -266,6 +276,7 @@ export class Scheduler {
|
|
|
266
276
|
}
|
|
267
277
|
}
|
|
268
278
|
|
|
279
|
+
// Reports an invalid record or delivery once per file/id/problem and leaves the file untouched; in: snapshot/startCook, out: a scheduled_action.invalid warning.
|
|
269
280
|
private warnInvalid(id: string, problem: string, file = "tasks.json"): void {
|
|
270
281
|
const key = `${file}:${id}:${problem}`;
|
|
271
282
|
if (this.warned.has(key)) return;
|
|
@@ -292,6 +303,7 @@ export class Scheduler {
|
|
|
292
303
|
return { live, pending };
|
|
293
304
|
}
|
|
294
305
|
|
|
306
|
+
// True while anything keeps the clock useful (a run in flight, a saved delivery, a waiting or cooking action); in: ensureRunning and tick, out: start/stop of the interval.
|
|
295
307
|
private hasPending(): boolean {
|
|
296
308
|
const { live, pending } = this.snapshot();
|
|
297
309
|
return this.running.size > 0 || pending.length > 0 || live.some((t) => t.state === "waiting" || t.state === "cooking");
|
|
@@ -359,6 +371,7 @@ export class Scheduler {
|
|
|
359
371
|
|
|
360
372
|
// ----- delivery ----------------------------------------------------------
|
|
361
373
|
|
|
374
|
+
// Delivers every saved run whose delivery time has come; in: tick, out: deliver calls.
|
|
362
375
|
private deliverDue(now: Date): void {
|
|
363
376
|
for (const entry of this.snapshot().pending) {
|
|
364
377
|
if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now);
|
|
@@ -394,6 +407,7 @@ export class Scheduler {
|
|
|
394
407
|
|
|
395
408
|
// ----- starting runs -----------------------------------------------------
|
|
396
409
|
|
|
410
|
+
// Starts waiting actions whose cook time has come and skips the overlap of those still cooking; in: tick, out: startCook/skipOverlap calls.
|
|
397
411
|
private startDue(now: Date): void {
|
|
398
412
|
for (const t of this.snapshot().live) {
|
|
399
413
|
if (!t.nextCookAt || parseLocalIso(t.nextCookAt).getTime() > now.getTime()) continue;
|
|
@@ -468,6 +482,7 @@ export class Scheduler {
|
|
|
468
482
|
return started;
|
|
469
483
|
}
|
|
470
484
|
|
|
485
|
+
// The display name of an action from tasks.json, else its id; in: startCook's no-agent message, out: event text.
|
|
471
486
|
private nameOf(id: string): string {
|
|
472
487
|
return readTasks(this.home).find((t) => t.id === id)?.name ?? id;
|
|
473
488
|
}
|
|
@@ -511,6 +526,7 @@ export class Scheduler {
|
|
|
511
526
|
|
|
512
527
|
// ----- the run -----------------------------------------------------------
|
|
513
528
|
|
|
529
|
+
// Runs one action to its outcome and writes the result unless the action was deleted meanwhile; in: launch, out: finish.
|
|
514
530
|
private async runWorker(task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<void> {
|
|
515
531
|
const deliverAt = parseLocalIso(task.current!.deliverAt);
|
|
516
532
|
const outcome = await this.execute(task, agent, entry, deliverAt);
|
|
@@ -556,6 +572,7 @@ export class Scheduler {
|
|
|
556
572
|
}
|
|
557
573
|
}
|
|
558
574
|
|
|
575
|
+
// One worker run with a per-attempt timeout and the delete signal, raced so a hung provider cannot hold it; in: execute, out: the worker's AgentResponse.
|
|
559
576
|
private async attempt(worker: Agent, task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<AgentResponse> {
|
|
560
577
|
const ms = scheduledRunTimeoutMs(agent.dynamicSubagents?.workerTimeoutMs ?? Infinity, task.timeoutMs);
|
|
561
578
|
// One controller per attempt (the timeout is per attempt), fed by the entry's delete controller.
|
|
@@ -613,6 +630,7 @@ export class Scheduler {
|
|
|
613
630
|
|
|
614
631
|
// ----- commands ----------------------------------------------------------
|
|
615
632
|
|
|
633
|
+
// Starts an action immediately outside its schedule (also resolves a missed one); in: runScheduledAction, out: the cooking record.
|
|
616
634
|
runNow(id: string): ScheduledActionRecord {
|
|
617
635
|
const rec = readTasks(this.home).find((t) => t.id === id);
|
|
618
636
|
if (!rec) throw notFound(id);
|
|
@@ -629,6 +647,7 @@ export class Scheduler {
|
|
|
629
647
|
return started;
|
|
630
648
|
}
|
|
631
649
|
|
|
650
|
+
// Ignores a missed occurrence and schedules the next one (a one-shot becomes done); in: skipScheduledAction and onMissed: skip, out: the updated record.
|
|
632
651
|
skip(id: string): ScheduledActionRecord {
|
|
633
652
|
const now = this.now();
|
|
634
653
|
const rec = updateTasks(this.home, (tasks) => {
|
|
@@ -647,6 +666,7 @@ export class Scheduler {
|
|
|
647
666
|
return rec;
|
|
648
667
|
}
|
|
649
668
|
|
|
669
|
+
// Deletes an action (aborting a cooking run only with force) and optionally purges its run files; in: deleteScheduledAction, out: a DeleteResult.
|
|
650
670
|
async delete(id: string, opts: { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
|
|
651
671
|
const rec = readTasks(this.home).find((t) => t.id === id);
|
|
652
672
|
if (!rec) return { ok: false, reason: "not_found" };
|
|
@@ -699,6 +719,7 @@ export function resetSchedulers(): void {
|
|
|
699
719
|
schedulers.clear();
|
|
700
720
|
}
|
|
701
721
|
|
|
722
|
+
// The scheduler of a resolved home (option, setHome, env or default); in: the public functions, out: a Scheduler.
|
|
702
723
|
const schedulerFor = (opts?: HomeOptions): Scheduler => getScheduler(resolveHome(opts?.home));
|
|
703
724
|
|
|
704
725
|
/** Makes `agent` the live runner for actions created under its name, and starts the clock if work is pending. */
|
package/src/scheduler/store.ts
CHANGED
|
@@ -72,8 +72,11 @@ export interface RunHeader {
|
|
|
72
72
|
error?: string;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The `scheduled_actions` folder of a home; in: the path helpers, out: its path.
|
|
75
76
|
const baseDir = (home: string): string => path.join(home, "scheduled_actions");
|
|
77
|
+
// Path of tasks.json; in: readTasks/updateTasks, out: its path.
|
|
76
78
|
const tasksFile = (home: string): string => path.join(baseDir(home), "tasks.json");
|
|
79
|
+
// Path of deliveries.json; in: readDeliveries/updateDeliveries, out: its path.
|
|
77
80
|
const deliveriesFile = (home: string): string => path.join(baseDir(home), "deliveries.json");
|
|
78
81
|
|
|
79
82
|
/** Absolute path of a run file given its `scheduled_actions/`-relative path. */
|
|
@@ -134,6 +137,7 @@ function readList(file: string, key: "tasks" | "pending"): Raw[] {
|
|
|
134
137
|
return list ?? [];
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
// Writes `{ version, <key>: [...] }` durably (with a .bak); in: updateTasks/updateDeliveries, out: the file.
|
|
137
141
|
function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
|
|
138
142
|
writeFileDurable(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
|
|
139
143
|
}
|
|
@@ -151,6 +155,7 @@ export function updateTasks<T>(home: string, fn: (tasks: ScheduledActionRecord[]
|
|
|
151
155
|
return result;
|
|
152
156
|
}
|
|
153
157
|
|
|
158
|
+
/** Every saved delivery as stored (entries may be invalid: check with validateDelivery); in: the scheduler, out: PendingDelivery[]. */
|
|
154
159
|
export function readDeliveries(home: string): PendingDelivery[] {
|
|
155
160
|
return readList(deliveriesFile(home), "pending") as unknown as PendingDelivery[];
|
|
156
161
|
}
|
|
@@ -229,6 +234,7 @@ export function validateDelivery(raw: unknown): string | undefined {
|
|
|
229
234
|
// Run files
|
|
230
235
|
// ---------------------------------------------------------------------------
|
|
231
236
|
|
|
237
|
+
// Renders a run file: the `---` header lines, a blank line, the result text; in: writeRunFile, out: the file text.
|
|
232
238
|
function renderRun(header: RunHeader, body: string): string {
|
|
233
239
|
const lines = ["---"];
|
|
234
240
|
for (const [key, value] of Object.entries(header)) {
|
package/src/scheduler/time.ts
CHANGED
|
@@ -15,7 +15,9 @@ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
|
|
|
15
15
|
const DDHHMM = /^(\d{2,}):(\d{2}):(\d{2})$/;
|
|
16
16
|
const LOCAL_ISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
|
|
17
17
|
|
|
18
|
+
// Two-digit zero padding; in: date formatting here, out: a string.
|
|
18
19
|
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
20
|
+
// JSON text of a value for error messages; in: the validators, out: a string.
|
|
19
21
|
const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
|
|
20
22
|
|
|
21
23
|
/** Parses `DD:HH:MM` (DD >= 2 digits, MM < 60). HH is range-checked by the caller. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# src/session/
|
|
2
|
+
|
|
3
|
+
Saving a conversation, its spending and its tool logs.
|
|
4
|
+
|
|
5
|
+
| File | What it is |
|
|
6
|
+
| :-- | :-- |
|
|
7
|
+
| `store.ts` | Snapshot format (`session.json`, one line per field and per turn), load validation, `SessionTelemetry`, media extraction, journal replay (`replayJournal`), and `priceUsage` (the one cost model, integer micro-USD). |
|
|
8
|
+
| `journal.ts` | `SessionJournal`: after each step appends one fsynced line to `journal.ndjson`; writes a fresh snapshot (new `epoch`) when history changed any other way or the journal passed 500 lines / 4 MB. |
|
|
9
|
+
| `usage.ts` | Pure spending accounting: `UsageTotals`, `usageLine`, `buildUsageReport`. No Agent import. |
|
|
10
|
+
| `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
|
|
11
|
+
|
|
12
|
+
## Invariants
|
|
13
|
+
- Layout: `<home>/sessions/<id>/{session.json, journal.ndjson, media/, tool-logs/, workers/}`.
|
|
14
|
+
- A journal whose header epoch differs from the snapshot's is ignored on load.
|
|
15
|
+
- Journal record types: `base`, `msg`, `meta`, `run`, `end`, `tool`, `pending`, `usage`. Replay ignores unknown types, drops a torn last line, and stops at earlier damage with `session.journal_corrupt`.
|
|
16
|
+
- Media is content-addressed (`media/<kind>-h<hash>.<ext>`), written once, never deleted.
|
|
17
|
+
- Valid sessions over the load limits throw `SessionLoadError` and are never quarantined; only corrupt files are renamed `*.corrupt-<ts>`.
|
|
18
|
+
- Pricing: a provider-reported cost wins and is never rescaled; unknown pricing stays unknown, never $0; cache reads and writes are subsets of input.
|
|
19
|
+
- Never call `saveSessionDir` on a folder an Agent persists to.
|