@vincemakes/kiso-runtime 0.1.38 → 0.2.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/dist/agent.d.ts +1 -0
- package/dist/agent.js +2 -0
- package/dist/index.d.ts +32 -11
- package/dist/index.js +41 -11
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +25 -0
- package/dist/lock-adapter.js +46 -1
- package/dist/run.js +21 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.js +8 -0
- package/dist/trace/analyze.d.ts +35 -0
- package/dist/trace/analyze.js +51 -0
- package/dist/trace/guard.d.ts +46 -0
- package/dist/trace/guard.js +208 -0
- package/dist/trace/hash.d.ts +22 -0
- package/dist/trace/hash.js +34 -0
- package/dist/trace/manifest.d.ts +36 -0
- package/dist/trace/manifest.js +100 -0
- package/dist/trace/record.d.ts +129 -0
- package/dist/trace/record.js +237 -0
- package/dist/trace/writer.d.ts +43 -0
- package/dist/trace/writer.js +154 -0
- package/dist/usage/canonical.d.ts +123 -0
- package/dist/usage/canonical.js +152 -0
- package/package.json +9 -5
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 3, the request tracer (the guard).
|
|
3
|
+
*
|
|
4
|
+
* One TraceRecord per adapter call, settled when the stream ends. The
|
|
5
|
+
* guard lives AT the adapter boundary (run.ts wires
|
|
6
|
+
* truncationGuard(traceGuard(tracer, adapter))): the kernel, the log,
|
|
7
|
+
* and the model-visible byte stream are untouched (I6 — pinned by
|
|
8
|
+
* trace-bytes.test.ts). retryAttempt counts prior calls in this run
|
|
9
|
+
* with an identical contextHash — the loop re-streams the SAME messages
|
|
10
|
+
* array on retry (loop.ts), so an identical hash is exactly "same
|
|
11
|
+
* request, retried" (§1.4).
|
|
12
|
+
*
|
|
13
|
+
* Soft-fail: any failure in trace assembly (never expected — hashes and
|
|
14
|
+
* the manifest cannot throw today) marks the request ABSENT rather than
|
|
15
|
+
* breaking the stream; the writer's own degradation covers I/O.
|
|
16
|
+
*
|
|
17
|
+
* Usage is provider-raw, never normalized (E2's job): openai-compat
|
|
18
|
+
* reports input TOTAL (fresh = input − cacheRead); anthropic's
|
|
19
|
+
* input_tokens is ALREADY fresh-only (the trace records it as-is).
|
|
20
|
+
* A request that settles with NO usage data records the quartet as
|
|
21
|
+
* zeros with cacheWrite null — "0 = unknown", documented at record.ts;
|
|
22
|
+
* the honest nullable quartet is a schema bump, deferred.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { buildContextManifest, segmentHashes } from "./manifest.js";
|
|
26
|
+
import { cacheableHashes } from "./analyze.js";
|
|
27
|
+
import { hashContext, hashSystemPrompt, hashToolSpecs, stablePrefixFingerprint } from "./hash.js";
|
|
28
|
+
import { PRICING_TABLE_V1, canonicalizeUsage } from "../usage/canonical.js";
|
|
29
|
+
import { TRACE_SCHEMA_VERSION } from "./record.js";
|
|
30
|
+
import { TraceWriter } from "./writer.js";
|
|
31
|
+
export class RequestTracer {
|
|
32
|
+
#writer;
|
|
33
|
+
#log;
|
|
34
|
+
#provider;
|
|
35
|
+
#runId;
|
|
36
|
+
#adapterVersion;
|
|
37
|
+
#requestIndex = 0;
|
|
38
|
+
#contextHashCounts = new Map();
|
|
39
|
+
constructor(deps) {
|
|
40
|
+
this.#writer = new TraceWriter({ root: deps.root, sessionId: deps.sessionId });
|
|
41
|
+
this.#log = deps.log;
|
|
42
|
+
this.#provider = deps.provider;
|
|
43
|
+
this.#runId = deps.runId;
|
|
44
|
+
this.#adapterVersion = deps.adapterVersion ?? null;
|
|
45
|
+
}
|
|
46
|
+
init() {
|
|
47
|
+
this.#writer.init();
|
|
48
|
+
}
|
|
49
|
+
async *wrap(options, upstream) {
|
|
50
|
+
const t0 = performance.now();
|
|
51
|
+
let record = null;
|
|
52
|
+
try {
|
|
53
|
+
record = this.#startRecord(options);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
record = null; // trace absent; the stream is never affected
|
|
57
|
+
}
|
|
58
|
+
// null until the first event; settled to 0 (unknown) only when no
|
|
59
|
+
// event ever came — a stream whose first event lands in the same
|
|
60
|
+
// tick records 0, the resolution limit of the "0 = unknown" marker
|
|
61
|
+
let ttftMs = null;
|
|
62
|
+
let inputTokens = null;
|
|
63
|
+
let cacheRead = null;
|
|
64
|
+
let cacheWrite = null;
|
|
65
|
+
let outputTokens = null;
|
|
66
|
+
let usageKnown = false;
|
|
67
|
+
const toolCalls = [];
|
|
68
|
+
let outcome = "ok";
|
|
69
|
+
try {
|
|
70
|
+
for await (const ev of upstream) {
|
|
71
|
+
// the null check must be the ONLY guard — a 0-initialized
|
|
72
|
+
// number never moves (the slice-3 dead-field finding)
|
|
73
|
+
if (ttftMs === null)
|
|
74
|
+
ttftMs = performance.now() - t0;
|
|
75
|
+
if (ev.type === "tool_call_start")
|
|
76
|
+
toolCalls.push(ev.name);
|
|
77
|
+
if (ev.type === "usage") {
|
|
78
|
+
usageKnown = usageKnown || ev.known;
|
|
79
|
+
if (ev.inputTokens !== null)
|
|
80
|
+
inputTokens = ev.inputTokens;
|
|
81
|
+
if (ev.cacheRead !== null)
|
|
82
|
+
cacheRead = ev.cacheRead;
|
|
83
|
+
if (ev.cacheWrite !== null)
|
|
84
|
+
cacheWrite = ev.cacheWrite;
|
|
85
|
+
if (ev.outputTokens !== null)
|
|
86
|
+
outputTokens = ev.outputTokens;
|
|
87
|
+
}
|
|
88
|
+
yield ev;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
outcome = this.#classifyOutcome(err, options);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (record !== null) {
|
|
97
|
+
this.#settle(record, {
|
|
98
|
+
outcome,
|
|
99
|
+
t0,
|
|
100
|
+
ttftMs,
|
|
101
|
+
toolCalls,
|
|
102
|
+
inputTokens,
|
|
103
|
+
cacheRead,
|
|
104
|
+
cacheWrite,
|
|
105
|
+
outputTokens,
|
|
106
|
+
usageKnown,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Clean-settle marking for the whole run. */
|
|
112
|
+
finishRun() {
|
|
113
|
+
this.#writer.finishRun(this.#runId, this.#requestIndex - 1);
|
|
114
|
+
}
|
|
115
|
+
#startRecord(options) {
|
|
116
|
+
const { systemPrompt, tools, messages } = options;
|
|
117
|
+
const contextHash = hashContext(systemPrompt, tools, messages);
|
|
118
|
+
const retryAttempt = this.#contextHashCounts.get(contextHash) ?? 0;
|
|
119
|
+
this.#contextHashCounts.set(contextHash, retryAttempt + 1);
|
|
120
|
+
const manifest = buildContextManifest({ log: this.#log, systemPrompt, tools, messages });
|
|
121
|
+
const hashes = segmentHashes(systemPrompt, tools, messages);
|
|
122
|
+
return {
|
|
123
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
124
|
+
kind: "request",
|
|
125
|
+
requestId: randomUUID(),
|
|
126
|
+
runId: this.#runId,
|
|
127
|
+
requestIndex: this.#requestIndex++,
|
|
128
|
+
retryAttempt,
|
|
129
|
+
provider: this.#provider,
|
|
130
|
+
model: options.model,
|
|
131
|
+
adapterVersion: this.#adapterVersion,
|
|
132
|
+
systemPromptHash: hashSystemPrompt(systemPrompt),
|
|
133
|
+
toolSchemaHash: hashToolSpecs(tools),
|
|
134
|
+
contextHash,
|
|
135
|
+
contextManifest: manifest,
|
|
136
|
+
// R4b: the per-segment hash LIST rides the record (the break
|
|
137
|
+
// derivation is analysis-side but needs the list, not the
|
|
138
|
+
// aggregated fingerprint — slice 5's data source); the
|
|
139
|
+
// fingerprint covers the CACHEABLE prefix only — the current
|
|
140
|
+
// turn (freshness fresh) is never part of it (slice 4)
|
|
141
|
+
segmentHashes: hashes,
|
|
142
|
+
stablePrefixFingerprint: stablePrefixFingerprint(cacheableHashes(manifest, hashes)),
|
|
143
|
+
freshInput: 0, // unknown until the usage event — "0 = unknown"
|
|
144
|
+
cacheRead: 0,
|
|
145
|
+
cacheWrite: null,
|
|
146
|
+
output: 0,
|
|
147
|
+
// the canonical block starts at the same "0 = unknown" convention
|
|
148
|
+
// and is settled with the quartet (same raw, one derivation)
|
|
149
|
+
canonical: {
|
|
150
|
+
input: 0,
|
|
151
|
+
output: 0,
|
|
152
|
+
cacheRead: 0,
|
|
153
|
+
cacheWrite: null,
|
|
154
|
+
reasoning: null,
|
|
155
|
+
costUsd: 0,
|
|
156
|
+
pricingTableId: PRICING_TABLE_V1.id,
|
|
157
|
+
pricingTableVersion: PRICING_TABLE_V1.version,
|
|
158
|
+
},
|
|
159
|
+
latencyMs: 0,
|
|
160
|
+
ttftMs: 0,
|
|
161
|
+
toolCalls: [],
|
|
162
|
+
outcome: "ok",
|
|
163
|
+
ts: Date.now(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
#settle(record, p) {
|
|
167
|
+
record.outcome = p.outcome;
|
|
168
|
+
record.latencyMs = performance.now() - p.t0;
|
|
169
|
+
record.ttftMs = p.ttftMs ?? 0; // null = no event ever — the "0 = unknown" marker
|
|
170
|
+
record.toolCalls = p.toolCalls;
|
|
171
|
+
if (p.usageKnown) {
|
|
172
|
+
record.freshInput =
|
|
173
|
+
this.#provider === "anthropic"
|
|
174
|
+
? p.inputTokens ?? 0
|
|
175
|
+
: p.inputTokens !== null
|
|
176
|
+
? Math.max(0, p.inputTokens - (p.cacheRead ?? 0))
|
|
177
|
+
: 0;
|
|
178
|
+
record.cacheRead = p.cacheRead ?? 0;
|
|
179
|
+
record.cacheWrite = p.cacheWrite ?? null;
|
|
180
|
+
record.output = p.outputTokens ?? 0;
|
|
181
|
+
}
|
|
182
|
+
// E2 — the canonical block formalizes the same raw (the route-keyed
|
|
183
|
+
// mapping; the validator pins block == quartet, so the two can never
|
|
184
|
+
// drift apart). Unknown usage canonicalizes to the "0 = unknown"
|
|
185
|
+
// convention, consistent with the quartet above.
|
|
186
|
+
record.canonical = canonicalizeUsage(this.#provider, {
|
|
187
|
+
inputTokens: p.inputTokens,
|
|
188
|
+
outputTokens: p.outputTokens,
|
|
189
|
+
cacheRead: p.cacheRead,
|
|
190
|
+
cacheWrite: p.cacheWrite,
|
|
191
|
+
});
|
|
192
|
+
this.#writer.enqueue(record);
|
|
193
|
+
}
|
|
194
|
+
#classifyOutcome(err, options) {
|
|
195
|
+
if (options.signal?.aborted === true)
|
|
196
|
+
return "aborted";
|
|
197
|
+
const name = err instanceof Error ? err.name : "";
|
|
198
|
+
if (name === "AbortError" || name === "APIUserAbortError")
|
|
199
|
+
return "aborted";
|
|
200
|
+
return "provider_error";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Wrap the adapter so every stream() call settles a trace record. */
|
|
204
|
+
export function traceGuard(tracer, adapter) {
|
|
205
|
+
return {
|
|
206
|
+
stream: (options) => tracer.wrap(options, adapter.stream(options)),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — the request hashes (proposal §4, ruling R4b + R1a).
|
|
3
|
+
*
|
|
4
|
+
* Every hash is pinned to the schemaVersion's HashSpec (sha-256,
|
|
5
|
+
* full hex) — `hashSpecFor(TRACE_SCHEMA_VERSION)` is checked at every
|
|
6
|
+
* call, so a version bump without a re-pin fails loudly instead of
|
|
7
|
+
* silently changing the algorithm. The canonical serialization is
|
|
8
|
+
* JSON.stringify of the request-shaped values; the byte discipline is
|
|
9
|
+
* that the same construction path (the kernel's projection) produces
|
|
10
|
+
* the same serialization, which the trace-bytes gate pins end-to-end.
|
|
11
|
+
*/
|
|
12
|
+
import type { Message, ToolSpec } from "@vincemakes/kiso-core";
|
|
13
|
+
export declare function sha256Hex(input: string): string;
|
|
14
|
+
export declare const canonicalJson: (v: unknown) => string;
|
|
15
|
+
export declare function hashSystemPrompt(systemPrompt: string | undefined): string;
|
|
16
|
+
export declare function hashToolSpecs(tools: readonly ToolSpec[] | undefined): string;
|
|
17
|
+
/** The full request projection — the same messages array the loop hands
|
|
18
|
+
* the adapter, so a retry re-serializes to an IDENTICAL hash (§1.4). */
|
|
19
|
+
export declare function hashContext(systemPrompt: string | undefined, tools: readonly ToolSpec[] | undefined, messages: readonly Message[]): string;
|
|
20
|
+
/** sha-256 over the per-segment hashes of the cacheable prefix — the
|
|
21
|
+
* segments up to but NOT including the current turn (R4b). */
|
|
22
|
+
export declare function stablePrefixFingerprint(segmentHashes: readonly string[]): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — the request hashes (proposal §4, ruling R4b + R1a).
|
|
3
|
+
*
|
|
4
|
+
* Every hash is pinned to the schemaVersion's HashSpec (sha-256,
|
|
5
|
+
* full hex) — `hashSpecFor(TRACE_SCHEMA_VERSION)` is checked at every
|
|
6
|
+
* call, so a version bump without a re-pin fails loudly instead of
|
|
7
|
+
* silently changing the algorithm. The canonical serialization is
|
|
8
|
+
* JSON.stringify of the request-shaped values; the byte discipline is
|
|
9
|
+
* that the same construction path (the kernel's projection) produces
|
|
10
|
+
* the same serialization, which the trace-bytes gate pins end-to-end.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { TRACE_SCHEMA_VERSION, hashSpecFor } from "./record.js";
|
|
14
|
+
export function sha256Hex(input) {
|
|
15
|
+
hashSpecFor(TRACE_SCHEMA_VERSION); // the pinned algorithm, or fail loudly
|
|
16
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
17
|
+
}
|
|
18
|
+
export const canonicalJson = (v) => JSON.stringify(v);
|
|
19
|
+
export function hashSystemPrompt(systemPrompt) {
|
|
20
|
+
return sha256Hex(systemPrompt ?? "");
|
|
21
|
+
}
|
|
22
|
+
export function hashToolSpecs(tools) {
|
|
23
|
+
return sha256Hex(canonicalJson(tools ?? []));
|
|
24
|
+
}
|
|
25
|
+
/** The full request projection — the same messages array the loop hands
|
|
26
|
+
* the adapter, so a retry re-serializes to an IDENTICAL hash (§1.4). */
|
|
27
|
+
export function hashContext(systemPrompt, tools, messages) {
|
|
28
|
+
return sha256Hex(canonicalJson({ systemPrompt, tools, messages }));
|
|
29
|
+
}
|
|
30
|
+
/** sha-256 over the per-segment hashes of the cacheable prefix — the
|
|
31
|
+
* segments up to but NOT including the current turn (R4b). */
|
|
32
|
+
export function stablePrefixFingerprint(segmentHashes) {
|
|
33
|
+
return sha256Hex(segmentHashes.join(""));
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 3, the context manifest (proposal §1.3).
|
|
3
|
+
*
|
|
4
|
+
* buildContextManifest derives the per-request segment list from the
|
|
5
|
+
* session log and the request projection. Segments are the system
|
|
6
|
+
* prompt, the tool schema, then one per user turn. Each turn carries a
|
|
7
|
+
* THIN seqRange pointer into the event log ([firstSeq, lastSeq] of the
|
|
8
|
+
* events that produced it) plus an estTokens estimate — the record
|
|
9
|
+
* never copies payloads (proposal §1.1).
|
|
10
|
+
*
|
|
11
|
+
* Turn boundaries are the log's visible user_input events: a VETOED
|
|
12
|
+
* input (user_input_replaced with null content) is not a boundary — the
|
|
13
|
+
* model never saw it; a REWRITE keeps the original boundary position
|
|
14
|
+
* (the projection renders the replacement in place, events.ts:339).
|
|
15
|
+
* The last turn is current_turn/fresh, earlier turns cache_read; the
|
|
16
|
+
* system/tools head is cache_read (a stable prefix, R4b).
|
|
17
|
+
*
|
|
18
|
+
* When the projection's user-message count and the log's visible
|
|
19
|
+
* boundary count diverge (a summary replaced whole turns, an alignment
|
|
20
|
+
* surprise), every turn range degrades to null — honest thin pointers
|
|
21
|
+
* rather than wrong ones.
|
|
22
|
+
*/
|
|
23
|
+
import type { Event, Message, ToolSpec } from "@vincemakes/kiso-core";
|
|
24
|
+
import type { TraceSegment } from "./record.js";
|
|
25
|
+
export interface ManifestInput {
|
|
26
|
+
log: readonly Event[];
|
|
27
|
+
systemPrompt: string | undefined;
|
|
28
|
+
tools: readonly ToolSpec[] | undefined;
|
|
29
|
+
messages: readonly Message[];
|
|
30
|
+
}
|
|
31
|
+
export declare function buildContextManifest(input: ManifestInput): TraceSegment[];
|
|
32
|
+
/** Per-segment content hashes for the fingerprint (R4b): the system
|
|
33
|
+
* prompt, the tool schema, and each turn's messages, in manifest
|
|
34
|
+
* order. The CURRENT turn's hash is the last element — the cacheable
|
|
35
|
+
* prefix is everything before it. */
|
|
36
|
+
export declare function segmentHashes(systemPrompt: string | undefined, tools: readonly ToolSpec[] | undefined, messages: readonly Message[]): string[];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 3, the context manifest (proposal §1.3).
|
|
3
|
+
*
|
|
4
|
+
* buildContextManifest derives the per-request segment list from the
|
|
5
|
+
* session log and the request projection. Segments are the system
|
|
6
|
+
* prompt, the tool schema, then one per user turn. Each turn carries a
|
|
7
|
+
* THIN seqRange pointer into the event log ([firstSeq, lastSeq] of the
|
|
8
|
+
* events that produced it) plus an estTokens estimate — the record
|
|
9
|
+
* never copies payloads (proposal §1.1).
|
|
10
|
+
*
|
|
11
|
+
* Turn boundaries are the log's visible user_input events: a VETOED
|
|
12
|
+
* input (user_input_replaced with null content) is not a boundary — the
|
|
13
|
+
* model never saw it; a REWRITE keeps the original boundary position
|
|
14
|
+
* (the projection renders the replacement in place, events.ts:339).
|
|
15
|
+
* The last turn is current_turn/fresh, earlier turns cache_read; the
|
|
16
|
+
* system/tools head is cache_read (a stable prefix, R4b).
|
|
17
|
+
*
|
|
18
|
+
* When the projection's user-message count and the log's visible
|
|
19
|
+
* boundary count diverge (a summary replaced whole turns, an alignment
|
|
20
|
+
* surprise), every turn range degrades to null — honest thin pointers
|
|
21
|
+
* rather than wrong ones.
|
|
22
|
+
*/
|
|
23
|
+
import { estimateTokens } from "@vincemakes/kiso-core";
|
|
24
|
+
import { canonicalJson, hashSystemPrompt, hashToolSpecs, sha256Hex } from "./hash.js";
|
|
25
|
+
/** The log's visible user boundaries, in log order: every user_input
|
|
26
|
+
* whose replacement (if any) is not a veto. */
|
|
27
|
+
function visibleBoundaries(log) {
|
|
28
|
+
const vetoed = new Set();
|
|
29
|
+
for (const ev of log) {
|
|
30
|
+
if (ev.type === "user_input_replaced" && ev.content === null)
|
|
31
|
+
vetoed.add(ev.replaces);
|
|
32
|
+
}
|
|
33
|
+
const result = [];
|
|
34
|
+
for (const ev of log) {
|
|
35
|
+
if (ev.type === "user_input" && !vetoed.has(ev.seq))
|
|
36
|
+
result.push(ev.seq);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
const systemSegment = (systemPrompt) => ({
|
|
41
|
+
role: "system",
|
|
42
|
+
seqRange: null,
|
|
43
|
+
estTokens: estimateTokens([{ role: "user", content: systemPrompt ?? "" }]),
|
|
44
|
+
freshness: "cache_read",
|
|
45
|
+
});
|
|
46
|
+
const toolsSegment = (tools) => ({
|
|
47
|
+
role: "tools",
|
|
48
|
+
seqRange: null,
|
|
49
|
+
estTokens: estimateTokens([{ role: "tool", callId: "", content: JSON.stringify(tools ?? []), isError: false }]),
|
|
50
|
+
freshness: "cache_read",
|
|
51
|
+
});
|
|
52
|
+
export function buildContextManifest(input) {
|
|
53
|
+
const { log, systemPrompt, tools, messages } = input;
|
|
54
|
+
const boundaries = visibleBoundaries(log);
|
|
55
|
+
const userCount = messages.filter((m) => m.role === "user").length;
|
|
56
|
+
const aligned = userCount === boundaries.length;
|
|
57
|
+
const lastSeq = log.length > 0 ? log[log.length - 1].seq : -1;
|
|
58
|
+
const segments = [systemSegment(systemPrompt), toolsSegment(tools)];
|
|
59
|
+
// Partition the projection at user boundaries: partition k runs from
|
|
60
|
+
// user_k (inclusive) to user_{k+1} (exclusive); messages before the
|
|
61
|
+
// first user (not produced by the projection today) join partition 0.
|
|
62
|
+
const userPositions = [];
|
|
63
|
+
messages.forEach((m, i) => {
|
|
64
|
+
if (m.role === "user")
|
|
65
|
+
userPositions.push(i);
|
|
66
|
+
});
|
|
67
|
+
for (let k = 0; k < userPositions.length; k++) {
|
|
68
|
+
const start = k === 0 ? 0 : userPositions[k];
|
|
69
|
+
const end = k + 1 < userPositions.length ? userPositions[k + 1] : messages.length;
|
|
70
|
+
const isCurrent = k === userPositions.length - 1;
|
|
71
|
+
const range = aligned && boundaries[k] !== undefined
|
|
72
|
+
? [boundaries[k], k + 1 < boundaries.length ? boundaries[k + 1] - 1 : lastSeq]
|
|
73
|
+
: null;
|
|
74
|
+
segments.push({
|
|
75
|
+
role: isCurrent ? "current_turn" : "turn",
|
|
76
|
+
seqRange: range,
|
|
77
|
+
estTokens: estimateTokens(messages.slice(start, end)),
|
|
78
|
+
freshness: isCurrent ? "fresh" : "cache_read",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return segments;
|
|
82
|
+
}
|
|
83
|
+
/** Per-segment content hashes for the fingerprint (R4b): the system
|
|
84
|
+
* prompt, the tool schema, and each turn's messages, in manifest
|
|
85
|
+
* order. The CURRENT turn's hash is the last element — the cacheable
|
|
86
|
+
* prefix is everything before it. */
|
|
87
|
+
export function segmentHashes(systemPrompt, tools, messages) {
|
|
88
|
+
const hashes = [hashSystemPrompt(systemPrompt), hashToolSpecs(tools)];
|
|
89
|
+
const userPositions = [];
|
|
90
|
+
messages.forEach((m, i) => {
|
|
91
|
+
if (m.role === "user")
|
|
92
|
+
userPositions.push(i);
|
|
93
|
+
});
|
|
94
|
+
for (let k = 0; k < userPositions.length; k++) {
|
|
95
|
+
const start = k === 0 ? 0 : userPositions[k];
|
|
96
|
+
const end = k + 1 < userPositions.length ? userPositions[k + 1] : messages.length;
|
|
97
|
+
hashes.push(sha256Hex(canonicalJson(messages.slice(start, end))));
|
|
98
|
+
}
|
|
99
|
+
return hashes;
|
|
100
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — the Request Trace record schema (proposal §1.1 as adopted
|
|
3
|
+
* by the R1 ruling: "adopt as-is, keep ts, per-round additive"). This is
|
|
4
|
+
* the 1.2.0 field-set lock: every field below is spec'd, and the
|
|
5
|
+
* closed-field-set gate (ruling R1a) pins the shape bidirectionally —
|
|
6
|
+
* `TRACE_RECORD_FIELDS`/`TRACE_SEGMENT_FIELDS` must stay in exact
|
|
7
|
+
* agreement with the interfaces, or trace-schema.test.ts goes red.
|
|
8
|
+
*
|
|
9
|
+
* R1a: `schemaVersion` pins BOTH the record shape AND the hash /
|
|
10
|
+
* fingerprint algorithms (`HASH_SPEC_BY_VERSION`). A version bump must
|
|
11
|
+
* pin a spec for the new version before any writer can use it
|
|
12
|
+
* (`hashSpecFor` throws otherwise).
|
|
13
|
+
*
|
|
14
|
+
* The ledger is OUT-side (ADR-0051 §6): versionable, never part of the
|
|
15
|
+
* correctness ABI. These types are runtime-internal.
|
|
16
|
+
*
|
|
17
|
+
* E2 (1.3.0) — schemaVersion 2: the record gains the `canonical` block
|
|
18
|
+
* (E2 proposal §1.5 Case A — the nested block, ruled 2026-08-13; raw
|
|
19
|
+
* quartet stays as provider observation above it). The validators accept
|
|
20
|
+
* BOTH generations (R1d-1): a v1 sidecar has no canonical block and reads
|
|
21
|
+
* as defaults at every consumer — never a crash.
|
|
22
|
+
*/
|
|
23
|
+
/** schemaVersion: 2 for 1.3.0 (the canonical block). Version 1 = the 1.2.0
|
|
24
|
+
* shape, kept for generation-compat reads (R1d-1). Algorithm and shape
|
|
25
|
+
* changes bump it (ADR-0051 §6 OUT-side versioning). */
|
|
26
|
+
export declare const TRACE_SCHEMA_VERSION = 2;
|
|
27
|
+
/** The versions a reader may meet in a ledger. v1 records are accepted
|
|
28
|
+
* (generation-compat, R1d-1) and read as defaults — no canonical block. */
|
|
29
|
+
export declare const TRACE_SCHEMA_VERSIONS: Readonly<Set<number>>;
|
|
30
|
+
import type { CanonicalUsage } from "../usage/canonical.js";
|
|
31
|
+
export type Freshness = "fresh" | "cache_read" | "cache_write";
|
|
32
|
+
/** That is the complete set for 1.2.0. */
|
|
33
|
+
export type Outcome = "ok" | "provider_error" | "aborted";
|
|
34
|
+
/** That is the complete set for 1.2.0. (Truncation surfaces as "aborted"
|
|
35
|
+
* when the stream ends before settle; refined in E5 if needed.) */
|
|
36
|
+
export interface TraceSegment {
|
|
37
|
+
role: "system" | "tools" | "turn" | "current_turn";
|
|
38
|
+
/** Thin pointer into the event log: [firstSeq, lastSeq] inclusive of the
|
|
39
|
+
* events that produced this segment. null for system/tools (not events). */
|
|
40
|
+
seqRange: [number, number] | null;
|
|
41
|
+
estTokens: number;
|
|
42
|
+
freshness: Freshness;
|
|
43
|
+
}
|
|
44
|
+
/** That is the complete set for 1.2.0. */
|
|
45
|
+
export interface TraceRecord {
|
|
46
|
+
schemaVersion: 2;
|
|
47
|
+
kind: "request";
|
|
48
|
+
requestId: string;
|
|
49
|
+
runId: string;
|
|
50
|
+
requestIndex: number;
|
|
51
|
+
retryAttempt: number;
|
|
52
|
+
provider: string;
|
|
53
|
+
model: string;
|
|
54
|
+
adapterVersion: string | null;
|
|
55
|
+
systemPromptHash: string;
|
|
56
|
+
toolSchemaHash: string;
|
|
57
|
+
contextHash: string;
|
|
58
|
+
contextManifest: TraceSegment[];
|
|
59
|
+
/** Per-segment content hashes, indexed 1:1 with contextManifest
|
|
60
|
+
* (segment i's canonical serialization — R4b's analysis-side data
|
|
61
|
+
* source: the cache-break derivation needs the LIST, not the
|
|
62
|
+
* aggregated fingerprint). Sizing note: 64 hex chars × segments
|
|
63
|
+
* per request. */
|
|
64
|
+
segmentHashes: string[];
|
|
65
|
+
stablePrefixFingerprint: string;
|
|
66
|
+
/** The usage quartet is PROVIDER-RAW — never a billing surface.
|
|
67
|
+
* Canonical/billing usage lives in the `canonical` block (E2); these
|
|
68
|
+
* fields are observation only (a provider may count a token a dozen
|
|
69
|
+
* ways; billing must not). */
|
|
70
|
+
freshInput: number;
|
|
71
|
+
cacheRead: number;
|
|
72
|
+
cacheWrite: number | null;
|
|
73
|
+
output: number;
|
|
74
|
+
/** E2 — the canonical record of the same raw quartet (the pinned
|
|
75
|
+
* sentence: input is FRESH-ONLY; total = input + cacheRead + cacheWrite
|
|
76
|
+
* is the derived quantity). Formalizes the quartet by construction —
|
|
77
|
+
* the validator pins the equality — and carries the cost from the
|
|
78
|
+
* versioned pricing table (every cost records its table version). */
|
|
79
|
+
canonical: CanonicalUsage;
|
|
80
|
+
latencyMs: number;
|
|
81
|
+
ttftMs: number;
|
|
82
|
+
toolCalls: string[];
|
|
83
|
+
outcome: Outcome;
|
|
84
|
+
lineageLink?: {
|
|
85
|
+
parentSessionId: string;
|
|
86
|
+
parentRunId: string;
|
|
87
|
+
parentInvocationSeq: number;
|
|
88
|
+
role: string;
|
|
89
|
+
};
|
|
90
|
+
ts: number;
|
|
91
|
+
}
|
|
92
|
+
/** That is the complete set for 1.2.0. */
|
|
93
|
+
export interface HeaderLine {
|
|
94
|
+
schemaVersion: 2;
|
|
95
|
+
kind: "header";
|
|
96
|
+
sessionId: string;
|
|
97
|
+
kisoVersion: string;
|
|
98
|
+
createdAt: number;
|
|
99
|
+
}
|
|
100
|
+
export interface RunEndLine {
|
|
101
|
+
schemaVersion: 2;
|
|
102
|
+
kind: "run_end";
|
|
103
|
+
runId: string;
|
|
104
|
+
ts: number;
|
|
105
|
+
lastRequestIndex: number;
|
|
106
|
+
}
|
|
107
|
+
export interface CrashLine {
|
|
108
|
+
schemaVersion: 2;
|
|
109
|
+
kind: "crash";
|
|
110
|
+
ts: number;
|
|
111
|
+
note: string;
|
|
112
|
+
}
|
|
113
|
+
export type TraceLine = HeaderLine | TraceRecord | RunEndLine | CrashLine;
|
|
114
|
+
export interface HashSpec {
|
|
115
|
+
readonly algorithm: "sha-256";
|
|
116
|
+
readonly output: "full-hex";
|
|
117
|
+
}
|
|
118
|
+
export declare const HASH_SPEC_BY_VERSION: Readonly<Record<number, HashSpec>>;
|
|
119
|
+
export declare function hashSpecFor(version: number): HashSpec;
|
|
120
|
+
/** The 1.2.0 field set (schemaVersion 1) — kept verbatim for
|
|
121
|
+
* generation-compat reads of old sidecars (R1d-1): a v1 record has no
|
|
122
|
+
* canonical block and reads as defaults at every consumer. */
|
|
123
|
+
export declare const TRACE_RECORD_FIELDS_V1: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts"];
|
|
124
|
+
/** The 1.3.0 field set (schemaVersion 2) = the v1 set + `canonical`. */
|
|
125
|
+
export declare const TRACE_RECORD_FIELDS: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical"];
|
|
126
|
+
export declare const TRACE_SEGMENT_FIELDS: readonly ["role", "seqRange", "estTokens", "freshness"];
|
|
127
|
+
export declare function validateTraceSegment(v: unknown): v is TraceSegment;
|
|
128
|
+
export declare function validateTraceRecord(v: unknown): v is TraceRecord;
|
|
129
|
+
export declare function validateTraceLine(v: unknown): v is TraceLine;
|