@alma-harness/core 0.1.0
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/LICENSE +202 -0
- package/README.md +71 -0
- package/dist/chunk-KPNXPUGR.js +125 -0
- package/dist/chunk-KPNXPUGR.js.map +1 -0
- package/dist/index.d.ts +789 -0
- package/dist/index.js +127 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/index.d.ts +74 -0
- package/dist/testing/index.js +268 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/turn-store-uKJ4inz2.d.ts +1054 -0
- package/package.json +43 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy scope — the isolation boundary of the whole harness.
|
|
3
|
+
*
|
|
4
|
+
* `{org, uid}` derives from a server-verified token, never from a client
|
|
5
|
+
* payload, and is bound to tool registries by closure at construction time —
|
|
6
|
+
* the model never passes org/uid as parameters.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/architecture.md §6.1, §6.8
|
|
9
|
+
*/
|
|
10
|
+
interface Scope {
|
|
11
|
+
/** Organization (tenant) id. */
|
|
12
|
+
readonly org: string;
|
|
13
|
+
/** User id within the organization. */
|
|
14
|
+
readonly uid: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Thrown by {@link scopePath} when a scope segment could be used for path
|
|
18
|
+
* traversal or key-delimiter injection.
|
|
19
|
+
*/
|
|
20
|
+
declare class InvalidScopeError extends Error {
|
|
21
|
+
constructor(segment: "org" | "uid", value: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The single place that builds `tenants/{org}/users/{uid}` — §6.8.
|
|
25
|
+
*
|
|
26
|
+
* A *concept*, not a literal storage path: the Firestore adapter renders it as
|
|
27
|
+
* a collection path, the Postgres adapter as row-level-security predicates
|
|
28
|
+
* (§6). Every store keys its data by this concept so scoped purge and export
|
|
29
|
+
* have one canonical addressing scheme.
|
|
30
|
+
*/
|
|
31
|
+
declare function scopePath(scope: Scope): string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Neutral message format — §6.1.
|
|
35
|
+
*
|
|
36
|
+
* The harness defines its own message vocabulary; provider adapters translate
|
|
37
|
+
* to/from each provider's wire format. Sessions persist ONLY this format — no
|
|
38
|
+
* opaque CLI transcripts, no derived project keys.
|
|
39
|
+
*/
|
|
40
|
+
type MediaKind = "image" | "audio" | "document";
|
|
41
|
+
/**
|
|
42
|
+
* Reference to media held in product-owned storage — §6.1: media travels by
|
|
43
|
+
* reference and is never persisted inline.
|
|
44
|
+
*
|
|
45
|
+
* DECISION: the doc leaves the shape open; we use an opaque URI plus optional
|
|
46
|
+
* metadata. The product decides how the URI is dereferenced (GCS, S3, …); the
|
|
47
|
+
* harness never fetches it implicitly.
|
|
48
|
+
*/
|
|
49
|
+
interface MediaRef {
|
|
50
|
+
uri: string;
|
|
51
|
+
contentType?: string;
|
|
52
|
+
bytes?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Display name the person saw — spec 040. Optional, and deliberately not
|
|
55
|
+
* derived from `uri`: a storage path is an id, and the two coincide only by
|
|
56
|
+
* accident.
|
|
57
|
+
*
|
|
58
|
+
* Added for a migration that measured both cases. One store being moved
|
|
59
|
+
* repeats the name inside the message text (168 of 168 messages with an
|
|
60
|
+
* attachment), so the field is redundant there; another carries it in 125 of
|
|
61
|
+
* 125 document messages and in the text in NONE, so it is the only copy.
|
|
62
|
+
* Reconstructing it by parsing prose is the failure mode this avoids.
|
|
63
|
+
*/
|
|
64
|
+
filename?: string;
|
|
65
|
+
}
|
|
66
|
+
/** One content block of a message — §6.1. */
|
|
67
|
+
type Block = {
|
|
68
|
+
type: "text";
|
|
69
|
+
text: string;
|
|
70
|
+
} | {
|
|
71
|
+
type: "tool_call";
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
input: unknown;
|
|
75
|
+
} | {
|
|
76
|
+
type: "tool_result";
|
|
77
|
+
callId: string;
|
|
78
|
+
output: unknown;
|
|
79
|
+
isError?: boolean;
|
|
80
|
+
} | {
|
|
81
|
+
type: "media";
|
|
82
|
+
kind: MediaKind;
|
|
83
|
+
ref: MediaRef;
|
|
84
|
+
};
|
|
85
|
+
interface MsgMeta {
|
|
86
|
+
/** ISO 8601 timestamp. */
|
|
87
|
+
at: string;
|
|
88
|
+
/** Channel the message arrived on / was sent to (e.g. "whatsapp", "portal"). */
|
|
89
|
+
channel?: string;
|
|
90
|
+
/** Model that produced an assistant message (e.g. "anthropic/<model-id>"). */
|
|
91
|
+
model?: string;
|
|
92
|
+
}
|
|
93
|
+
/** A conversation message in the neutral format — §6.1. */
|
|
94
|
+
interface Msg {
|
|
95
|
+
role: "user" | "assistant" | "tool";
|
|
96
|
+
blocks: Block[];
|
|
97
|
+
meta?: MsgMeta;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Model client contract — §6.2. One adapter per provider lives in
|
|
102
|
+
* `@alma-harness/providers`; each adapter swallows the provider differences
|
|
103
|
+
* (tool-call shapes, streaming, caching, structured output, token accounting)
|
|
104
|
+
* and has contract tests with recorded fixtures to catch API drift.
|
|
105
|
+
*/
|
|
106
|
+
/**
|
|
107
|
+
* The providers Alma knows by name — a CLOSED union, because the price table,
|
|
108
|
+
* the client map, and the routing trail all key on it. Anthropic and OpenAI
|
|
109
|
+
* from birth (§3 principle 4); `openrouter` is the gateway bridge (spec:
|
|
110
|
+
* openrouter-gateway) — a gateway does not answer "who processed this data?"
|
|
111
|
+
* by itself, so its adapter requires a declared upstream allowlist.
|
|
112
|
+
*/
|
|
113
|
+
type ProviderId = "anthropic" | "openai" | "openrouter";
|
|
114
|
+
interface ModelRef {
|
|
115
|
+
provider: ProviderId;
|
|
116
|
+
/** Provider-native model id. */
|
|
117
|
+
id: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* System prompt block carrying the stable/volatile boundary — §6.2, §6.9.
|
|
121
|
+
* Stable blocks (persona, guidance) form the cacheable prefix; volatile blocks
|
|
122
|
+
* (date/time, interlocutor identity, recall) come after it. One discipline
|
|
123
|
+
* optimizes caching on both providers.
|
|
124
|
+
*/
|
|
125
|
+
interface SystemBlock {
|
|
126
|
+
text: string;
|
|
127
|
+
volatility: "stable" | "volatile";
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Wire spec of a tool as sent to the model — §6.4. Always DERIVED from the
|
|
131
|
+
* scoped registry, never a hand-maintained parallel list: the "tool exists on
|
|
132
|
+
* the server but is missing from the allowlist" bug class ceases to exist.
|
|
133
|
+
*/
|
|
134
|
+
interface ToolSpec {
|
|
135
|
+
name: string;
|
|
136
|
+
description: string;
|
|
137
|
+
/** JSON Schema derived from the registered validation schema. */
|
|
138
|
+
inputSchema: Record<string, unknown>;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Token accounting for one model call — §6.2, §6.5.
|
|
142
|
+
*
|
|
143
|
+
* DECISION (spec 005): `inputTokens` EXCLUDES cache reads — it counts
|
|
144
|
+
* uncached, full-price input tokens only. Adapters normalize their wire
|
|
145
|
+
* semantics to this (Anthropic already reports it this way; the OpenAI
|
|
146
|
+
* adapter subtracts `cached_tokens`), so the BudgetGuard prices every
|
|
147
|
+
* provider identically.
|
|
148
|
+
*/
|
|
149
|
+
interface Usage {
|
|
150
|
+
inputTokens: number;
|
|
151
|
+
outputTokens: number;
|
|
152
|
+
cacheReadInputTokens?: number;
|
|
153
|
+
cacheWriteInputTokens?: number;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* DECISION: neutral stop vocabulary; adapters map provider-specific reasons
|
|
157
|
+
* (e.g. OpenAI `finish_reason`, Anthropic `stop_reason`) onto it.
|
|
158
|
+
* `refusal` (added by spec 002) is a provider-level safety decline — a harness
|
|
159
|
+
* serving people surfaces it as a first-class terminal state, never as a
|
|
160
|
+
* normal `end_turn`. `context_window_exceeded` (spec 002 review) means the
|
|
161
|
+
* conversation no longer fits the model's context window — distinct from
|
|
162
|
+
* `max_tokens` (output cap) so the long-context policy (§6.6) can react by
|
|
163
|
+
* pruning or summarizing.
|
|
164
|
+
*/
|
|
165
|
+
type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal" | "context_window_exceeded";
|
|
166
|
+
/**
|
|
167
|
+
* Neutral streaming event — §6.2 names the kinds ("text deltas, tool calls,
|
|
168
|
+
* usage"); DECISION: this union is the exact vocabulary both adapters must
|
|
169
|
+
* translate into. `tool_call` is emitted once per call with fully-parsed input.
|
|
170
|
+
*/
|
|
171
|
+
type ModelEvent = {
|
|
172
|
+
type: "text_delta";
|
|
173
|
+
text: string;
|
|
174
|
+
} | {
|
|
175
|
+
type: "tool_call";
|
|
176
|
+
id: string;
|
|
177
|
+
name: string;
|
|
178
|
+
input: unknown;
|
|
179
|
+
} | {
|
|
180
|
+
type: "usage";
|
|
181
|
+
usage: Usage;
|
|
182
|
+
} | {
|
|
183
|
+
type: "stop";
|
|
184
|
+
reason: StopReason;
|
|
185
|
+
};
|
|
186
|
+
interface ModelRequest {
|
|
187
|
+
model: ModelRef;
|
|
188
|
+
/** With stable/volatile boundary markers — §6.9. */
|
|
189
|
+
system: SystemBlock[];
|
|
190
|
+
messages: Msg[];
|
|
191
|
+
/** Derived from the registry — never a parallel list (§6.4). */
|
|
192
|
+
tools: ToolSpec[];
|
|
193
|
+
maxTokens: number;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* §6.2. `signal` (added by spec 005 review) lets the loop abort the in-flight
|
|
197
|
+
* provider call on cancellation or budget exhaustion — without it, tokens
|
|
198
|
+
* keep generating (and billing) after the harness has already terminated the
|
|
199
|
+
* turn, and a hung stream would keep the turn promise pending forever.
|
|
200
|
+
*/
|
|
201
|
+
interface ModelClient {
|
|
202
|
+
stream(req: ModelRequest, opts?: {
|
|
203
|
+
signal?: AbortSignal;
|
|
204
|
+
}): AsyncIterable<ModelEvent>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Budget — §6.5, spec: spend-store. Metering is mandatory; POLICY at the cap
|
|
209
|
+
* is product-owned. `perTurnUsd` is the one unconditional hard stop — at a
|
|
210
|
+
* sane level it trips only on malfunction (a tool loop), never on a
|
|
211
|
+
* legitimate conversation, and the session survives it. The persistent caps
|
|
212
|
+
* default to `warn` because this harness sits in front of people in fragile
|
|
213
|
+
* moments: a mid-conversation "budget exceeded" is a worse failure than the
|
|
214
|
+
* overspend. `BudgetGuard` enforcement is part of the privileged core — not a
|
|
215
|
+
* capability seam (§7.1); the `SpendStore` it accounts through is one.
|
|
216
|
+
*/
|
|
217
|
+
/** The two caps that need spend surviving the turn — spec: spend-store. */
|
|
218
|
+
type PersistentCapName = "perSessionUsd" | "perTenantDayUsd";
|
|
219
|
+
/** A persistent cap with its crossing policy. */
|
|
220
|
+
interface PersistentCap {
|
|
221
|
+
usd: number;
|
|
222
|
+
/**
|
|
223
|
+
* DECISION (spec: spend-store): defaults to `"warn"` — the turn continues
|
|
224
|
+
* and the crossing lands on the cost trail and the `TurnResult`, exactly
|
|
225
|
+
* once per cap per turn. `"block"` (terminate `budget_exceeded`, refuse new
|
|
226
|
+
* turns at preflight) is the opt-in for machine-facing consumers — an eval
|
|
227
|
+
* sweep, a public agent's kill-switch — never the ambient default.
|
|
228
|
+
*/
|
|
229
|
+
onExceeded?: "warn" | "block";
|
|
230
|
+
}
|
|
231
|
+
/** Dollar caps — §6.5, §8. All optional; an absent cap is uncapped. */
|
|
232
|
+
interface BudgetCaps {
|
|
233
|
+
/**
|
|
234
|
+
* Hard cap for a single turn. DECISION: a triggered turn (routine run, §8)
|
|
235
|
+
* is one turn, so this is also the per-run cap — no separate field.
|
|
236
|
+
*/
|
|
237
|
+
perTurnUsd?: number;
|
|
238
|
+
/** Bare number = `warn` (spec: spend-store). Keyed {org, uid, sessionId}. */
|
|
239
|
+
perSessionUsd?: number | PersistentCap;
|
|
240
|
+
/**
|
|
241
|
+
* Bare number = `warn`. Keyed {org, UTC day} — deliberately org-wide across
|
|
242
|
+
* uids: an org-level number is what an operator caps or watches (§6.5).
|
|
243
|
+
*/
|
|
244
|
+
perTenantDayUsd?: number | PersistentCap;
|
|
245
|
+
}
|
|
246
|
+
/** Thrown by {@link BudgetGuard.charge} when a block-mode cap is crossed. */
|
|
247
|
+
declare class BudgetExceededError extends Error {
|
|
248
|
+
readonly cap: keyof BudgetCaps;
|
|
249
|
+
readonly capUsd: number;
|
|
250
|
+
readonly spentUsd: number;
|
|
251
|
+
constructor(cap: keyof BudgetCaps, capUsd: number, spentUsd: number);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* A {@link SpendStore} failure while a BLOCK-mode cap was configured — the
|
|
255
|
+
* fail-closed posture (spec: spend-store). Its own class because the loop must
|
|
256
|
+
* TERMINATE the turn on it wherever it surfaces: inside a delegate it would
|
|
257
|
+
* otherwise be swallowed into tool-result data like any handler error, and an
|
|
258
|
+
* opted-into stop would fail open exactly where the spend is.
|
|
259
|
+
*/
|
|
260
|
+
declare class SpendAccountingError extends Error {
|
|
261
|
+
constructor(operation: "add" | "peek", cause: unknown);
|
|
262
|
+
}
|
|
263
|
+
interface BudgetGuard {
|
|
264
|
+
/**
|
|
265
|
+
* Prices `usage` via the versioned price table, accumulates spend — in
|
|
266
|
+
* memory for the turn, through the {@link SpendStore} for the persistent
|
|
267
|
+
* caps — and throws {@link BudgetExceededError} when `perTurnUsd` or a
|
|
268
|
+
* block-mode cap is crossed. Async since spec: spend-store — a persistent
|
|
269
|
+
* counter cannot hide behind a sync signature. Warn-mode crossings are not
|
|
270
|
+
* returned here: they surface on the guard's own state (see
|
|
271
|
+
* `TurnBudgetGuard`), so the loop can stamp them on the settling
|
|
272
|
+
* `CostEvent` even when this call throws. Every turn's usage + cost also
|
|
273
|
+
* lands in the AuditLog cost trail — §6.8.
|
|
274
|
+
*/
|
|
275
|
+
charge(usage: Usage & {
|
|
276
|
+
model: ModelRef;
|
|
277
|
+
}): Promise<void>;
|
|
278
|
+
}
|
|
279
|
+
/** Addresses both counters one charge touches — spec: spend-store. */
|
|
280
|
+
interface SpendKey {
|
|
281
|
+
scope: Scope;
|
|
282
|
+
sessionId: string;
|
|
283
|
+
/** ISO 8601 — the store derives the UTC day bucket from it. */
|
|
284
|
+
at: string;
|
|
285
|
+
}
|
|
286
|
+
/** Post-operation counter totals. */
|
|
287
|
+
interface SpendTotals {
|
|
288
|
+
/** Total for {org, uid, sessionId}. */
|
|
289
|
+
sessionUsd: number;
|
|
290
|
+
/** Total for {org, UTC day} — across ALL uids and sessions of the org. */
|
|
291
|
+
tenantDayUsd: number;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Persistent spend accounting — capability seam (§7.1), spec: spend-store.
|
|
295
|
+
* WHERE spend accumulates is swappable; THAT it is accounted — and that caps
|
|
296
|
+
* are enforced, in the privileged guard — is not (the `AuditLog` idiom).
|
|
297
|
+
*
|
|
298
|
+
* Counters are content-free aggregates and deliberately do NOT participate in
|
|
299
|
+
* scoped purge (§10): retained as a legitimate-interest financial record —
|
|
300
|
+
* purging them would turn an erasure right into a budget reset.
|
|
301
|
+
*/
|
|
302
|
+
interface SpendStore {
|
|
303
|
+
/**
|
|
304
|
+
* Atomically adds `usd` to BOTH counters and returns the post-add totals.
|
|
305
|
+
* Increment-and-return in one step is the load-bearing property: two
|
|
306
|
+
* concurrent turns must never both act on a stale total — read-modify-write
|
|
307
|
+
* is the store's job, not the guard's.
|
|
308
|
+
*/
|
|
309
|
+
add(entry: SpendKey & {
|
|
310
|
+
usd: number;
|
|
311
|
+
}): Promise<SpendTotals>;
|
|
312
|
+
/** Current totals without charging — turn-start preflight, and the surface a product-side alerting watcher polls. */
|
|
313
|
+
peek(key: SpendKey): Promise<SpendTotals>;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* One row of the per-provider/model price table — §6.5: versioned
|
|
317
|
+
* configuration data, not code.
|
|
318
|
+
*/
|
|
319
|
+
interface ModelPrice {
|
|
320
|
+
model: ModelRef;
|
|
321
|
+
inputUsdPerMTok: number;
|
|
322
|
+
outputUsdPerMTok: number;
|
|
323
|
+
cacheReadUsdPerMTok?: number;
|
|
324
|
+
cacheWriteUsdPerMTok?: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Routing policy — complexity × sensitivity — §6.3.
|
|
329
|
+
*
|
|
330
|
+
* The policy declares, per sensitivity class, which providers/models may touch
|
|
331
|
+
* the data and under what condition (e.g. `health` only on providers with an
|
|
332
|
+
* adequate data-processing agreement, or after pseudonymization).
|
|
333
|
+
*
|
|
334
|
+
* `ModelPolicy` enforcement is part of the privileged core — deliberately NOT
|
|
335
|
+
* a capability seam (§7.1).
|
|
336
|
+
*/
|
|
337
|
+
/** Task complexity tier — §6.3. */
|
|
338
|
+
type Tier = "mechanical" | "standard" | "complex";
|
|
339
|
+
/**
|
|
340
|
+
* Data sensitivity class — §6.3.
|
|
341
|
+
* `health` ⊃ special-category data under LGPD Art. 11 / GDPR Art. 9.
|
|
342
|
+
*/
|
|
343
|
+
type Sensitivity = "public" | "internal" | "personal" | "health";
|
|
344
|
+
/** Ordered least → most sensitive — §6.3, spec 007. */
|
|
345
|
+
declare const SENSITIVITY_LEVELS: readonly Sensitivity[];
|
|
346
|
+
/**
|
|
347
|
+
* True when `a` is MORE sensitive than `b`. Spec 007: dispatch refuses a
|
|
348
|
+
* tool whose class exceeds the calling loop's declared sensitivity — a
|
|
349
|
+
* `health` tool in a `public` turn is a consumer bug surfaced loudly, never
|
|
350
|
+
* a silent data flow into a context routed for a lower class.
|
|
351
|
+
*/
|
|
352
|
+
declare function sensitivityExceeds(a: Sensitivity, b: Sensitivity): boolean;
|
|
353
|
+
interface RoutingIntent {
|
|
354
|
+
tier: Tier;
|
|
355
|
+
sensitivity: Sensitivity;
|
|
356
|
+
/** Optional free-form task label, recorded in the routing trail. */
|
|
357
|
+
task?: string;
|
|
358
|
+
}
|
|
359
|
+
interface ModelChoice {
|
|
360
|
+
model: ModelRef;
|
|
361
|
+
/**
|
|
362
|
+
* DECISION: the "why" of §6.8's RoutingEvent is carried here so every
|
|
363
|
+
* resolution is auditable verbatim — a policy must explain itself.
|
|
364
|
+
*/
|
|
365
|
+
rationale: string;
|
|
366
|
+
}
|
|
367
|
+
interface ModelPolicy {
|
|
368
|
+
/** Every resolution is recorded in the AuditLog routing trail — §6.3, §6.8. */
|
|
369
|
+
resolve(intent: RoutingIntent): ModelChoice;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Tenancy and audit — §6.8. Trails carry METADATA ONLY, never content — by
|
|
374
|
+
* construction, not by reviewer vigilance. Together with the logged-context
|
|
375
|
+
* invariant (§7.3) they answer "what exactly did the model see about this
|
|
376
|
+
* user?" as a query, not archaeology.
|
|
377
|
+
*/
|
|
378
|
+
/**
|
|
379
|
+
* Access trail entry: what/when/via which tool — §6.8. Correlation ids
|
|
380
|
+
* (spec 007) make "what happened in this turn?" a filter, not a join
|
|
381
|
+
* heuristic; they are optional because non-turn contexts (consolidation
|
|
382
|
+
* jobs) have no turnId.
|
|
383
|
+
*/
|
|
384
|
+
interface AccessEvent {
|
|
385
|
+
scope: Scope;
|
|
386
|
+
/** ISO 8601. */
|
|
387
|
+
at: string;
|
|
388
|
+
/** Tool that performed the access. */
|
|
389
|
+
tool: string;
|
|
390
|
+
/** DECISION: coarse verbs; finer detail goes in `resource`, never content. */
|
|
391
|
+
action: "read" | "write" | "delete" | "export";
|
|
392
|
+
/** Identifier of the touched resource (id/path) — never its content. */
|
|
393
|
+
resource?: string;
|
|
394
|
+
sessionId?: string;
|
|
395
|
+
turnId?: string;
|
|
396
|
+
}
|
|
397
|
+
/** Routing trail entry — §6.3, §6.8. */
|
|
398
|
+
interface RoutingEvent {
|
|
399
|
+
scope: Scope;
|
|
400
|
+
at: string;
|
|
401
|
+
tier: Tier;
|
|
402
|
+
sensitivity: Sensitivity;
|
|
403
|
+
/** The chosen model. */
|
|
404
|
+
model: ModelRef;
|
|
405
|
+
/** Why — carried verbatim from `ModelChoice.rationale`. */
|
|
406
|
+
rationale: string;
|
|
407
|
+
sessionId?: string;
|
|
408
|
+
turnId?: string;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Recall trail entry — §7.3, spec 012.
|
|
412
|
+
*
|
|
413
|
+
* The logged-context invariant says a new MODEL-VISIBLE input requires a new
|
|
414
|
+
* logged event type, never a side channel. The recall block is exactly that:
|
|
415
|
+
* content the model sees which is not in the conversation.
|
|
416
|
+
*
|
|
417
|
+
* DECISION (spec 012): this records PROVENANCE, not the rendered text. A
|
|
418
|
+
* verbatim copy of recalled content would be a copy surface erasure cannot
|
|
419
|
+
* reach without rewriting history — the failure spec 011 closed, one layer up.
|
|
420
|
+
* "What did the model see about this user at turn 12" stays answerable as
|
|
421
|
+
* "these facts and these episodes", each resolvable to its CURRENT state,
|
|
422
|
+
* including erased. Replay of recall is therefore provenance-level, not
|
|
423
|
+
* byte-level: between perfect replay and erasure, erasure wins.
|
|
424
|
+
*/
|
|
425
|
+
interface RecallEvent {
|
|
426
|
+
scope: Scope;
|
|
427
|
+
at: string;
|
|
428
|
+
sessionId: string;
|
|
429
|
+
turnId: string;
|
|
430
|
+
/** Ids of the profile fact versions rendered into the block. */
|
|
431
|
+
factIds: readonly string[];
|
|
432
|
+
/** Ids of the episodes rendered into the block. */
|
|
433
|
+
episodeIds: readonly string[];
|
|
434
|
+
/** The single budget the whole block was assembled under (§6.7). */
|
|
435
|
+
budgetTokens: number;
|
|
436
|
+
/**
|
|
437
|
+
* Tokens the block the model actually SAW measured, by the core's own
|
|
438
|
+
* estimator — 0 when the block was dropped. (It formerly documented the
|
|
439
|
+
* assembler's self-reported number, which the core no longer trusts.)
|
|
440
|
+
*/
|
|
441
|
+
estimatedTokens: number;
|
|
442
|
+
/** Measured size of a block that was dropped rather than shown. */
|
|
443
|
+
droppedTokens?: number;
|
|
444
|
+
/** True when the budget dropped content that would otherwise have shown. */
|
|
445
|
+
truncated: boolean;
|
|
446
|
+
/** Tiers whose read failed; their block is missing, the rest still rendered. */
|
|
447
|
+
degradedTiers?: readonly string[];
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* A field of the `ModelRequest` a `step:pre` interceptor may attempt — spec
|
|
451
|
+
* 029. Five, and the loop treats them in two classes: `system`, `messages` and
|
|
452
|
+
* `maxTokens` are the content and ceiling a hook may narrow; `model` and
|
|
453
|
+
* `tools` are privileged core and are repinned after the chain (§6.3, §6.4).
|
|
454
|
+
*/
|
|
455
|
+
type ContextField = "system" | "messages" | "maxTokens" | "model" | "tools";
|
|
456
|
+
/**
|
|
457
|
+
* The SIZE of what a model call carried — spec 029. Metadata only: enough to
|
|
458
|
+
* answer "how much entered the model's view from outside the session log",
|
|
459
|
+
* never a copy of it.
|
|
460
|
+
*/
|
|
461
|
+
interface ContextShape {
|
|
462
|
+
systemBlocks: number;
|
|
463
|
+
systemChars: number;
|
|
464
|
+
messages: number;
|
|
465
|
+
/** Blocks across all messages — a fabricated block carrying no prose still moves this. */
|
|
466
|
+
messageBlocks: number;
|
|
467
|
+
/**
|
|
468
|
+
* Characters in TEXT blocks only. A size signal for injected prose, NOT a
|
|
469
|
+
* byte count of the request: serializing tool payloads to measure them would
|
|
470
|
+
* put the dispatch path's cost on every rewriting step, and `messageBlocks`
|
|
471
|
+
* already catches what carries no text.
|
|
472
|
+
*/
|
|
473
|
+
messageChars: number;
|
|
474
|
+
maxTokens: number;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* `step:pre` rewrote what the model was about to see — §7.3, spec 029.
|
|
478
|
+
*
|
|
479
|
+
* The logged-context invariant says a new MODEL-VISIBLE input requires a new
|
|
480
|
+
* logged event type, never a side channel. §7.2 deliberately lets an
|
|
481
|
+
* interceptor rewrite the request, so `system` and `messages` were exactly
|
|
482
|
+
* that: content in front of the model appearing in no session entry.
|
|
483
|
+
*
|
|
484
|
+
* DECISION (spec 029): this records SHAPES, not the rewritten text — the same
|
|
485
|
+
* trade {@link RecallEvent} made and for the same reason (a verbatim copy is a
|
|
486
|
+
* surface erasure cannot reach), plus the one that governs every trail here:
|
|
487
|
+
* they carry metadata only, by construction.
|
|
488
|
+
*
|
|
489
|
+
* DECISION (spec 029): emitted when the chain TOUCHED a field, not when the
|
|
490
|
+
* core honored it. A rewrite the core discards — a raised `maxTokens`, a
|
|
491
|
+
* swapped `model` — is named in {@link refused}. Recording only what survived
|
|
492
|
+
* would leave the pins silent, which is half of what the slice fixed.
|
|
493
|
+
*
|
|
494
|
+
* This narrows §7.3's `step:pre` hole; it does not close it. A rewrite is
|
|
495
|
+
* still not byte-level reconstructable. What it can no longer be is unrecorded.
|
|
496
|
+
*/
|
|
497
|
+
interface ContextEvent {
|
|
498
|
+
scope: Scope;
|
|
499
|
+
at: string;
|
|
500
|
+
sessionId: string;
|
|
501
|
+
turnId: string;
|
|
502
|
+
/** The turn step (1-based). A delegate's rewrite carries its PARENT's step. */
|
|
503
|
+
step: number;
|
|
504
|
+
/** True when the rewritten call was a delegate's, not the turn's own. */
|
|
505
|
+
delegate?: boolean;
|
|
506
|
+
/**
|
|
507
|
+
* Fields the chain touched — never empty, since the event exists because one
|
|
508
|
+
* was. Detected by REFERENCE (by value for `maxTokens`) against the pre-hook
|
|
509
|
+
* request, so a hook that rebuilds an identical array over-reports. That is
|
|
510
|
+
* the safe direction for a trail: a spurious entry is not a leak, a missing
|
|
511
|
+
* one is.
|
|
512
|
+
*/
|
|
513
|
+
changed: readonly ContextField[];
|
|
514
|
+
/** Of those, the ones the core discarded or clamped rather than honored. */
|
|
515
|
+
refused?: readonly ContextField[];
|
|
516
|
+
/** What the model would have been sent. */
|
|
517
|
+
before: ContextShape;
|
|
518
|
+
/** What it WAS sent — post-clamp, post-pin. */
|
|
519
|
+
after: ContextShape;
|
|
520
|
+
}
|
|
521
|
+
/** Cost trail entry — §6.5, §6.8. */
|
|
522
|
+
interface CostEvent {
|
|
523
|
+
scope: Scope;
|
|
524
|
+
at: string;
|
|
525
|
+
model: ModelRef;
|
|
526
|
+
usage: Usage;
|
|
527
|
+
costUsd: number;
|
|
528
|
+
sessionId?: string;
|
|
529
|
+
turnId?: string;
|
|
530
|
+
/**
|
|
531
|
+
* Warn-mode persistent caps THIS settle crossed — spec: spend-store. At
|
|
532
|
+
* most once per cap per turn; absent on every other event.
|
|
533
|
+
*/
|
|
534
|
+
capsCrossed?: readonly PersistentCapName[];
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* §6.8. The SINK is a capability seam (§7.1) — where trails are written is
|
|
538
|
+
* swappable; THAT they are written is not (emission lives in the privileged
|
|
539
|
+
* core and cannot be bypassed by hooks or configuration).
|
|
540
|
+
*
|
|
541
|
+
* DECISION (spec 027, revised): every method may return a promise, and the
|
|
542
|
+
* harness AWAITS it. The previous rule — "synchronous fire-and-forget so
|
|
543
|
+
* auditing never blocks the critical path" — could not be enforced and was not
|
|
544
|
+
* true: `void` is exactly the return type TypeScript lets an `async` function
|
|
545
|
+
* satisfy, so a DB- or HTTP-backed sink (the shape §5 promises for Postgres)
|
|
546
|
+
* was always assignable, and its rejection escaped as an unhandled rejection —
|
|
547
|
+
* ending the process while the turn reported success.
|
|
548
|
+
*
|
|
549
|
+
* The latency the old rule protected is now the SINK's choice, where it
|
|
550
|
+
* belongs: buffer internally and return synchronously to stay off the critical
|
|
551
|
+
* path, or return a promise and be awaited. Either satisfies the type.
|
|
552
|
+
*
|
|
553
|
+
* A sink that FAILS terminates the turn, on every family. Where trails are
|
|
554
|
+
* written is swappable (§7.1); that they are written is not, and a trail that
|
|
555
|
+
* silently stopped being written is the failure the invariant exists to catch.
|
|
556
|
+
*
|
|
557
|
+
* One ergonomic consequence, worth knowing before it surprises you: a bare
|
|
558
|
+
* `void` return type accepts a function returning ANYTHING, and the union does
|
|
559
|
+
* not inherit that rule. `access: (e) => log.push(e)` no longer compiles —
|
|
560
|
+
* `void log.push(e)`, or a block body, does. The error is at the type level
|
|
561
|
+
* and immediate, which is the trade for a contract that no longer lies about
|
|
562
|
+
* what it accepts.
|
|
563
|
+
*/
|
|
564
|
+
interface AuditLog {
|
|
565
|
+
access(e: AccessEvent): void | Promise<void>;
|
|
566
|
+
routing(e: RoutingEvent): void | Promise<void>;
|
|
567
|
+
cost(e: CostEvent): void | Promise<void>;
|
|
568
|
+
/** §7.3 — what the model was shown from memory, by reference. */
|
|
569
|
+
recall(e: RecallEvent): void | Promise<void>;
|
|
570
|
+
/**
|
|
571
|
+
* §7.3, spec 029 — what a `step:pre` hook changed about the model request,
|
|
572
|
+
* by shape. Fires only when a hook actually touched one of the five fields,
|
|
573
|
+
* so an agent with no rewriting hooks never calls it. Required all the same:
|
|
574
|
+
* whether a trail is written is not a product choice.
|
|
575
|
+
*/
|
|
576
|
+
context(e: ContextEvent): void | Promise<void>;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* A trail sink failed — spec 027 review. TYPED, because "a failing audit sink
|
|
580
|
+
* terminates the turn" has to hold on every path, and the loop classifies
|
|
581
|
+
* errors by type: an untyped throw from a sink inside a delegate was caught by
|
|
582
|
+
* the tool-dispatch handler and became tool-result DATA, so the turn reported
|
|
583
|
+
* success with a trail silently unwritten. The same shape that made
|
|
584
|
+
* `SpendAccountingError` typed, for the same reason.
|
|
585
|
+
*/
|
|
586
|
+
declare class AuditSinkError extends Error {
|
|
587
|
+
readonly family: "access" | "routing" | "cost" | "recall" | "context";
|
|
588
|
+
readonly cause: unknown;
|
|
589
|
+
constructor(family: "access" | "routing" | "cost" | "recall" | "context", cause: unknown);
|
|
590
|
+
}
|
|
591
|
+
/** Consent state for one integration — §6.8, §10. */
|
|
592
|
+
interface Consent {
|
|
593
|
+
granted: boolean;
|
|
594
|
+
/** ISO 8601 of the grant/revocation. */
|
|
595
|
+
at?: string;
|
|
596
|
+
/** Version of the consent text the user acted on. */
|
|
597
|
+
version?: string;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Per-integration consent gate — §6.8. Capability seam (§7.1).
|
|
601
|
+
* DECISION: `integration` is a product-defined slug (e.g. "calendar");
|
|
602
|
+
* absence of a record must resolve to `{ granted: false }`, never throw.
|
|
603
|
+
*/
|
|
604
|
+
interface ConsentStore {
|
|
605
|
+
get(scope: Scope, integration: string): Promise<Consent>;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Session persistence — §6.6. Design inherited from the two production stores:
|
|
610
|
+
* transactional seq, chunking, TTL on the root doc, owner stamping for scoped
|
|
611
|
+
* purge. Sessions persist ONLY the neutral message format (§6.1).
|
|
612
|
+
*
|
|
613
|
+
* The long-context policy belongs to the HARNESS, not the store — and it is
|
|
614
|
+
* entry-time tool-output discipline plus windowing/summary at cache-cold
|
|
615
|
+
* boundaries, NEVER mid-history pruning: a consuming product measured pruning
|
|
616
|
+
* at +27% total cost (+99% cache writes) — see §6.6 and spec:
|
|
617
|
+
* tool-output-discipline.
|
|
618
|
+
*/
|
|
619
|
+
interface LoadOpts {
|
|
620
|
+
/**
|
|
621
|
+
* DECISION: §6.6 leaves LoadOpts open; Phase 0 defines only recency
|
|
622
|
+
* windowing. `limit` returns the LAST `limit` messages, still in
|
|
623
|
+
* chronological order — recency is what context assembly wants. A
|
|
624
|
+
* non-positive limit yields an empty history.
|
|
625
|
+
*/
|
|
626
|
+
limit?: number;
|
|
627
|
+
}
|
|
628
|
+
/** Capability seam — §7.1. Exercised by the shared contract suite (§6). */
|
|
629
|
+
interface SessionStore {
|
|
630
|
+
/**
|
|
631
|
+
* Appends entries atomically and in order (transactional seq — §6.6):
|
|
632
|
+
* concurrent appends to one session must never interleave or lose entries.
|
|
633
|
+
*
|
|
634
|
+
* PRECONDITION: every string in `entries`, keys included, is well-formed
|
|
635
|
+
* UTF-16. `runTurn` repairs each message as it is recorded (spec:
|
|
636
|
+
* well-formed-text), but only BEST-EFFORT — its catch keeps the unrepaired
|
|
637
|
+
* message — and a product writing here directly gets no such pass at all.
|
|
638
|
+
* `toWellFormedDeep` from this package is the remedy.
|
|
639
|
+
*
|
|
640
|
+
* ENFORCED by every adapter this package ships, and identically: a violation
|
|
641
|
+
* throws {@link import("./text").MalformedTextError} naming the offending
|
|
642
|
+
* entry and path, before anything is written. Spec 040 closed this; until
|
|
643
|
+
* then they DIVERGED — the in-memory store kept a lone surrogate while
|
|
644
|
+
* Postgres refused the write — and both contracts said "do not rely on
|
|
645
|
+
* either behaviour", which is not a contract. Refusing rather than repairing
|
|
646
|
+
* is the deliberate half: a store that silently rewrites the bytes of a
|
|
647
|
+
* record kept for years is worse than one that declines.
|
|
648
|
+
*
|
|
649
|
+
* A PRODUCT's own `SessionStore` is not covered by that sentence and owns
|
|
650
|
+
* the check itself — `assertWellFormed` is exported for exactly this, and
|
|
651
|
+
* the quickstart's store calls it. This distinction is not pedantry: the
|
|
652
|
+
* first draft of this paragraph said "every adapter", which read as every
|
|
653
|
+
* implementation, and the example in this repo was at that moment still
|
|
654
|
+
* accepting what Postgres rejects (spec 040 review).
|
|
655
|
+
*/
|
|
656
|
+
append(scope: Scope, sessionId: string, entries: Msg[]): Promise<void>;
|
|
657
|
+
/**
|
|
658
|
+
* Chronological history. DECISION: an unknown session resolves to `[]`
|
|
659
|
+
* rather than throwing — "no history yet" and "no such session" are the
|
|
660
|
+
* same thing to a turn that is about to create one.
|
|
661
|
+
*/
|
|
662
|
+
load(scope: Scope, sessionId: string, opts?: LoadOpts): Promise<Msg[]>;
|
|
663
|
+
/**
|
|
664
|
+
* LGPD/GDPR erasure — §6.6, §10. With `sessionId`, erases that session;
|
|
665
|
+
* without it, purges EVERY session in the scope (owner-stamped purge).
|
|
666
|
+
*/
|
|
667
|
+
erase(scope: Scope, sessionId?: string): Promise<void>;
|
|
668
|
+
/**
|
|
669
|
+
* Drops the session's `tool_call` and `tool_result` blocks, keeping
|
|
670
|
+
* everything the user saw — spec 039.
|
|
671
|
+
*
|
|
672
|
+
* The split it serves: for a product whose conversations are a professional
|
|
673
|
+
* record kept for years, the tool traffic is the arguments and results of
|
|
674
|
+
* what the agent DID — richer, more sensitive, and useful for weeks rather
|
|
675
|
+
* than years. What the trail keeps is the metadata (§6.8); what this removes
|
|
676
|
+
* is the payload.
|
|
677
|
+
*
|
|
678
|
+
* TWO CONSTRAINTS, both enforced here rather than documented and hoped for.
|
|
679
|
+
*
|
|
680
|
+
* `inactiveSince` is REQUIRED, and the store refuses when the session holds
|
|
681
|
+
* an entry newer than it — reporting `expired: false`, never throwing.
|
|
682
|
+
* Removing blocks from a session still in use is the mid-history pruning
|
|
683
|
+
* §6.6 forbids, and the cost is measured rather than feared: a consuming
|
|
684
|
+
* product ran that experiment and saw total cost +27% with cache writes
|
|
685
|
+
* +99%, because prompt caching is a prefix match and removing any byte
|
|
686
|
+
* mid-history rewrites everything after it. On a session nobody will resume
|
|
687
|
+
* the same removal is free, and that condition is the entire difference.
|
|
688
|
+
*
|
|
689
|
+
* ALL the tool blocks go, or none. Not "the ones older than X" — spec 026
|
|
690
|
+
* taught the loop to pair every `tool_call` with a `tool_result` on every
|
|
691
|
+
* exit path, and a provider answers 400 for a dangling `tool_use`, so a
|
|
692
|
+
* partial expiry would manufacture exactly that. All-or-nothing is paired by
|
|
693
|
+
* construction.
|
|
694
|
+
*
|
|
695
|
+
* A message left with no blocks is removed. `media` blocks survive: they are
|
|
696
|
+
* `MediaRef` pointers rather than content, they are user-facing, and the
|
|
697
|
+
* storage holding the bytes has its own lifecycle that is not Alma's.
|
|
698
|
+
*
|
|
699
|
+
* SCHEDULING IS THE PRODUCT'S. This is one session; enumerating which
|
|
700
|
+
* sessions are stale is a cross-scope question, and every contract here is
|
|
701
|
+
* scope-bound by design (§6.1).
|
|
702
|
+
*/
|
|
703
|
+
expireToolTraffic(scope: Scope, sessionId: string, opts: {
|
|
704
|
+
inactiveSince: string;
|
|
705
|
+
}): Promise<ToolTrafficExpiry>;
|
|
706
|
+
}
|
|
707
|
+
/** What one {@link SessionStore.expireToolTraffic} pass did — spec 039. */
|
|
708
|
+
interface ToolTrafficExpiry {
|
|
709
|
+
/** Blocks removed. */
|
|
710
|
+
blocks: number;
|
|
711
|
+
/** Messages left with nothing, and therefore removed entirely. */
|
|
712
|
+
messages: number;
|
|
713
|
+
/**
|
|
714
|
+
* False when the session was still active and nothing was touched.
|
|
715
|
+
*
|
|
716
|
+
* A refusal, not an error: a sweep over a thousand sessions where forty are
|
|
717
|
+
* still live is the normal case. A throw would make the caller write a
|
|
718
|
+
* try/catch per session and swallow it — and a swallowed refusal is how you
|
|
719
|
+
* stop noticing that nothing is being expired.
|
|
720
|
+
*/
|
|
721
|
+
expired: boolean;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Lifecycle events — §7.2. A small, CLOSED set of typed hooks on the turn
|
|
726
|
+
* flow — enough for guards, telemetry, and extra policy without touching the
|
|
727
|
+
* loop. Deliberately not extensible: a new model-visible input requires a new
|
|
728
|
+
* logged event type (§7.3), never a side channel.
|
|
729
|
+
*
|
|
730
|
+
* Interceptors are capability-narrowing ONLY: a hook can veto, rewrite, or
|
|
731
|
+
* annotate, but it can never register tools outside the scoped registry,
|
|
732
|
+
* widen a scope, or bypass audit/budget. The loop enforces this by
|
|
733
|
+
* construction — hooks receive no registry or store handles.
|
|
734
|
+
*
|
|
735
|
+
* "By construction" became true of `step:pre` only in spec 029. Until then a
|
|
736
|
+
* hook could raise `maxTokens` — not audit or budget, but how far one step may
|
|
737
|
+
* spend before the cap gets its chance: a widening the sentence above already
|
|
738
|
+
* forbade and nothing enforced. See {@link StepDecision} for the backstop that
|
|
739
|
+
* now holds it.
|
|
740
|
+
*/
|
|
741
|
+
/** How the turn was initiated — §8, §9. */
|
|
742
|
+
type TurnTrigger = "user" | "routine" | "system";
|
|
743
|
+
/**
|
|
744
|
+
* §9 step 4 — every turn ends with exactly one of these.
|
|
745
|
+
*
|
|
746
|
+
* `busy` joined the original four in spec 030: a turn that waited out
|
|
747
|
+
* `leaseWaitMs` for a session another turn was holding. It is its own reason
|
|
748
|
+
* because this union is the vocabulary every product switches on — a lease
|
|
749
|
+
* timeout arriving as `"error"` with a magic message would be untypeable, and
|
|
750
|
+
* it is the one terminal state where retrying the SAME turn is the right move.
|
|
751
|
+
*/
|
|
752
|
+
type TerminalReason = "completed" | "budget_exceeded" | "cancelled" | "error" | "busy";
|
|
753
|
+
interface TurnStartEvent {
|
|
754
|
+
scope: Scope;
|
|
755
|
+
sessionId: string;
|
|
756
|
+
turnId: string;
|
|
757
|
+
trigger: TurnTrigger;
|
|
758
|
+
/** ISO 8601. */
|
|
759
|
+
at: string;
|
|
760
|
+
}
|
|
761
|
+
interface TurnEndEvent {
|
|
762
|
+
scope: Scope;
|
|
763
|
+
sessionId: string;
|
|
764
|
+
turnId: string;
|
|
765
|
+
terminalReason: TerminalReason;
|
|
766
|
+
usage: Usage;
|
|
767
|
+
costUsd: number;
|
|
768
|
+
/**
|
|
769
|
+
* Model calls the TURN made — spec 032. The same index `TurnEvent.step`
|
|
770
|
+
* carries and `maxSteps` bounds; a delegate's own steps are not counted, or
|
|
771
|
+
* the number and its ceiling would measure different things.
|
|
772
|
+
*/
|
|
773
|
+
steps: number;
|
|
774
|
+
/**
|
|
775
|
+
* Wall-clock of the turn's own work, milliseconds — spec 032. Excludes the
|
|
776
|
+
* session lease wait (spec 030): a caller holds both ends of its `runTurn`
|
|
777
|
+
* call and can already measure total time, so reporting the WORK is what
|
|
778
|
+
* gives it the split it cannot reconstruct.
|
|
779
|
+
*/
|
|
780
|
+
durationMs: number;
|
|
781
|
+
at: string;
|
|
782
|
+
}
|
|
783
|
+
/** `step:pre` — what the model is about to see, before it is sent. */
|
|
784
|
+
interface StepPreEvent {
|
|
785
|
+
scope: Scope;
|
|
786
|
+
sessionId: string;
|
|
787
|
+
turnId: string;
|
|
788
|
+
request: ModelRequest;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* DECISION: outcome vocabulary for `step:pre` — rewrite happens by passing a
|
|
792
|
+
* modified event to `next()`; rejecting aborts the step with an auditable
|
|
793
|
+
* reason.
|
|
794
|
+
*
|
|
795
|
+
* The core applies a BACKSTOP after the chain returns, the way the registered
|
|
796
|
+
* ceiling still applies after every `tool:post` hook (see
|
|
797
|
+
* {@link ToolAnnotation}) — spec 029. `model` and `tools` are repinned,
|
|
798
|
+
* `maxTokens` is honored only when it NARROWS, and whichever of the five
|
|
799
|
+
* fields the chain touched is written to the `context` trail, honored or
|
|
800
|
+
* refused (§7.3).
|
|
801
|
+
*
|
|
802
|
+
* Rewrite by passing a modified event, never by mutating `event.request`: the
|
|
803
|
+
* request handed to the chain is FROZEN, so an in-place assignment throws. It
|
|
804
|
+
* used to succeed silently, bypassing both the backstop and the trail.
|
|
805
|
+
*/
|
|
806
|
+
type StepDecision = {
|
|
807
|
+
action: "proceed";
|
|
808
|
+
request: ModelRequest;
|
|
809
|
+
} | {
|
|
810
|
+
action: "reject";
|
|
811
|
+
reason: string;
|
|
812
|
+
};
|
|
813
|
+
/** `tool:pre` — a tool call the model has requested, before dispatch. */
|
|
814
|
+
interface ToolPreEvent {
|
|
815
|
+
scope: Scope;
|
|
816
|
+
sessionId: string;
|
|
817
|
+
turnId: string;
|
|
818
|
+
call: {
|
|
819
|
+
id: string;
|
|
820
|
+
name: string;
|
|
821
|
+
input: unknown;
|
|
822
|
+
};
|
|
823
|
+
spec: ToolSpec;
|
|
824
|
+
}
|
|
825
|
+
type ToolPreDecision = {
|
|
826
|
+
action: "proceed";
|
|
827
|
+
input: unknown;
|
|
828
|
+
} | {
|
|
829
|
+
action: "veto";
|
|
830
|
+
reason: string;
|
|
831
|
+
};
|
|
832
|
+
/** `tool:post` — a tool result, before it is appended to the conversation. */
|
|
833
|
+
interface ToolPostEvent {
|
|
834
|
+
scope: Scope;
|
|
835
|
+
sessionId: string;
|
|
836
|
+
turnId: string;
|
|
837
|
+
call: {
|
|
838
|
+
id: string;
|
|
839
|
+
name: string;
|
|
840
|
+
input: unknown;
|
|
841
|
+
};
|
|
842
|
+
result: {
|
|
843
|
+
output: unknown;
|
|
844
|
+
isError: boolean;
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* What a `tool:post` hook contributes to a tool result — §7.2, spec:
|
|
849
|
+
* tool-output-discipline. `advisory` appends context (the canonical
|
|
850
|
+
* loop-hygiene use); `output` REPLACES what the model will see — the entry
|
|
851
|
+
* point where a product swaps an oversized result for the relevant slice
|
|
852
|
+
* plus a pointer, BEFORE it enters the transcript (removal later costs more
|
|
853
|
+
* than it saves — §6.6). Hooks run in order, each seeing the output as left
|
|
854
|
+
* by the previous. §7.3 holds in both directions: the session persists
|
|
855
|
+
* exactly what the model saw, and the replaced original is retained nowhere.
|
|
856
|
+
* The registered ceiling still applies after every hook — the core backstop.
|
|
857
|
+
*/
|
|
858
|
+
interface ToolAnnotation {
|
|
859
|
+
advisory?: string;
|
|
860
|
+
/**
|
|
861
|
+
* Replaces the output the model will see — and the session will persist.
|
|
862
|
+
* A PRESENT key replaces even when its value is undefined (normalized to
|
|
863
|
+
* null), so a redaction hook can suppress a result outright.
|
|
864
|
+
*/
|
|
865
|
+
output?: unknown;
|
|
866
|
+
}
|
|
867
|
+
/** Observe-mode hook — may be async; the loop awaits but never interprets it. */
|
|
868
|
+
type Observer<E> = (event: E) => void | Promise<void>;
|
|
869
|
+
/**
|
|
870
|
+
* Intercept-mode hook, middleware-style — §7.2: call `next()` to delegate
|
|
871
|
+
* down the chain (optionally with a rewritten event), or return without
|
|
872
|
+
* calling it to short-circuit.
|
|
873
|
+
*/
|
|
874
|
+
type Interceptor<E, R> = (event: E, next: (event: E) => Promise<R>) => Promise<R>;
|
|
875
|
+
/** The closed hook set — §7.2. Keys are the event names, verbatim. */
|
|
876
|
+
interface LifecycleHooks {
|
|
877
|
+
"turn:start"?: Observer<TurnStartEvent>;
|
|
878
|
+
"turn:end"?: Observer<TurnEndEvent>;
|
|
879
|
+
"step:pre"?: Interceptor<StepPreEvent, StepDecision>;
|
|
880
|
+
"tool:pre"?: Interceptor<ToolPreEvent, ToolPreDecision>;
|
|
881
|
+
"tool:post"?: (event: ToolPostEvent) => ToolAnnotation | undefined | Promise<ToolAnnotation | undefined>;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Turn coordination — spec 030. Two failures the loop could not see, closed by
|
|
886
|
+
* one seam.
|
|
887
|
+
*
|
|
888
|
+
* A webhook that redelivers because it never saw a 200 used to make the
|
|
889
|
+
* harness run the turn again: the message sent twice, the memory written
|
|
890
|
+
* twice, the spend charged twice. And two DIFFERENT messages arriving on one
|
|
891
|
+
* session concurrently both loaded the same history and both appended, so the
|
|
892
|
+
* second turn never saw the first.
|
|
893
|
+
*
|
|
894
|
+
* The first needs an idempotency record; the second needs serialization.
|
|
895
|
+
* They are one seam because the lease is what makes the claim simple: with the
|
|
896
|
+
* session serialized, a claim has exactly two outcomes — fresh, or a completed
|
|
897
|
+
* turn to replay — and an in-flight claim is only reachable after a crash,
|
|
898
|
+
* never through concurrency.
|
|
899
|
+
*
|
|
900
|
+
* The idiom is `AuditLog`'s and `SpendStore`'s: WHERE a turn's coordination
|
|
901
|
+
* record lives is swappable (§7.1); THAT a turn is claimed before it runs is
|
|
902
|
+
* not.
|
|
903
|
+
*/
|
|
904
|
+
/**
|
|
905
|
+
* Addresses one turn's idempotency record.
|
|
906
|
+
*
|
|
907
|
+
* DECISION (spec 030): keyed by the full scope AND the session, never by
|
|
908
|
+
* `idempotencyKey` alone. A key is unique only within the transport that
|
|
909
|
+
* issued it, and a global key space would let one tenant's retry collide with
|
|
910
|
+
* another's — the isolation boundary applies here like everywhere else (§6.1).
|
|
911
|
+
*/
|
|
912
|
+
interface TurnKey {
|
|
913
|
+
scope: Scope;
|
|
914
|
+
sessionId: string;
|
|
915
|
+
/** Caller-supplied delivery identity — typically the inbound message id. */
|
|
916
|
+
idempotencyKey: string;
|
|
917
|
+
}
|
|
918
|
+
interface LeaseOpts {
|
|
919
|
+
/**
|
|
920
|
+
* How long the lease is held before it expires on its own. Must exceed a
|
|
921
|
+
* realistic worst-case turn: a live turn whose lease expires gets it stolen
|
|
922
|
+
* and interleaves, which is the failure the lease exists to prevent. It is a
|
|
923
|
+
* ceiling on how long a CRASHED holder can block a session, so it cannot
|
|
924
|
+
* simply be enormous either.
|
|
925
|
+
*/
|
|
926
|
+
ttlMs: number;
|
|
927
|
+
/** How long to wait for a busy session before giving up. */
|
|
928
|
+
waitMs: number;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Proof that this holder owns the session — spec 030.
|
|
932
|
+
*
|
|
933
|
+
* The token exists so {@link TurnStore.release} can refuse a STALE one. A
|
|
934
|
+
* holder whose lease already expired must never release the lease the next
|
|
935
|
+
* turn is now holding: that would serialize nothing while appearing to, which
|
|
936
|
+
* is worse than no lease at all.
|
|
937
|
+
*/
|
|
938
|
+
interface TurnLease {
|
|
939
|
+
readonly token: string;
|
|
940
|
+
/** ISO 8601. */
|
|
941
|
+
readonly expiresAt: string;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The replayable subset of a finished turn — spec 030.
|
|
945
|
+
*
|
|
946
|
+
* Deliberately NOT the loop's whole `TurnResult`. `capsCrossed`,
|
|
947
|
+
* `accountingError` and `budgetExceeded` describe the ORIGINAL run's
|
|
948
|
+
* infrastructure and enforcement state; re-reporting a cap crossing on every
|
|
949
|
+
* retry would double-count in exactly the product-side alerting spec 019
|
|
950
|
+
* built. What replays is what the turn produced, not how it went.
|
|
951
|
+
*
|
|
952
|
+
* It holds `reply` verbatim, which makes it a COPY SURFACE in the sense spec
|
|
953
|
+
* 010 defines — the price of replaying rather than refusing, paid explicitly.
|
|
954
|
+
* {@link TurnStore.erase} is how §10 reaches it.
|
|
955
|
+
*/
|
|
956
|
+
interface CompletedTurn {
|
|
957
|
+
reply: Msg;
|
|
958
|
+
terminalReason: TerminalReason;
|
|
959
|
+
stopReason: StopReason | null;
|
|
960
|
+
usage: Usage;
|
|
961
|
+
costUsd: number;
|
|
962
|
+
/**
|
|
963
|
+
* What the original turn cost in steps and milliseconds — spec 032, carried
|
|
964
|
+
* for the same reason `usage` and `costUsd` are. A replay reporting
|
|
965
|
+
* `durationMs: 0` would be the same lie as one reporting `costUsd: 0`.
|
|
966
|
+
*/
|
|
967
|
+
steps: number;
|
|
968
|
+
durationMs: number;
|
|
969
|
+
/** The original turn's id — correlation across the trails it already wrote. */
|
|
970
|
+
turnId: string;
|
|
971
|
+
/**
|
|
972
|
+
* Present when `terminalReason` is `"error"` — spec 033. It replays where
|
|
973
|
+
* `capsCrossed`, `accountingError` and `budgetExceeded` deliberately do not,
|
|
974
|
+
* and the difference is what each describes: those three are the original
|
|
975
|
+
* run's INFRASTRUCTURE and enforcement state, where re-reporting on every
|
|
976
|
+
* retry would double-count in a product's alerting. This is the turn's
|
|
977
|
+
* OUTCOME. A replayed failure that says `"error"` with no reason is strictly
|
|
978
|
+
* less than the turn it replays, and no double-counting argument applies to a
|
|
979
|
+
* string.
|
|
980
|
+
*/
|
|
981
|
+
error?: string;
|
|
982
|
+
/** ISO 8601 of the ORIGINAL turn. */
|
|
983
|
+
at: string;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* `fresh` — nothing has run under this key; the turn proceeds.
|
|
987
|
+
* `replay` — a turn already finished under it; its result is returned as-is.
|
|
988
|
+
*
|
|
989
|
+
* A turn that ended `error` or `budget_exceeded` still COMPLETES its claim, so
|
|
990
|
+
* a retry replays that outcome. A failed turn is a result, not an invitation
|
|
991
|
+
* to run it again and charge again.
|
|
992
|
+
*/
|
|
993
|
+
type TurnClaim = {
|
|
994
|
+
status: "fresh";
|
|
995
|
+
} | {
|
|
996
|
+
status: "replay";
|
|
997
|
+
completed: CompletedTurn;
|
|
998
|
+
};
|
|
999
|
+
/**
|
|
1000
|
+
* Capability seam — §7.1, spec 030. Exercised by `describeTurnStoreContract`.
|
|
1001
|
+
*
|
|
1002
|
+
* Configured or not, with no half-protected mode: a product that wires this
|
|
1003
|
+
* decided double-execution is unacceptable, so a store failure terminates the
|
|
1004
|
+
* turn rather than degrading to "unprotected but running" — the fail-closed
|
|
1005
|
+
* posture `SpendAccountingError` takes under a block cap.
|
|
1006
|
+
*/
|
|
1007
|
+
interface TurnStore {
|
|
1008
|
+
/**
|
|
1009
|
+
* Takes the session, waiting up to `opts.waitMs` for a busy one. Resolves
|
|
1010
|
+
* `null` when the wait expires — the caller ends the turn `"busy"` rather
|
|
1011
|
+
* than proceeding unserialized.
|
|
1012
|
+
*
|
|
1013
|
+
* Concurrent callers must see exactly ONE winner. That is the property the
|
|
1014
|
+
* whole seam stands on, and it is the store's job: a lease handed to two
|
|
1015
|
+
* holders serializes nothing.
|
|
1016
|
+
*/
|
|
1017
|
+
acquire(scope: Scope, sessionId: string, opts: LeaseOpts): Promise<TurnLease | null>;
|
|
1018
|
+
/**
|
|
1019
|
+
* Releases the session. IDEMPOTENT, and a stale token is a no-op rather than
|
|
1020
|
+
* another holder's release (see {@link TurnLease}).
|
|
1021
|
+
*/
|
|
1022
|
+
release(scope: Scope, sessionId: string, lease: TurnLease): Promise<void>;
|
|
1023
|
+
/** Records the attempt and reports whether this turn has already run. */
|
|
1024
|
+
claim(key: TurnKey): Promise<TurnClaim>;
|
|
1025
|
+
/** Stores the replayable record. Every later claim under this key replays it. */
|
|
1026
|
+
complete(key: TurnKey, completed: CompletedTurn): Promise<void>;
|
|
1027
|
+
/**
|
|
1028
|
+
* Drops a claim so a genuine retry may run — the path a turn takes when it
|
|
1029
|
+
* could not produce a result to store at all.
|
|
1030
|
+
*/
|
|
1031
|
+
abandon(key: TurnKey): Promise<void>;
|
|
1032
|
+
/**
|
|
1033
|
+
* §10 erasure. With `sessionId`, clears that session's lease and records;
|
|
1034
|
+
* without it, every session in the scope. Mirrors `SessionStore.erase`
|
|
1035
|
+
* deliberately: a product erasing a session must erase its turn records in
|
|
1036
|
+
* the same breath, or the reply survives the erasure that removed it from
|
|
1037
|
+
* the transcript.
|
|
1038
|
+
*/
|
|
1039
|
+
erase(scope: Scope, sessionId?: string): Promise<void>;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* A `TurnStore` operation failed — spec 030. Its own class for the reason
|
|
1043
|
+
* {@link import("./budget").SpendAccountingError} has one: the loop classifies
|
|
1044
|
+
* errors by TYPE, and inside a delegate an untyped throw is caught by the
|
|
1045
|
+
* tool-dispatch handler and becomes tool DATA, so a turn whose coordination
|
|
1046
|
+
* broke would report success.
|
|
1047
|
+
*/
|
|
1048
|
+
declare class TurnStoreError extends Error {
|
|
1049
|
+
readonly operation: "acquire" | "release" | "claim" | "complete" | "abandon";
|
|
1050
|
+
readonly cause: unknown;
|
|
1051
|
+
constructor(operation: "acquire" | "release" | "claim" | "complete" | "abandon", cause: unknown);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export { type ToolPostEvent as $, type AuditLog as A, type BudgetCaps as B, type CompletedTurn as C, type ProviderId as D, type RoutingEvent as E, type RoutingIntent as F, SENSITIVITY_LEVELS as G, type SessionStore as H, type Interceptor as I, SpendAccountingError as J, type SpendKey as K, type LeaseOpts as L, type ModelRef as M, type SpendStore as N, type Observer as O, type PersistentCap as P, type SpendTotals as Q, type RecallEvent as R, type Sensitivity as S, type Tier as T, type Usage as U, type StepDecision as V, type StepPreEvent as W, type StopReason as X, type SystemBlock as Y, type TerminalReason as Z, type ToolAnnotation as _, type Scope as a, type ToolPreDecision as a0, type ToolPreEvent as a1, type ToolSpec as a2, type ToolTrafficExpiry as a3, type TurnClaim as a4, type TurnEndEvent as a5, type TurnKey as a6, type TurnLease as a7, type TurnStartEvent as a8, type TurnStore as a9, TurnStoreError as aa, type TurnTrigger as ab, scopePath as ac, sensitivityExceeds as ad, type ModelPrice as b, type AccessEvent as c, AuditSinkError as d, type Block as e, BudgetExceededError as f, type BudgetGuard as g, type Consent as h, type ConsentStore as i, type ContextEvent as j, type ContextField as k, type ContextShape as l, type CostEvent as m, InvalidScopeError as n, type LifecycleHooks as o, type LoadOpts as p, type MediaKind as q, type MediaRef as r, type ModelChoice as s, type ModelClient as t, type ModelEvent as u, type ModelPolicy as v, type ModelRequest as w, type Msg as x, type MsgMeta as y, type PersistentCapName as z };
|