@loomwork/llm 0.0.0-stage → 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/NOTICE +8 -0
- package/README.md +80 -2
- package/dist/assembleStream.d.ts +95 -0
- package/dist/assembleStream.d.ts.map +1 -0
- package/dist/assembleStream.js +180 -0
- package/dist/assembleStream.js.map +1 -0
- package/dist/capabilities.d.ts +43 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +77 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/createLlmEffect.d.ts +110 -0
- package/dist/createLlmEffect.d.ts.map +1 -0
- package/dist/createLlmEffect.js +177 -0
- package/dist/createLlmEffect.js.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +280 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/parts.d.ts +220 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +231 -0
- package/dist/parts.js.map +1 -0
- package/dist/providers/AnthropicProvider.d.ts +53 -0
- package/dist/providers/AnthropicProvider.d.ts.map +1 -0
- package/dist/providers/AnthropicProvider.js +443 -0
- package/dist/providers/AnthropicProvider.js.map +1 -0
- package/dist/providers/GeminiProvider.d.ts +66 -0
- package/dist/providers/GeminiProvider.d.ts.map +1 -0
- package/dist/providers/GeminiProvider.js +281 -0
- package/dist/providers/GeminiProvider.js.map +1 -0
- package/dist/providers/OpenAiProvider.d.ts +56 -0
- package/dist/providers/OpenAiProvider.d.ts.map +1 -0
- package/dist/providers/OpenAiProvider.js +402 -0
- package/dist/providers/OpenAiProvider.js.map +1 -0
- package/dist/providers/extraHeaders.d.ts +7 -0
- package/dist/providers/extraHeaders.d.ts.map +1 -0
- package/dist/providers/extraHeaders.js +40 -0
- package/dist/providers/extraHeaders.js.map +1 -0
- package/dist/providers/systemPrompt.d.ts +10 -0
- package/dist/providers/systemPrompt.d.ts.map +1 -0
- package/dist/providers/systemPrompt.js +22 -0
- package/dist/providers/systemPrompt.js.map +1 -0
- package/dist/reasoning.d.ts +154 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +256 -0
- package/dist/reasoning.js.map +1 -0
- package/dist/resolveContent.d.ts +74 -0
- package/dist/resolveContent.d.ts.map +1 -0
- package/dist/resolveContent.js +335 -0
- package/dist/resolveContent.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/untrusted.d.ts +91 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +385 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +54 -3
- package/src/assembleStream.ts +219 -0
- package/src/capabilities.ts +96 -0
- package/src/createLlmEffect.ts +287 -0
- package/src/errors.ts +269 -0
- package/src/index.ts +137 -0
- package/src/parts.ts +409 -0
- package/src/providers/AnthropicProvider.ts +550 -0
- package/src/providers/GeminiProvider.ts +380 -0
- package/src/providers/OpenAiProvider.ts +511 -0
- package/src/providers/extraHeaders.ts +41 -0
- package/src/providers/systemPrompt.ts +19 -0
- package/src/reasoning.ts +386 -0
- package/src/resolveContent.ts +425 -0
- package/src/types.ts +175 -0
- package/src/untrusted.ts +440 -0
package/src/reasoning.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { type TaintedValue, tainted } from "@loomwork/classify";
|
|
3
|
+
import type { EffectContext } from "@loomwork/effect-runtime";
|
|
4
|
+
import type { LlmReasoning, LlmReasoningKind, LlmReasoningPart } from "./parts";
|
|
5
|
+
import type { ContentResolver } from "./resolveContent";
|
|
6
|
+
|
|
7
|
+
// ── ADR 0133 — model reasoning as audit evidence ─────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// The provider returns reasoning next to its answer. `captureReasoning` runs BEFORE `parse`
|
|
10
|
+
// and reduces it to its recorded form: kind, provider, signature and a reference to the readable
|
|
11
|
+
// text in a sidecar store. The text never reaches a step value; the audit fact is a userspace
|
|
12
|
+
// event naming references and digests, never text and never signatures. `readReasoning` joins
|
|
13
|
+
// those events back to their blobs for an auditor.
|
|
14
|
+
|
|
15
|
+
/** The event the capture emits (userspace: the fold ignores it). */
|
|
16
|
+
export const LLM_REASONING_CAPTURED = "LLM_REASONING_CAPTURED";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Write and read view of the sidecar store (structural: a `ContentStore` satisfies it, with no
|
|
20
|
+
* package dependency). Content-addressed and idempotent by hash, so a crash-recovery rerun that
|
|
21
|
+
* produces the same bytes stores nothing new.
|
|
22
|
+
*/
|
|
23
|
+
export interface ReasoningStore {
|
|
24
|
+
put(bytes: Uint8Array): Promise<{ readonly __loomContent: string }>;
|
|
25
|
+
get(ref: { readonly __loomContent: string }): Promise<Uint8Array | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read-only view, for {@link readReasoning}. */
|
|
29
|
+
export type ReasoningReader = Pick<ReasoningStore, "get">;
|
|
30
|
+
|
|
31
|
+
export interface LlmReasoningOptions {
|
|
32
|
+
/** Where readable text is kept. Without one, text is dropped and only signatures are recorded. */
|
|
33
|
+
store?: ReasoningStore;
|
|
34
|
+
/** Default: on iff a store is configured. `false` is the explicit opt-out. */
|
|
35
|
+
capture?: boolean | ((ctx: EffectContext) => boolean);
|
|
36
|
+
/** Deliver readable reasoning deltas live (D6): through the per-call sink under a runtime pipe, else to `onDelta`. Off by default. */
|
|
37
|
+
live?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The configured live sink for reasoning, the analogue of `createLlmEffect`'s text `onDelta`
|
|
40
|
+
* (a transport such as `ConversationHub.reasoningSink()`). Called only when `live` is on and the
|
|
41
|
+
* call is streamed, with the increment still WRAPPED as untrusted content
|
|
42
|
+
* (`llm-reasoning:<stepId>`): the sink unwraps it at its own transport edge, never earlier.
|
|
43
|
+
* Never journaled; not called on replay.
|
|
44
|
+
*/
|
|
45
|
+
onDelta?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
|
|
46
|
+
/** Recorded on the audit event (the provider SPI does not expose its model id). */
|
|
47
|
+
model?: string;
|
|
48
|
+
/**
|
|
49
|
+
* An AUTHORIZING resolver for a reasoning part's `textRef` (the ADR 0120 `ContentResolver` seam,
|
|
50
|
+
* called with `origin: "reasoning"` and the call's executionId / stepId / principal). It returns the
|
|
51
|
+
* readable text's bytes, or `null` for absent / not yours; a throw is an outage and fails the call.
|
|
52
|
+
* Default: {@link reasoningStoreResolver} over `store`, which serves only blobs sealed to the
|
|
53
|
+
* calling execution.
|
|
54
|
+
*/
|
|
55
|
+
resolve?: ContentResolver;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One part of an `LLM_REASONING_CAPTURED` payload: references and digests, never text. */
|
|
59
|
+
export interface CapturedReasoningPart {
|
|
60
|
+
kind: LlmReasoningKind;
|
|
61
|
+
ref?: string;
|
|
62
|
+
digest?: string;
|
|
63
|
+
size?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LlmReasoningCaptured {
|
|
67
|
+
stepId: string;
|
|
68
|
+
attempt: number;
|
|
69
|
+
provider: string;
|
|
70
|
+
model?: string;
|
|
71
|
+
parts: CapturedReasoningPart[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const enc = new TextEncoder();
|
|
75
|
+
const dec = new TextDecoder();
|
|
76
|
+
|
|
77
|
+
// ── the execution seal (ADR 0133 D5, "who may resolve a ref") ─────────────────
|
|
78
|
+
//
|
|
79
|
+
// The sidecar is content-addressed and, like every ContentStore, has no tenancy of its own: whoever
|
|
80
|
+
// can name an address can read it, and a reasoning part's `textRef` rides in a transcript a caller
|
|
81
|
+
// may have assembled. So the stored bytes are SEALED to the execution that captured them (a tiny
|
|
82
|
+
// JSON envelope naming it), and the default resolver serves a blob only to that execution. A forged
|
|
83
|
+
// or copied ref to another execution's blob resolves to `null` (not yours), which the drop rule
|
|
84
|
+
// treats as a hole. The part's `digest` and the audit event's `digest`/`size` stay those of the
|
|
85
|
+
// readable text itself.
|
|
86
|
+
|
|
87
|
+
/** The bytes stored for `text`: sealed to `executionId`. */
|
|
88
|
+
export function sealReasoningText(text: string, executionId: string): Uint8Array {
|
|
89
|
+
return enc.encode(JSON.stringify({ e: executionId, t: text }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The text bytes inside a sealed blob, or `null` when it is not sealed to `executionId` (or is not a sealed blob). */
|
|
93
|
+
export function openReasoningText(bytes: Uint8Array, executionId: string): Uint8Array | null {
|
|
94
|
+
try {
|
|
95
|
+
const v = JSON.parse(dec.decode(bytes)) as { e?: unknown; t?: unknown } | null;
|
|
96
|
+
if (v === null || typeof v !== "object" || v.e !== executionId || typeof v.t !== "string") {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return enc.encode(v.t);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The default reasoning resolver: reads the sidecar and serves a blob only to the execution it is sealed to. */
|
|
106
|
+
export function reasoningStoreResolver(store: ReasoningReader): ContentResolver {
|
|
107
|
+
return async (ref, ctx) => {
|
|
108
|
+
const bytes = await store.get({ __loomContent: ref.address });
|
|
109
|
+
return bytes === null ? null : openReasoningText(bytes, ctx.executionId);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** SHA-256 hex of the canonical UTF-8 bytes of `text`. */
|
|
114
|
+
export function reasoningDigest(bytes: Uint8Array): string {
|
|
115
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether capture is on for this call: an explicit choice wins, else on iff a store is configured. */
|
|
119
|
+
export function captureOn(options: LlmReasoningOptions | undefined, ctx: EffectContext): boolean {
|
|
120
|
+
if (options === undefined || options.store === undefined) return false;
|
|
121
|
+
const c = options.capture;
|
|
122
|
+
return c === undefined ? true : typeof c === "function" ? c(ctx) : c;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reduce a response's reasoning to its recorded parts and (when anything was captured) emit the
|
|
127
|
+
* audit event. Pure with respect to the response; the only side effects are the store `put`s and
|
|
128
|
+
* the one `ctx.emit`. A failing `put` throws: audit evidence that cannot be kept fails the step
|
|
129
|
+
* (and retries it) instead of silently recording less than was configured.
|
|
130
|
+
*/
|
|
131
|
+
export async function captureReasoning(
|
|
132
|
+
reasoning: readonly LlmReasoning[],
|
|
133
|
+
options: LlmReasoningOptions | undefined,
|
|
134
|
+
ctx: EffectContext
|
|
135
|
+
): Promise<LlmReasoningPart[]> {
|
|
136
|
+
const store = captureOn(options, ctx) ? options?.store : undefined;
|
|
137
|
+
const parts: LlmReasoningPart[] = [];
|
|
138
|
+
const audit: CapturedReasoningPart[] = [];
|
|
139
|
+
for (const r of reasoning) {
|
|
140
|
+
const part: LlmReasoningPart = {
|
|
141
|
+
type: "reasoning",
|
|
142
|
+
kind: r.kind,
|
|
143
|
+
provider: r.provider,
|
|
144
|
+
...(r.signature !== undefined && { signature: r.signature }),
|
|
145
|
+
};
|
|
146
|
+
const text = r.text ?? "";
|
|
147
|
+
if (text !== "") {
|
|
148
|
+
if (store !== undefined) {
|
|
149
|
+
const bytes = enc.encode(text);
|
|
150
|
+
const ref = await store.put(sealReasoningText(text, ctx.executionId));
|
|
151
|
+
part.textRef = { address: ref.__loomContent, size: bytes.byteLength };
|
|
152
|
+
part.digest = reasoningDigest(bytes);
|
|
153
|
+
audit.push({
|
|
154
|
+
kind: r.kind,
|
|
155
|
+
ref: ref.__loomContent,
|
|
156
|
+
digest: part.digest,
|
|
157
|
+
size: bytes.byteLength,
|
|
158
|
+
});
|
|
159
|
+
} else {
|
|
160
|
+
// Readable text existed and was not kept: the part cannot be reproduced for continuation.
|
|
161
|
+
part.omitted = true;
|
|
162
|
+
}
|
|
163
|
+
} else if (store !== undefined) {
|
|
164
|
+
audit.push({ kind: r.kind });
|
|
165
|
+
}
|
|
166
|
+
parts.push(part);
|
|
167
|
+
}
|
|
168
|
+
if (store !== undefined && audit.length > 0) {
|
|
169
|
+
const payload: LlmReasoningCaptured = {
|
|
170
|
+
stepId: ctx.stepId,
|
|
171
|
+
attempt: ctx.attempt,
|
|
172
|
+
provider: reasoning[0]?.provider ?? "",
|
|
173
|
+
...(options?.model !== undefined && { model: options.model }),
|
|
174
|
+
parts: audit,
|
|
175
|
+
};
|
|
176
|
+
ctx.emit(LLM_REASONING_CAPTURED, payload);
|
|
177
|
+
}
|
|
178
|
+
return parts;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* ADR 0133 D2 - the text-free form of anything reasoning-shaped: a recorded part loses any `text`
|
|
183
|
+
* it carries; a raw {@link LlmReasoning} becomes a part with kind, provider and signature, flagged
|
|
184
|
+
* `omitted` when it had readable text (the text was not kept, so it cannot be continued). For a
|
|
185
|
+
* layer that sits between a provider and a journal (a decision, a cache) and has no sidecar:
|
|
186
|
+
* readable reasoning must reach a sidecar via `createLlmEffect`, or nowhere.
|
|
187
|
+
*/
|
|
188
|
+
export function textFreeReasoning(
|
|
189
|
+
reasoning: readonly (LlmReasoning | LlmReasoningPart)[]
|
|
190
|
+
): LlmReasoningPart[] {
|
|
191
|
+
return reasoning.map((r): LlmReasoningPart => {
|
|
192
|
+
if ((r as { type?: string }).type === "reasoning") {
|
|
193
|
+
const { text: _text, ...recorded } = r as LlmReasoningPart;
|
|
194
|
+
return recorded;
|
|
195
|
+
}
|
|
196
|
+
const raw = r as LlmReasoning;
|
|
197
|
+
return {
|
|
198
|
+
type: "reasoning",
|
|
199
|
+
kind: raw.kind,
|
|
200
|
+
provider: raw.provider,
|
|
201
|
+
...(raw.signature !== undefined && { signature: raw.signature }),
|
|
202
|
+
...((raw.text ?? "") !== "" && { omitted: true as const }),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── the audit reader (D4) ────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/** The minimal log view `readReasoning` needs: an `EventStore` satisfies it. */
|
|
210
|
+
export interface ReasoningLogReader {
|
|
211
|
+
read(executionId: string): Promise<readonly { eventType: string; payload: unknown }[]>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `redacted`: the vendor withheld this block (opaque data in `signature`, never any text), so there is
|
|
216
|
+
* nothing to verify or erase; it is NOT `ok` (which would read as "readable and checked").
|
|
217
|
+
*/
|
|
218
|
+
export type ReasoningPartStatus = "ok" | "redacted" | "erased" | "digest-mismatch";
|
|
219
|
+
|
|
220
|
+
export interface ReasoningPartRead {
|
|
221
|
+
kind: LlmReasoningKind;
|
|
222
|
+
provider: string;
|
|
223
|
+
status: ReasoningPartStatus;
|
|
224
|
+
/** Present only when `status` is `"ok"` and the part had readable text; wrapped as untrusted. */
|
|
225
|
+
text?: TaintedValue<string>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface ReasoningAttemptRead {
|
|
229
|
+
attempt: number;
|
|
230
|
+
provider: string;
|
|
231
|
+
model?: string;
|
|
232
|
+
/** This attempt's value is the one the step recorded (the last capture before `STEP_COMPLETED`). */
|
|
233
|
+
recorded: boolean;
|
|
234
|
+
parts: ReasoningPartRead[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface ReasoningStepRead {
|
|
238
|
+
stepId: string;
|
|
239
|
+
/** In log order; superseded attempts are kept. */
|
|
240
|
+
attempts: ReasoningAttemptRead[];
|
|
241
|
+
/** The attempt whose value the step recorded, when the step completed. */
|
|
242
|
+
recordedAttempt?: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Which steps to read and how much: every bound is applied BEFORE a blob is fetched. */
|
|
246
|
+
export interface ReasoningQuery {
|
|
247
|
+
executionId: string;
|
|
248
|
+
/** Exactly this step. */
|
|
249
|
+
stepId?: string;
|
|
250
|
+
/** Only steps whose id starts with this (a conversation turn's steps), filtered before any `store.get`. */
|
|
251
|
+
stepPrefix?: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Bounds for {@link readReasoningBounded}. Reading stops at the first one reached. */
|
|
255
|
+
export interface ReasoningReadLimits {
|
|
256
|
+
/** Most parts read (each part with a stored text costs one `store.get`). */
|
|
257
|
+
maxParts?: number;
|
|
258
|
+
/** Most readable-text bytes read in total. */
|
|
259
|
+
maxBytes?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Join `LLM_REASONING_CAPTURED` events to their blobs. Each part is checked against its digest:
|
|
264
|
+
* an absent blob reports `erased`, an altered one `digest-mismatch`. Readable text comes back
|
|
265
|
+
* wrapped as untrusted content (origin `llm-reasoning:<stepId>`), so it cannot be put in a prompt
|
|
266
|
+
* instruction position. Grouped by step with every attempt; the attempt whose value the step
|
|
267
|
+
* recorded is the last capture before that step's `STEP_COMPLETED`.
|
|
268
|
+
*/
|
|
269
|
+
export async function readReasoning(
|
|
270
|
+
log: ReasoningLogReader,
|
|
271
|
+
store: ReasoningReader,
|
|
272
|
+
query: ReasoningQuery
|
|
273
|
+
): Promise<ReasoningStepRead[]> {
|
|
274
|
+
return (await readReasoningBounded(log, store, query)).steps;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* {@link readReasoning} for an endpoint: the same join, but bounded. `stepId` / `stepPrefix` select
|
|
279
|
+
* events before any blob is fetched, and `limits` cap the parts and the readable bytes read; when a
|
|
280
|
+
* cap is reached the first parts (in log order) come back with `truncated: true` and nothing past
|
|
281
|
+
* the cap is fetched. A part whose declared text size would cross `maxBytes` is neither read nor included.
|
|
282
|
+
*/
|
|
283
|
+
export async function readReasoningBounded(
|
|
284
|
+
log: ReasoningLogReader,
|
|
285
|
+
store: ReasoningReader,
|
|
286
|
+
query: ReasoningQuery,
|
|
287
|
+
limits: ReasoningReadLimits = {}
|
|
288
|
+
): Promise<{ steps: ReasoningStepRead[]; truncated: boolean }> {
|
|
289
|
+
const maxParts = limits.maxParts ?? Number.POSITIVE_INFINITY;
|
|
290
|
+
const maxBytes = limits.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
291
|
+
const events = await log.read(query.executionId);
|
|
292
|
+
const steps = new Map<string, ReasoningStepRead>();
|
|
293
|
+
const latest = new Map<string, ReasoningAttemptRead>();
|
|
294
|
+
let parts = 0;
|
|
295
|
+
let bytes = 0;
|
|
296
|
+
let truncated = false;
|
|
297
|
+
for (const ev of events) {
|
|
298
|
+
if (ev.eventType === LLM_REASONING_CAPTURED) {
|
|
299
|
+
if (truncated) continue;
|
|
300
|
+
const p = asCaptured(ev.payload);
|
|
301
|
+
if (
|
|
302
|
+
p === undefined ||
|
|
303
|
+
(query.stepId !== undefined && p.stepId !== query.stepId) ||
|
|
304
|
+
(query.stepPrefix !== undefined && !p.stepId.startsWith(query.stepPrefix))
|
|
305
|
+
) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const attempt: ReasoningAttemptRead = {
|
|
309
|
+
attempt: p.attempt,
|
|
310
|
+
provider: p.provider,
|
|
311
|
+
...(p.model !== undefined && { model: p.model }),
|
|
312
|
+
recorded: false,
|
|
313
|
+
parts: [],
|
|
314
|
+
};
|
|
315
|
+
for (const part of p.parts) {
|
|
316
|
+
if (parts >= maxParts) {
|
|
317
|
+
truncated = true;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
// The event's own `size` (the text's bytes) gates the fetch, so nothing past the cap is read.
|
|
321
|
+
const size = part.ref !== undefined ? (part.size ?? 0) : 0;
|
|
322
|
+
if (bytes + size > maxBytes) {
|
|
323
|
+
truncated = true;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
parts += 1;
|
|
327
|
+
bytes += size;
|
|
328
|
+
attempt.parts.push(await readPart(store, part, p.provider, p.stepId, query.executionId));
|
|
329
|
+
}
|
|
330
|
+
if (attempt.parts.length > 0 || !truncated) {
|
|
331
|
+
let step = steps.get(p.stepId);
|
|
332
|
+
if (step === undefined) {
|
|
333
|
+
step = { stepId: p.stepId, attempts: [] };
|
|
334
|
+
steps.set(p.stepId, step);
|
|
335
|
+
}
|
|
336
|
+
step.attempts.push(attempt);
|
|
337
|
+
latest.set(p.stepId, attempt);
|
|
338
|
+
}
|
|
339
|
+
} else if (ev.eventType === "STEP_COMPLETED") {
|
|
340
|
+
const id = (ev.payload as { stepId?: unknown } | null)?.stepId;
|
|
341
|
+
const attempt = typeof id === "string" ? latest.get(id) : undefined;
|
|
342
|
+
if (attempt !== undefined && typeof id === "string") {
|
|
343
|
+
attempt.recorded = true;
|
|
344
|
+
const step = steps.get(id);
|
|
345
|
+
if (step !== undefined) step.recordedAttempt = attempt.attempt;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { steps: [...steps.values()], truncated };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function asCaptured(payload: unknown): LlmReasoningCaptured | undefined {
|
|
353
|
+
const p = payload as Partial<LlmReasoningCaptured> | null;
|
|
354
|
+
if (
|
|
355
|
+
p === null ||
|
|
356
|
+
typeof p !== "object" ||
|
|
357
|
+
typeof p.stepId !== "string" ||
|
|
358
|
+
typeof p.attempt !== "number" ||
|
|
359
|
+
typeof p.provider !== "string" ||
|
|
360
|
+
!Array.isArray(p.parts)
|
|
361
|
+
) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
return p as LlmReasoningCaptured;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function readPart(
|
|
368
|
+
store: ReasoningReader,
|
|
369
|
+
part: CapturedReasoningPart,
|
|
370
|
+
provider: string,
|
|
371
|
+
stepId: string,
|
|
372
|
+
executionId: string
|
|
373
|
+
): Promise<ReasoningPartRead> {
|
|
374
|
+
const base = { kind: part.kind, provider };
|
|
375
|
+
if (part.kind === "redacted") return { ...base, status: "redacted" };
|
|
376
|
+
if (part.ref === undefined) return { ...base, status: "ok" };
|
|
377
|
+
const stored = await store.get({ __loomContent: part.ref });
|
|
378
|
+
if (stored === null) return { ...base, status: "erased" };
|
|
379
|
+
// A blob not sealed to this execution is not what this execution captured.
|
|
380
|
+
const bytes = openReasoningText(stored, executionId);
|
|
381
|
+
if (bytes === null) return { ...base, status: "digest-mismatch" };
|
|
382
|
+
if (part.digest === undefined || reasoningDigest(bytes) !== part.digest) {
|
|
383
|
+
return { ...base, status: "digest-mismatch" };
|
|
384
|
+
}
|
|
385
|
+
return { ...base, status: "ok", text: tainted(dec.decode(bytes), `llm-reasoning:${stepId}`) };
|
|
386
|
+
}
|