1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
|
import type {
ChatMessage,
ProviderContract,
ReasoningEffort,
ToolDispatchPolicy,
} from "@dispatch/kernel";
export function buildUserMessage(text: string): ChatMessage {
return { role: "user", chunks: [{ type: "text", text }] };
}
// ── Provider-error retry backoff schedule ───────────────────────────────────
//
// Pure, deterministic delay decision (no I/O, no clock) for retrying retryable
// provider errors (HTTP 429 / 5xx "overloaded"). The concrete `sleep` (I/O)
// is wired in the orchestrator; this owns only the policy.
/**
* Stepped backoff schedule (ms): 5s, 10s, 30s, 60s, 5m, 10m, 15m, 30m.
* After the head is exhausted, {@link RETRY_TAIL_MS} (30m) repeats.
*/
export const RETRY_SCHEDULE_MS = [
5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000,
] as const;
/** Tail delay (ms) repeated after the stepped head: 30 minutes. */
export const RETRY_TAIL_MS = 1_800_000;
/** Cumulative scheduled-sleep budget (ms) after which retrying gives up: 8h. */
export const RETRY_BUDGET_MS = 8 * 60 * 60 * 1000;
/**
* Cumulative scheduled sleep through `attempt` (sum of delay[0..attempt]).
* Pure — no I/O, no clock.
*/
export function cumulativeSleepMs(attempt: number): number {
let sum = 0;
for (let i = 0; i <= attempt; i++) {
sum += i < RETRY_SCHEDULE_MS.length ? (RETRY_SCHEDULE_MS[i] ?? RETRY_TAIL_MS) : RETRY_TAIL_MS;
}
return sum;
}
/**
* Pure, deterministic delay decision for the retry strategy: given the
* 0-based attempt index, return the delay in ms to sleep before the next
* retry, or `undefined` to stop (cumulative budget exhausted). No I/O, no
* clock — fully testable. Matches the plan's schedule:
* `5s, 10s, 30s, 60s, 5m, 10m, 15m, 30m`, then repeat 30m until 8h of
* cumulative scheduled sleep is reached, then give up.
*/
export function delayFor(attempt: number): number | undefined {
const scheduled = RETRY_SCHEDULE_MS[attempt];
const delay = scheduled !== undefined ? scheduled : RETRY_TAIL_MS;
if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop
return delay;
}
/**
* Resolve the reasoning-effort level for a turn:
* per-turn override → persisted per-conversation value → default `"high"`.
* Pure — no I/O, no ambient state.
*/
export function resolveReasoningEffort(
override: ReasoningEffort | undefined,
stored: ReasoningEffort | null,
): ReasoningEffort {
return override ?? stored ?? "high";
}
/**
* Resolve the model name for a turn:
* per-turn override → persisted per-conversation value → `undefined`.
*
* Unlike {@link resolveReasoningEffort}, there is NO default model name: when
* both the override and the persisted value are absent, this returns
* `undefined` and the caller falls through to `resolveProvider()` (the default
* provider). Returning `undefined` (rather than a sentinel) keeps the existing
* "no model override" code path untouched. Pure — no I/O, no ambient state.
*/
export function resolveModelName(
override: string | undefined,
stored: string | null,
): string | undefined {
return override ?? stored ?? undefined;
}
export function selectFirstProvider(
providers: ReadonlyMap<string, ProviderContract>,
): ProviderContract {
const first = providers.values().next();
if (first.done === true || first.value === undefined) {
throw new Error("No providers registered — at least one provider is required to run a turn.");
}
return first.value;
}
export function resolveTools(tools: ReadonlyMap<string, unknown>): readonly unknown[] {
return [...tools.values()];
}
export function defaultDispatchPolicy(): ToolDispatchPolicy {
return { maxConcurrent: 1, eager: true };
}
export function generateTurnId(): string {
return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
|