@vincemakes/kiso-runtime 1.1.0 → 1.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.js +1 -0
- package/dist/run.js +21 -1
- package/dist/session.d.ts +3 -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 +185 -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 +107 -0
- package/dist/trace/record.js +185 -0
- package/dist/trace/writer.d.ts +43 -0
- package/dist/trace/writer.js +154 -0
- package/package.json +2 -2
package/dist/agent.js
CHANGED
|
@@ -55,6 +55,7 @@ export class AgentRuntime {
|
|
|
55
55
|
const adapter = await this.#adapterPromise;
|
|
56
56
|
const config = {
|
|
57
57
|
model: this.#definition.model,
|
|
58
|
+
...(this.#definition.provider !== undefined ? { provider: this.#definition.provider } : {}),
|
|
58
59
|
...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
|
|
59
60
|
registry: this.#registry,
|
|
60
61
|
...(this.#definition.permissionPolicy !== undefined || this.#definition.hooks !== undefined
|
package/dist/run.js
CHANGED
|
@@ -8,6 +8,8 @@ import { ABORTED, MergedSignal, abortable, openRunId } from "./recovery.js";
|
|
|
8
8
|
import { deriveRecoveryPlan, invocationSeqOf } from "./recovery-plan.js";
|
|
9
9
|
import { composeApprovalChain, composeSystemPrompt, composeToolTable, microcompactFor } from "./compose.js";
|
|
10
10
|
import { truncationGuard } from "./truncation-guard.js";
|
|
11
|
+
import { RequestTracer, traceGuard } from "./trace/guard.js";
|
|
12
|
+
import { runtimeVersion } from "./trace/writer.js";
|
|
11
13
|
import { ResumeBlockedError } from "./session.js";
|
|
12
14
|
/**
|
|
13
15
|
* A single turn. Async-iterable, so `for await (const ev of session.run(x))`
|
|
@@ -47,6 +49,7 @@ export class Run {
|
|
|
47
49
|
// The WHOLE body is one try/finally: a consumer that abandons the
|
|
48
50
|
// run at ANY yield (even the user_input one) must release the
|
|
49
51
|
// session's single-run slot and its approval resolvers.
|
|
52
|
+
let tracer = null;
|
|
50
53
|
try {
|
|
51
54
|
// round 4: health is re-checked when the iterator ACTUALLY starts —
|
|
52
55
|
// a run constructed before the session was poisoned must fail
|
|
@@ -54,6 +57,20 @@ export class Run {
|
|
|
54
57
|
this.#session.ensureHealthy();
|
|
55
58
|
this.#session.beginRun(this);
|
|
56
59
|
const log = this.#session.log;
|
|
60
|
+
// E1 (1.2.0): the request tracer — the observation ledger. It
|
|
61
|
+
// sits at the adapter boundary; the model-visible byte stream is
|
|
62
|
+
// untouched (I6, trace-bytes.test.ts). Soft-fail: a degraded
|
|
63
|
+
// writer costs one stderr line and the run goes on.
|
|
64
|
+
tracer = new RequestTracer({
|
|
65
|
+
root: this.#store.root,
|
|
66
|
+
sessionId: this.#session.id,
|
|
67
|
+
runId: this.runId,
|
|
68
|
+
provider: this.#config.provider ?? "adapter",
|
|
69
|
+
model: this.#config.model,
|
|
70
|
+
adapterVersion: runtimeVersion(),
|
|
71
|
+
log: log.all,
|
|
72
|
+
});
|
|
73
|
+
tracer.init();
|
|
57
74
|
const signal = this.#externalSignal ? new MergedSignal(this.#abort.signal, this.#externalSignal) : this.#abort.signal;
|
|
58
75
|
// E2: the session's own microcompact wins; otherwise the FIRST
|
|
59
76
|
// extension providing a compaction config supplies it.
|
|
@@ -75,7 +92,7 @@ export class Run {
|
|
|
75
92
|
const loopConfig = () => ({
|
|
76
93
|
// 0.1.40 (R-C item 3): the truncation guard gates the model
|
|
77
94
|
// stream — a truncated turn's tool batch never executes.
|
|
78
|
-
adapter: truncationGuard(this.#adapter),
|
|
95
|
+
adapter: traceGuard(tracer, truncationGuard(this.#adapter)), // tracer assigned above, before loopConfig
|
|
79
96
|
model: this.#config.model,
|
|
80
97
|
sessionId: this.#session.id, // P3: tools see their session (ToolContext.sessionId)
|
|
81
98
|
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
|
@@ -230,6 +247,9 @@ export class Run {
|
|
|
230
247
|
for (const executionId of this.#uncertaintyIds) {
|
|
231
248
|
this.#session.dropUncertaintyResolver(executionId);
|
|
232
249
|
}
|
|
250
|
+
// E1: the run's ledger story — the run_end lands synchronously
|
|
251
|
+
// (a killed run leaves no run_end, and the next init marks it).
|
|
252
|
+
tracer?.finishRun();
|
|
233
253
|
this.#session.endRun(this);
|
|
234
254
|
}
|
|
235
255
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -179,6 +179,9 @@ export declare class AgentSession {
|
|
|
179
179
|
}
|
|
180
180
|
export interface SessionConfig {
|
|
181
181
|
readonly model: string;
|
|
182
|
+
/** E1: the adapter identity ("anthropic" | "openai-compat") — trace
|
|
183
|
+
* provenance, additive (S1 surface untouched: type-only, optional). */
|
|
184
|
+
readonly provider?: "anthropic" | "openai-compat";
|
|
182
185
|
readonly systemPrompt?: string;
|
|
183
186
|
readonly tools?: readonly Tool<any>[];
|
|
184
187
|
readonly registry: import("@vincemakes/kiso-core").ToolRegistry;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 4, the cache-break derivation (proposal §4, ruling
|
|
3
|
+
* R4b: per-segment hashes + prefix fingerprint; the BREAK COUNT is an
|
|
4
|
+
* analysis-side derivation, never a recorded field).
|
|
5
|
+
*
|
|
6
|
+
* The cacheable prefix is every segment that is NOT the current turn —
|
|
7
|
+
* freshness "fresh" is the boundary (manifest.ts). `cacheableHashes`
|
|
8
|
+
* pairs the manifest segments 1:1 with the per-segment hashes and
|
|
9
|
+
* drops the fresh tail, so the fingerprint and the break derivation
|
|
10
|
+
* share one boundary by construction: a current-turn change alone
|
|
11
|
+
* never moves the fingerprint and never counts a break.
|
|
12
|
+
*
|
|
13
|
+
* `prefixBreak` compares two adjacent requests' cacheable prefixes:
|
|
14
|
+
* the first differing segment is the break, at its (0-based) depth;
|
|
15
|
+
* a prefix that merely GREW (a new turn joined) breaks at the old
|
|
16
|
+
* length. Unchanged prefixes → null (0 breaks). This is what
|
|
17
|
+
* bench/trace-report.mjs and the bench render per-request (slice 5).
|
|
18
|
+
*/
|
|
19
|
+
import type { TraceSegment } from "./record.js";
|
|
20
|
+
/** The cacheable-prefix hashes: segments[i] ↔ hashes[i], dropping every
|
|
21
|
+
* freshness "fresh" segment (the current turn). */
|
|
22
|
+
export declare function cacheableHashes(segments: readonly TraceSegment[], hashes: readonly string[]): string[];
|
|
23
|
+
export interface PrefixBreak {
|
|
24
|
+
/** 0-based segment index within the cacheable prefix where the
|
|
25
|
+
* prefix first diverges (depth 0 = the system prompt). */
|
|
26
|
+
readonly depth: number;
|
|
27
|
+
}
|
|
28
|
+
/** R4b: compare two adjacent requests' cacheable prefixes. null = the
|
|
29
|
+
* prefix is unchanged (0 breaks). A prefix that grew breaks at the old
|
|
30
|
+
* length — the new segment is where caching can no longer attach. */
|
|
31
|
+
export declare function prefixBreak(prev: readonly string[], next: readonly string[]): PrefixBreak | null;
|
|
32
|
+
/** Per-request breaks across a run's request sequence: request k's
|
|
33
|
+
* break is relative to request k−1; the first request has no
|
|
34
|
+
* predecessor (null). */
|
|
35
|
+
export declare function deriveBreaks(requests: readonly (readonly string[])[]): (PrefixBreak | null)[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 4, the cache-break derivation (proposal §4, ruling
|
|
3
|
+
* R4b: per-segment hashes + prefix fingerprint; the BREAK COUNT is an
|
|
4
|
+
* analysis-side derivation, never a recorded field).
|
|
5
|
+
*
|
|
6
|
+
* The cacheable prefix is every segment that is NOT the current turn —
|
|
7
|
+
* freshness "fresh" is the boundary (manifest.ts). `cacheableHashes`
|
|
8
|
+
* pairs the manifest segments 1:1 with the per-segment hashes and
|
|
9
|
+
* drops the fresh tail, so the fingerprint and the break derivation
|
|
10
|
+
* share one boundary by construction: a current-turn change alone
|
|
11
|
+
* never moves the fingerprint and never counts a break.
|
|
12
|
+
*
|
|
13
|
+
* `prefixBreak` compares two adjacent requests' cacheable prefixes:
|
|
14
|
+
* the first differing segment is the break, at its (0-based) depth;
|
|
15
|
+
* a prefix that merely GREW (a new turn joined) breaks at the old
|
|
16
|
+
* length. Unchanged prefixes → null (0 breaks). This is what
|
|
17
|
+
* bench/trace-report.mjs and the bench render per-request (slice 5).
|
|
18
|
+
*/
|
|
19
|
+
/** The cacheable-prefix hashes: segments[i] ↔ hashes[i], dropping every
|
|
20
|
+
* freshness "fresh" segment (the current turn). */
|
|
21
|
+
export function cacheableHashes(segments, hashes) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (let i = 0; i < segments.length; i++) {
|
|
24
|
+
if (segments[i].freshness !== "fresh")
|
|
25
|
+
out.push(hashes[i]);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** R4b: compare two adjacent requests' cacheable prefixes. null = the
|
|
30
|
+
* prefix is unchanged (0 breaks). A prefix that grew breaks at the old
|
|
31
|
+
* length — the new segment is where caching can no longer attach. */
|
|
32
|
+
export function prefixBreak(prev, next) {
|
|
33
|
+
const shared = Math.min(prev.length, next.length);
|
|
34
|
+
for (let i = 0; i < shared; i++) {
|
|
35
|
+
if (prev[i] !== next[i])
|
|
36
|
+
return { depth: i };
|
|
37
|
+
}
|
|
38
|
+
if (prev.length !== next.length)
|
|
39
|
+
return { depth: shared };
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** Per-request breaks across a run's request sequence: request k's
|
|
43
|
+
* break is relative to request k−1; the first request has no
|
|
44
|
+
* predecessor (null). */
|
|
45
|
+
export function deriveBreaks(requests) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (let k = 0; k < requests.length; k++) {
|
|
48
|
+
out.push(k === 0 ? null : prefixBreak(requests[k - 1], requests[k]));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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 type { Adapter, AdapterEvent, Event, StreamOptions } from "@vincemakes/kiso-core";
|
|
25
|
+
export interface RequestTracerDeps {
|
|
26
|
+
root: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
runId: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
model: string;
|
|
31
|
+
/** The adapter contract's implementation version (the runtime's own),
|
|
32
|
+
* resolved once at tracer init; null on failure (soft-fail). */
|
|
33
|
+
adapterVersion?: string | null;
|
|
34
|
+
/** The session log — the manifest's seqRange pointers derive from it. */
|
|
35
|
+
log: readonly Event[];
|
|
36
|
+
}
|
|
37
|
+
export declare class RequestTracer {
|
|
38
|
+
#private;
|
|
39
|
+
constructor(deps: RequestTracerDeps);
|
|
40
|
+
init(): void;
|
|
41
|
+
wrap(options: StreamOptions, upstream: AsyncIterable<AdapterEvent>): AsyncIterable<AdapterEvent>;
|
|
42
|
+
/** Clean-settle marking for the whole run. */
|
|
43
|
+
finishRun(): void;
|
|
44
|
+
}
|
|
45
|
+
/** Wrap the adapter so every stream() call settles a trace record. */
|
|
46
|
+
export declare function traceGuard(tracer: RequestTracer, adapter: Adapter): Adapter;
|
|
@@ -0,0 +1,185 @@
|
|
|
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 { TRACE_SCHEMA_VERSION } from "./record.js";
|
|
29
|
+
import { TraceWriter } from "./writer.js";
|
|
30
|
+
export class RequestTracer {
|
|
31
|
+
#writer;
|
|
32
|
+
#log;
|
|
33
|
+
#provider;
|
|
34
|
+
#runId;
|
|
35
|
+
#adapterVersion;
|
|
36
|
+
#requestIndex = 0;
|
|
37
|
+
#contextHashCounts = new Map();
|
|
38
|
+
constructor(deps) {
|
|
39
|
+
this.#writer = new TraceWriter({ root: deps.root, sessionId: deps.sessionId });
|
|
40
|
+
this.#log = deps.log;
|
|
41
|
+
this.#provider = deps.provider;
|
|
42
|
+
this.#runId = deps.runId;
|
|
43
|
+
this.#adapterVersion = deps.adapterVersion ?? null;
|
|
44
|
+
}
|
|
45
|
+
init() {
|
|
46
|
+
this.#writer.init();
|
|
47
|
+
}
|
|
48
|
+
async *wrap(options, upstream) {
|
|
49
|
+
const t0 = performance.now();
|
|
50
|
+
let record = null;
|
|
51
|
+
try {
|
|
52
|
+
record = this.#startRecord(options);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
record = null; // trace absent; the stream is never affected
|
|
56
|
+
}
|
|
57
|
+
// null until the first event; settled to 0 (unknown) only when no
|
|
58
|
+
// event ever came — a stream whose first event lands in the same
|
|
59
|
+
// tick records 0, the resolution limit of the "0 = unknown" marker
|
|
60
|
+
let ttftMs = null;
|
|
61
|
+
let inputTokens = null;
|
|
62
|
+
let cacheRead = null;
|
|
63
|
+
let cacheWrite = null;
|
|
64
|
+
let outputTokens = null;
|
|
65
|
+
let usageKnown = false;
|
|
66
|
+
const toolCalls = [];
|
|
67
|
+
let outcome = "ok";
|
|
68
|
+
try {
|
|
69
|
+
for await (const ev of upstream) {
|
|
70
|
+
// the null check must be the ONLY guard — a 0-initialized
|
|
71
|
+
// number never moves (the slice-3 dead-field finding)
|
|
72
|
+
if (ttftMs === null)
|
|
73
|
+
ttftMs = performance.now() - t0;
|
|
74
|
+
if (ev.type === "tool_call_start")
|
|
75
|
+
toolCalls.push(ev.name);
|
|
76
|
+
if (ev.type === "usage") {
|
|
77
|
+
usageKnown = usageKnown || ev.known;
|
|
78
|
+
if (ev.inputTokens !== null)
|
|
79
|
+
inputTokens = ev.inputTokens;
|
|
80
|
+
if (ev.cacheRead !== null)
|
|
81
|
+
cacheRead = ev.cacheRead;
|
|
82
|
+
if (ev.cacheWrite !== null)
|
|
83
|
+
cacheWrite = ev.cacheWrite;
|
|
84
|
+
if (ev.outputTokens !== null)
|
|
85
|
+
outputTokens = ev.outputTokens;
|
|
86
|
+
}
|
|
87
|
+
yield ev;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
outcome = this.#classifyOutcome(err, options);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (record !== null) {
|
|
96
|
+
this.#settle(record, {
|
|
97
|
+
outcome,
|
|
98
|
+
t0,
|
|
99
|
+
ttftMs,
|
|
100
|
+
toolCalls,
|
|
101
|
+
inputTokens,
|
|
102
|
+
cacheRead,
|
|
103
|
+
cacheWrite,
|
|
104
|
+
outputTokens,
|
|
105
|
+
usageKnown,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Clean-settle marking for the whole run. */
|
|
111
|
+
finishRun() {
|
|
112
|
+
this.#writer.finishRun(this.#runId, this.#requestIndex - 1);
|
|
113
|
+
}
|
|
114
|
+
#startRecord(options) {
|
|
115
|
+
const { systemPrompt, tools, messages } = options;
|
|
116
|
+
const contextHash = hashContext(systemPrompt, tools, messages);
|
|
117
|
+
const retryAttempt = this.#contextHashCounts.get(contextHash) ?? 0;
|
|
118
|
+
this.#contextHashCounts.set(contextHash, retryAttempt + 1);
|
|
119
|
+
const manifest = buildContextManifest({ log: this.#log, systemPrompt, tools, messages });
|
|
120
|
+
const hashes = segmentHashes(systemPrompt, tools, messages);
|
|
121
|
+
return {
|
|
122
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
123
|
+
kind: "request",
|
|
124
|
+
requestId: randomUUID(),
|
|
125
|
+
runId: this.#runId,
|
|
126
|
+
requestIndex: this.#requestIndex++,
|
|
127
|
+
retryAttempt,
|
|
128
|
+
provider: this.#provider,
|
|
129
|
+
model: options.model,
|
|
130
|
+
adapterVersion: this.#adapterVersion,
|
|
131
|
+
systemPromptHash: hashSystemPrompt(systemPrompt),
|
|
132
|
+
toolSchemaHash: hashToolSpecs(tools),
|
|
133
|
+
contextHash,
|
|
134
|
+
contextManifest: manifest,
|
|
135
|
+
// R4b: the per-segment hash LIST rides the record (the break
|
|
136
|
+
// derivation is analysis-side but needs the list, not the
|
|
137
|
+
// aggregated fingerprint — slice 5's data source); the
|
|
138
|
+
// fingerprint covers the CACHEABLE prefix only — the current
|
|
139
|
+
// turn (freshness fresh) is never part of it (slice 4)
|
|
140
|
+
segmentHashes: hashes,
|
|
141
|
+
stablePrefixFingerprint: stablePrefixFingerprint(cacheableHashes(manifest, hashes)),
|
|
142
|
+
freshInput: 0, // unknown until the usage event — "0 = unknown"
|
|
143
|
+
cacheRead: 0,
|
|
144
|
+
cacheWrite: null,
|
|
145
|
+
output: 0,
|
|
146
|
+
latencyMs: 0,
|
|
147
|
+
ttftMs: 0,
|
|
148
|
+
toolCalls: [],
|
|
149
|
+
outcome: "ok",
|
|
150
|
+
ts: Date.now(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
#settle(record, p) {
|
|
154
|
+
record.outcome = p.outcome;
|
|
155
|
+
record.latencyMs = performance.now() - p.t0;
|
|
156
|
+
record.ttftMs = p.ttftMs ?? 0; // null = no event ever — the "0 = unknown" marker
|
|
157
|
+
record.toolCalls = p.toolCalls;
|
|
158
|
+
if (p.usageKnown) {
|
|
159
|
+
record.freshInput =
|
|
160
|
+
this.#provider === "anthropic"
|
|
161
|
+
? p.inputTokens ?? 0
|
|
162
|
+
: p.inputTokens !== null
|
|
163
|
+
? Math.max(0, p.inputTokens - (p.cacheRead ?? 0))
|
|
164
|
+
: 0;
|
|
165
|
+
record.cacheRead = p.cacheRead ?? 0;
|
|
166
|
+
record.cacheWrite = p.cacheWrite ?? null;
|
|
167
|
+
record.output = p.outputTokens ?? 0;
|
|
168
|
+
}
|
|
169
|
+
this.#writer.enqueue(record);
|
|
170
|
+
}
|
|
171
|
+
#classifyOutcome(err, options) {
|
|
172
|
+
if (options.signal?.aborted === true)
|
|
173
|
+
return "aborted";
|
|
174
|
+
const name = err instanceof Error ? err.name : "";
|
|
175
|
+
if (name === "AbortError" || name === "APIUserAbortError")
|
|
176
|
+
return "aborted";
|
|
177
|
+
return "provider_error";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Wrap the adapter so every stream() call settles a trace record. */
|
|
181
|
+
export function traceGuard(tracer, adapter) {
|
|
182
|
+
return {
|
|
183
|
+
stream: (options) => tracer.wrap(options, adapter.stream(options)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -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,107 @@
|
|
|
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 (Case B of R2 — the
|
|
16
|
+
* export surface stays untouched pending the product-line countersign).
|
|
17
|
+
*/
|
|
18
|
+
/** schemaVersion: 1 for 1.2.0. Algorithm and shape changes bump it
|
|
19
|
+
* (ADR-0051 §6 OUT-side versioning). */
|
|
20
|
+
export declare const TRACE_SCHEMA_VERSION = 1;
|
|
21
|
+
export type Freshness = "fresh" | "cache_read" | "cache_write";
|
|
22
|
+
/** That is the complete set for 1.2.0. */
|
|
23
|
+
export type Outcome = "ok" | "provider_error" | "aborted";
|
|
24
|
+
/** That is the complete set for 1.2.0. (Truncation surfaces as "aborted"
|
|
25
|
+
* when the stream ends before settle; refined in E5 if needed.) */
|
|
26
|
+
export interface TraceSegment {
|
|
27
|
+
role: "system" | "tools" | "turn" | "current_turn";
|
|
28
|
+
/** Thin pointer into the event log: [firstSeq, lastSeq] inclusive of the
|
|
29
|
+
* events that produced this segment. null for system/tools (not events). */
|
|
30
|
+
seqRange: [number, number] | null;
|
|
31
|
+
estTokens: number;
|
|
32
|
+
freshness: Freshness;
|
|
33
|
+
}
|
|
34
|
+
/** That is the complete set for 1.2.0. */
|
|
35
|
+
export interface TraceRecord {
|
|
36
|
+
schemaVersion: 1;
|
|
37
|
+
kind: "request";
|
|
38
|
+
requestId: string;
|
|
39
|
+
runId: string;
|
|
40
|
+
requestIndex: number;
|
|
41
|
+
retryAttempt: number;
|
|
42
|
+
provider: string;
|
|
43
|
+
model: string;
|
|
44
|
+
adapterVersion: string | null;
|
|
45
|
+
systemPromptHash: string;
|
|
46
|
+
toolSchemaHash: string;
|
|
47
|
+
contextHash: string;
|
|
48
|
+
contextManifest: TraceSegment[];
|
|
49
|
+
/** Per-segment content hashes, indexed 1:1 with contextManifest
|
|
50
|
+
* (segment i's canonical serialization — R4b's analysis-side data
|
|
51
|
+
* source: the cache-break derivation needs the LIST, not the
|
|
52
|
+
* aggregated fingerprint). Sizing note: 64 hex chars × segments
|
|
53
|
+
* per request. */
|
|
54
|
+
segmentHashes: string[];
|
|
55
|
+
stablePrefixFingerprint: string;
|
|
56
|
+
/** The usage quartet is PROVIDER-RAW — never a billing surface.
|
|
57
|
+
* Canonical/billing usage lives in E2; these fields are observation
|
|
58
|
+
* only (a provider may count a token a dozen ways; billing must not). */
|
|
59
|
+
freshInput: number;
|
|
60
|
+
cacheRead: number;
|
|
61
|
+
cacheWrite: number | null;
|
|
62
|
+
output: number;
|
|
63
|
+
latencyMs: number;
|
|
64
|
+
ttftMs: number;
|
|
65
|
+
toolCalls: string[];
|
|
66
|
+
outcome: Outcome;
|
|
67
|
+
lineageLink?: {
|
|
68
|
+
parentSessionId: string;
|
|
69
|
+
parentRunId: string;
|
|
70
|
+
parentInvocationSeq: number;
|
|
71
|
+
role: string;
|
|
72
|
+
};
|
|
73
|
+
ts: number;
|
|
74
|
+
}
|
|
75
|
+
/** That is the complete set for 1.2.0. */
|
|
76
|
+
export interface HeaderLine {
|
|
77
|
+
schemaVersion: 1;
|
|
78
|
+
kind: "header";
|
|
79
|
+
sessionId: string;
|
|
80
|
+
kisoVersion: string;
|
|
81
|
+
createdAt: number;
|
|
82
|
+
}
|
|
83
|
+
export interface RunEndLine {
|
|
84
|
+
schemaVersion: 1;
|
|
85
|
+
kind: "run_end";
|
|
86
|
+
runId: string;
|
|
87
|
+
ts: number;
|
|
88
|
+
lastRequestIndex: number;
|
|
89
|
+
}
|
|
90
|
+
export interface CrashLine {
|
|
91
|
+
schemaVersion: 1;
|
|
92
|
+
kind: "crash";
|
|
93
|
+
ts: number;
|
|
94
|
+
note: string;
|
|
95
|
+
}
|
|
96
|
+
export type TraceLine = HeaderLine | TraceRecord | RunEndLine | CrashLine;
|
|
97
|
+
export interface HashSpec {
|
|
98
|
+
readonly algorithm: "sha-256";
|
|
99
|
+
readonly output: "full-hex";
|
|
100
|
+
}
|
|
101
|
+
export declare const HASH_SPEC_BY_VERSION: Readonly<Record<number, HashSpec>>;
|
|
102
|
+
export declare function hashSpecFor(version: number): HashSpec;
|
|
103
|
+
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"];
|
|
104
|
+
export declare const TRACE_SEGMENT_FIELDS: readonly ["role", "seqRange", "estTokens", "freshness"];
|
|
105
|
+
export declare function validateTraceSegment(v: unknown): v is TraceSegment;
|
|
106
|
+
export declare function validateTraceRecord(v: unknown): v is TraceRecord;
|
|
107
|
+
export declare function validateTraceLine(v: unknown): v is TraceLine;
|
|
@@ -0,0 +1,185 @@
|
|
|
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 (Case B of R2 — the
|
|
16
|
+
* export surface stays untouched pending the product-line countersign).
|
|
17
|
+
*/
|
|
18
|
+
/** schemaVersion: 1 for 1.2.0. Algorithm and shape changes bump it
|
|
19
|
+
* (ADR-0051 §6 OUT-side versioning). */
|
|
20
|
+
export const TRACE_SCHEMA_VERSION = 1;
|
|
21
|
+
export const HASH_SPEC_BY_VERSION = {
|
|
22
|
+
1: { algorithm: "sha-256", output: "full-hex" },
|
|
23
|
+
};
|
|
24
|
+
export function hashSpecFor(version) {
|
|
25
|
+
const spec = HASH_SPEC_BY_VERSION[version];
|
|
26
|
+
if (spec === undefined)
|
|
27
|
+
throw new Error(`no hash spec pinned for trace schemaVersion ${version}`);
|
|
28
|
+
return spec;
|
|
29
|
+
}
|
|
30
|
+
// ── The closed-field-set gate (R1a) ───────────────────────────────────────
|
|
31
|
+
// Trace-schema.test.ts asserts Object.keys of a fully populated record is
|
|
32
|
+
// EXACTLY this set (both directions).
|
|
33
|
+
export const TRACE_RECORD_FIELDS = [
|
|
34
|
+
"schemaVersion",
|
|
35
|
+
"kind",
|
|
36
|
+
"requestId",
|
|
37
|
+
"runId",
|
|
38
|
+
"requestIndex",
|
|
39
|
+
"retryAttempt",
|
|
40
|
+
"provider",
|
|
41
|
+
"model",
|
|
42
|
+
"adapterVersion",
|
|
43
|
+
"systemPromptHash",
|
|
44
|
+
"toolSchemaHash",
|
|
45
|
+
"contextHash",
|
|
46
|
+
"contextManifest",
|
|
47
|
+
"segmentHashes",
|
|
48
|
+
"stablePrefixFingerprint",
|
|
49
|
+
"freshInput",
|
|
50
|
+
"cacheRead",
|
|
51
|
+
"cacheWrite",
|
|
52
|
+
"output",
|
|
53
|
+
"latencyMs",
|
|
54
|
+
"ttftMs",
|
|
55
|
+
"toolCalls",
|
|
56
|
+
"outcome",
|
|
57
|
+
"lineageLink",
|
|
58
|
+
"ts",
|
|
59
|
+
];
|
|
60
|
+
export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
|
|
61
|
+
// ── Validators ────────────────────────────────────────────────────────────
|
|
62
|
+
// Strict by design: extra keys are rejected (the closed set), so a
|
|
63
|
+
// misspelled field can never silently enter the ledger.
|
|
64
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
65
|
+
const keysOf = (v) => (isRecord(v) ? Object.keys(v) : []);
|
|
66
|
+
const isNonNegInt = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
67
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
68
|
+
const isHex64 = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
|
|
69
|
+
/** The closed set, both directions: no key outside the spec, and every
|
|
70
|
+
* spec'd key present except the explicit optional ones. */
|
|
71
|
+
const hasClosedKeys = (v, spec, optional = []) => {
|
|
72
|
+
const keys = keysOf(v);
|
|
73
|
+
if (keys.some((k) => !spec.includes(k)))
|
|
74
|
+
return false;
|
|
75
|
+
const optionalSet = new Set(optional);
|
|
76
|
+
return spec.every((k) => optionalSet.has(k) || keys.includes(k));
|
|
77
|
+
};
|
|
78
|
+
const VALID_SEGMENT_ROLES = new Set(["system", "tools", "turn", "current_turn"]);
|
|
79
|
+
const VALID_FRESHNESS = new Set(["fresh", "cache_read", "cache_write"]);
|
|
80
|
+
const VALID_OUTCOMES = new Set(["ok", "provider_error", "aborted"]);
|
|
81
|
+
function isValidSeqRange(v) {
|
|
82
|
+
if (v === null)
|
|
83
|
+
return true;
|
|
84
|
+
if (!Array.isArray(v) || v.length !== 2)
|
|
85
|
+
return false;
|
|
86
|
+
const [a, b] = v;
|
|
87
|
+
return isNonNegInt(a) && isNonNegInt(b) && a <= b;
|
|
88
|
+
}
|
|
89
|
+
export function validateTraceSegment(v) {
|
|
90
|
+
if (!isRecord(v) || !hasClosedKeys(v, TRACE_SEGMENT_FIELDS))
|
|
91
|
+
return false;
|
|
92
|
+
if (typeof v.role !== "string" || !VALID_SEGMENT_ROLES.has(v.role))
|
|
93
|
+
return false;
|
|
94
|
+
if (!isValidSeqRange(v.seqRange))
|
|
95
|
+
return false;
|
|
96
|
+
if (!isNonNegInt(v.estTokens))
|
|
97
|
+
return false;
|
|
98
|
+
if (!VALID_FRESHNESS.has(v.freshness))
|
|
99
|
+
return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
export function validateTraceRecord(v) {
|
|
103
|
+
if (!isRecord(v) || !hasClosedKeys(v, TRACE_RECORD_FIELDS, ["lineageLink"]))
|
|
104
|
+
return false;
|
|
105
|
+
if (v.schemaVersion !== TRACE_SCHEMA_VERSION)
|
|
106
|
+
return false;
|
|
107
|
+
if (v.kind !== "request")
|
|
108
|
+
return false;
|
|
109
|
+
if (typeof v.requestId !== "string" || typeof v.runId !== "string")
|
|
110
|
+
return false;
|
|
111
|
+
if (!isNonNegInt(v.requestIndex) || !isNonNegInt(v.retryAttempt))
|
|
112
|
+
return false;
|
|
113
|
+
if (typeof v.provider !== "string" || typeof v.model !== "string")
|
|
114
|
+
return false;
|
|
115
|
+
if (v.adapterVersion !== null && typeof v.adapterVersion !== "string")
|
|
116
|
+
return false;
|
|
117
|
+
if (!isHex64(v.systemPromptHash) || !isHex64(v.toolSchemaHash) || !isHex64(v.contextHash))
|
|
118
|
+
return false;
|
|
119
|
+
if (!isHex64(v.stablePrefixFingerprint))
|
|
120
|
+
return false;
|
|
121
|
+
if (!Array.isArray(v.contextManifest) || !v.contextManifest.every(validateTraceSegment))
|
|
122
|
+
return false;
|
|
123
|
+
// segmentHashes must mirror the manifest 1:1 — a misaligned list
|
|
124
|
+
// would silently corrupt the break derivation (R4b)
|
|
125
|
+
if (!Array.isArray(v.segmentHashes) ||
|
|
126
|
+
v.segmentHashes.length !== v.contextManifest.length ||
|
|
127
|
+
!v.segmentHashes.every(isHex64))
|
|
128
|
+
return false;
|
|
129
|
+
if (!isNumber(v.freshInput) || !isNumber(v.cacheRead))
|
|
130
|
+
return false;
|
|
131
|
+
if (v.cacheWrite !== null && !isNumber(v.cacheWrite))
|
|
132
|
+
return false;
|
|
133
|
+
if (!isNumber(v.output) || !isNumber(v.latencyMs))
|
|
134
|
+
return false;
|
|
135
|
+
if (!isNumber(v.ttftMs))
|
|
136
|
+
return false; // 0 = unknown, never null (locked set)
|
|
137
|
+
if (!Array.isArray(v.toolCalls) || !v.toolCalls.every((t) => typeof t === "string"))
|
|
138
|
+
return false;
|
|
139
|
+
if (typeof v.outcome !== "string" || !VALID_OUTCOMES.has(v.outcome))
|
|
140
|
+
return false;
|
|
141
|
+
if (v.lineageLink !== undefined) {
|
|
142
|
+
const l = v.lineageLink;
|
|
143
|
+
if (!isRecord(l))
|
|
144
|
+
return false;
|
|
145
|
+
if (typeof l.parentSessionId !== "string" || typeof l.parentRunId !== "string")
|
|
146
|
+
return false;
|
|
147
|
+
if (!isNonNegInt(l.parentInvocationSeq))
|
|
148
|
+
return false;
|
|
149
|
+
if (typeof l.role !== "string")
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (!isNumber(v.ts))
|
|
153
|
+
return false;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
export function validateTraceLine(v) {
|
|
157
|
+
if (!isRecord(v))
|
|
158
|
+
return false;
|
|
159
|
+
if (v.schemaVersion !== TRACE_SCHEMA_VERSION)
|
|
160
|
+
return false;
|
|
161
|
+
switch (v.kind) {
|
|
162
|
+
case "header":
|
|
163
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "sessionId", "kisoVersion", "createdAt"]) &&
|
|
164
|
+
typeof v.sessionId === "string" &&
|
|
165
|
+
typeof v.kisoVersion === "string" &&
|
|
166
|
+
isNumber(v.createdAt));
|
|
167
|
+
case "request":
|
|
168
|
+
return validateTraceRecord(v);
|
|
169
|
+
case "run_end":
|
|
170
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "runId", "ts", "lastRequestIndex"]) &&
|
|
171
|
+
typeof v.runId === "string" &&
|
|
172
|
+
isNumber(v.ts) &&
|
|
173
|
+
// -1 = the run made no adapter calls (an empty run still
|
|
174
|
+
// settles cleanly); anything below that is not a request index
|
|
175
|
+
typeof v.lastRequestIndex === "number" &&
|
|
176
|
+
Number.isInteger(v.lastRequestIndex) &&
|
|
177
|
+
v.lastRequestIndex >= -1);
|
|
178
|
+
case "crash":
|
|
179
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "ts", "note"]) &&
|
|
180
|
+
isNumber(v.ts) &&
|
|
181
|
+
typeof v.note === "string");
|
|
182
|
+
default:
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
|
|
3
|
+
*
|
|
4
|
+
* The ledger is OUT-side (ADR-0051 §6): a versionable observation
|
|
5
|
+
* artifact, never part of the correctness ABI. The soft-fail law
|
|
6
|
+
* (work-order): trace loss costs ONE stderr line, then the session runs
|
|
7
|
+
* on — the writer degrades to a no-op on its first I/O failure and
|
|
8
|
+
* never throws into the caller.
|
|
9
|
+
*
|
|
10
|
+
* Async discipline (work-order §9): request lines are ENQUEUED (a sync
|
|
11
|
+
* push, sub-microsecond) and flushed on setImmediate — the model hot
|
|
12
|
+
* path never blocks on the ledger. The run_end line is flushed
|
|
13
|
+
* SYNCHRONOUSLY from the run's finally block: clean-settle marking is
|
|
14
|
+
* where the ledger's story (every hole reconciles as a crash, never as
|
|
15
|
+
* a writer bug) needs the write to have happened before the run ends.
|
|
16
|
+
*/
|
|
17
|
+
import { type TraceLine } from "./record.js";
|
|
18
|
+
/** The runtime's own version, read from the package.json next to the
|
|
19
|
+
* build; null on failure (soft-fail — the header still ships). */
|
|
20
|
+
export declare function runtimeVersion(): string | null;
|
|
21
|
+
export interface TraceWriterDeps {
|
|
22
|
+
/** The sessions dir (store.root). The ledger lives in <root>/traces/ —
|
|
23
|
+
* NOT flat in root: a flat `<sid>.trace.jsonl` would be enumerated as
|
|
24
|
+
* a phantom session by SessionStore.list() and corrupt the bench
|
|
25
|
+
* sessions glob (proposal §3.2; the R3a gate pins it). */
|
|
26
|
+
root: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
}
|
|
29
|
+
export declare class TraceWriter {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(deps: TraceWriterDeps);
|
|
32
|
+
/** One-time setup: mkdir the ledger dir, mark a previous run that left
|
|
33
|
+
* no run_end (crash), write the header — the header once per ledger
|
|
34
|
+
* file (session-level, §1.2), so a resume appends markers, never a
|
|
35
|
+
* second header. Never throws. */
|
|
36
|
+
init(): void;
|
|
37
|
+
/** Enqueue a line for the next flush — the hot path's only cost.
|
|
38
|
+
* Never throws; after a degradation the line is dropped. */
|
|
39
|
+
enqueue(line: TraceLine): void;
|
|
40
|
+
/** Clean-settle marking: the run_end line lands NOW (synchronous),
|
|
41
|
+
* together with any still-pending request lines. */
|
|
42
|
+
finishRun(runId: string, lastRequestIndex: number): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
|
|
3
|
+
*
|
|
4
|
+
* The ledger is OUT-side (ADR-0051 §6): a versionable observation
|
|
5
|
+
* artifact, never part of the correctness ABI. The soft-fail law
|
|
6
|
+
* (work-order): trace loss costs ONE stderr line, then the session runs
|
|
7
|
+
* on — the writer degrades to a no-op on its first I/O failure and
|
|
8
|
+
* never throws into the caller.
|
|
9
|
+
*
|
|
10
|
+
* Async discipline (work-order §9): request lines are ENQUEUED (a sync
|
|
11
|
+
* push, sub-microsecond) and flushed on setImmediate — the model hot
|
|
12
|
+
* path never blocks on the ledger. The run_end line is flushed
|
|
13
|
+
* SYNCHRONOUSLY from the run's finally block: clean-settle marking is
|
|
14
|
+
* where the ledger's story (every hole reconciles as a crash, never as
|
|
15
|
+
* a writer bug) needs the write to have happened before the run ends.
|
|
16
|
+
*/
|
|
17
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { TRACE_SCHEMA_VERSION } from "./record.js";
|
|
21
|
+
let cachedRuntimeVersion;
|
|
22
|
+
/** The runtime's own version, read from the package.json next to the
|
|
23
|
+
* build; null on failure (soft-fail — the header still ships). */
|
|
24
|
+
export function runtimeVersion() {
|
|
25
|
+
if (cachedRuntimeVersion === undefined) {
|
|
26
|
+
cachedRuntimeVersion = (() => {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
|
|
29
|
+
return pkg.version ?? null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
return cachedRuntimeVersion;
|
|
37
|
+
}
|
|
38
|
+
export class TraceWriter {
|
|
39
|
+
#root;
|
|
40
|
+
#sessionId;
|
|
41
|
+
#path;
|
|
42
|
+
#pending = [];
|
|
43
|
+
#flushScheduled = false;
|
|
44
|
+
#degraded = false;
|
|
45
|
+
constructor(deps) {
|
|
46
|
+
this.#root = deps.root;
|
|
47
|
+
this.#sessionId = deps.sessionId;
|
|
48
|
+
this.#path = join(deps.root, "traces", `${deps.sessionId}.jsonl`);
|
|
49
|
+
}
|
|
50
|
+
/** One-time setup: mkdir the ledger dir, mark a previous run that left
|
|
51
|
+
* no run_end (crash), write the header — the header once per ledger
|
|
52
|
+
* file (session-level, §1.2), so a resume appends markers, never a
|
|
53
|
+
* second header. Never throws. */
|
|
54
|
+
init() {
|
|
55
|
+
if (this.#degraded)
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(join(this.#root, "traces"), { recursive: true });
|
|
59
|
+
const fresh = !existsSync(this.#path);
|
|
60
|
+
if (this.#crashDetect()) {
|
|
61
|
+
this.#writeSync([
|
|
62
|
+
{ schemaVersion: TRACE_SCHEMA_VERSION, kind: "crash", ts: Date.now(), note: "previous run left no run_end" },
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
if (fresh) {
|
|
66
|
+
this.#writeSync([
|
|
67
|
+
{
|
|
68
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
69
|
+
kind: "header",
|
|
70
|
+
sessionId: this.#sessionId,
|
|
71
|
+
kisoVersion: runtimeVersion() ?? "?",
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.#degrade(err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Enqueue a line for the next flush — the hot path's only cost.
|
|
82
|
+
* Never throws; after a degradation the line is dropped. */
|
|
83
|
+
enqueue(line) {
|
|
84
|
+
if (this.#degraded)
|
|
85
|
+
return;
|
|
86
|
+
this.#pending.push(line);
|
|
87
|
+
if (!this.#flushScheduled) {
|
|
88
|
+
this.#flushScheduled = true;
|
|
89
|
+
setImmediate(() => {
|
|
90
|
+
this.#flushScheduled = false;
|
|
91
|
+
this.#flushSync();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Clean-settle marking: the run_end line lands NOW (synchronous),
|
|
96
|
+
* together with any still-pending request lines. */
|
|
97
|
+
finishRun(runId, lastRequestIndex) {
|
|
98
|
+
this.enqueue({
|
|
99
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
100
|
+
kind: "run_end",
|
|
101
|
+
runId,
|
|
102
|
+
ts: Date.now(),
|
|
103
|
+
lastRequestIndex,
|
|
104
|
+
});
|
|
105
|
+
this.#flushSync();
|
|
106
|
+
}
|
|
107
|
+
/** True when the ledger already exists and its LAST line is neither
|
|
108
|
+
* run_end nor crash — an un-terminated run (killed mid-run), to be
|
|
109
|
+
* marked. A crash-marked ledger is not re-marked. */
|
|
110
|
+
#crashDetect() {
|
|
111
|
+
if (!existsSync(this.#path))
|
|
112
|
+
return false;
|
|
113
|
+
let last;
|
|
114
|
+
try {
|
|
115
|
+
for (const line of readFileSync(this.#path, "utf8").split("\n")) {
|
|
116
|
+
if (line.trim() === "")
|
|
117
|
+
continue;
|
|
118
|
+
last = JSON.parse(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false; // unreadable ledger — do not compound the failure
|
|
123
|
+
}
|
|
124
|
+
if (last === undefined)
|
|
125
|
+
return false;
|
|
126
|
+
return last.kind !== "run_end" && last.kind !== "crash";
|
|
127
|
+
}
|
|
128
|
+
#writeSync(lines) {
|
|
129
|
+
if (lines.length === 0)
|
|
130
|
+
return;
|
|
131
|
+
try {
|
|
132
|
+
appendFileSync(this.#path, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
this.#degrade(err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
#flushSync() {
|
|
139
|
+
if (this.#pending.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
const lines = this.#pending;
|
|
142
|
+
this.#pending = [];
|
|
143
|
+
this.#writeSync(lines);
|
|
144
|
+
}
|
|
145
|
+
/** The soft-fail law: one stderr line, then the ledger is dropped and
|
|
146
|
+
* the session runs on. Idempotent. */
|
|
147
|
+
#degrade(err) {
|
|
148
|
+
if (this.#degraded)
|
|
149
|
+
return;
|
|
150
|
+
this.#degraded = true;
|
|
151
|
+
this.#pending = [];
|
|
152
|
+
console.error(`[kiso] trace writer degraded (${err instanceof Error ? err.message : String(err)}); request traces dropped`);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-runtime",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "kiso runtime
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|