@nanobpm/agentic 0.4.0 → 0.6.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/README.md +1 -1
- package/dist/protocol/conformance/control.d.ts +32 -0
- package/dist/protocol/conformance/control.js +113 -0
- package/dist/protocol/conformance/index.d.ts +1 -0
- package/dist/protocol/conformance/index.js +1 -0
- package/dist/protocol/control.d.ts +110 -0
- package/dist/protocol/control.js +191 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.js +1 -0
- package/dist/transcript/index.d.ts +2 -2
- package/dist/transcript/index.js +1 -1
- package/dist/transcript/schema.d.ts +23 -1
- package/dist/transcript/schema.js +34 -1
- package/dist/transcript/store.d.ts +93 -5
- package/dist/transcript/store.js +287 -6
- package/package.json +1 -1
- package/src/protocol/conformance/control.ts +152 -0
- package/src/protocol/conformance/corpus.test.ts +76 -0
- package/src/protocol/conformance/index.ts +6 -0
- package/src/protocol/control.test.ts +131 -0
- package/src/protocol/control.ts +258 -0
- package/src/protocol/index.ts +17 -0
- package/src/transcript/index.ts +8 -0
- package/src/transcript/schema.test.ts +31 -4
- package/src/transcript/schema.ts +36 -1
- package/src/transcript/store.ts +438 -6
- package/src/transcript/turns.test.ts +334 -0
|
@@ -34,6 +34,73 @@ export interface TranscriptChunk {
|
|
|
34
34
|
readonly offset: number;
|
|
35
35
|
readonly chunk: string;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* A structured turn's author role — the additive turn-structured view's parity
|
|
39
|
+
* with Camunda `AgentHistoryRole` (issue #475). One pass through the agent loop
|
|
40
|
+
* (model reasons → selects tools → evaluates results) is recorded as one or more
|
|
41
|
+
* role-tagged turns sharing a `loopIteration`.
|
|
42
|
+
*/
|
|
43
|
+
export type TranscriptTurnRole = "USER" | "ASSISTANT" | "TOOL_RESULT" | "CONFIGURATION" | "UNSPECIFIED";
|
|
44
|
+
/** Parity with Camunda `AgentHistoryContentType`: the type of a content block. */
|
|
45
|
+
export type TranscriptContentType = "TEXT" | "DOCUMENT" | "OBJECT" | "UNSPECIFIED";
|
|
46
|
+
/**
|
|
47
|
+
* A single typed content block in a turn's message, mirroring Camunda's
|
|
48
|
+
* `AgentHistoryMessageContentValue`. Exactly one payload is populated per the
|
|
49
|
+
* `contentType`: `text` for TEXT, `documentReference` for DOCUMENT, `object`
|
|
50
|
+
* (any JSON value) for OBJECT.
|
|
51
|
+
*/
|
|
52
|
+
export interface TranscriptContentBlock {
|
|
53
|
+
readonly contentType: TranscriptContentType;
|
|
54
|
+
/** Text payload; populated when `contentType` is TEXT. */
|
|
55
|
+
readonly text?: string;
|
|
56
|
+
/** Document reference; populated when `contentType` is DOCUMENT. */
|
|
57
|
+
readonly documentReference?: string;
|
|
58
|
+
/** JSON value payload; populated when `contentType` is OBJECT (any JSON type). */
|
|
59
|
+
readonly object?: unknown;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A tool call embedded in a turn, mirroring Camunda's
|
|
63
|
+
* `AgentHistoryEmbeddedToolCallValue`: `toolCallId`, `toolName`, the tool task's
|
|
64
|
+
* `elementId`, and the `arguments` passed to it.
|
|
65
|
+
*/
|
|
66
|
+
export interface TranscriptToolCall {
|
|
67
|
+
readonly toolCallId: string;
|
|
68
|
+
readonly toolName: string;
|
|
69
|
+
readonly elementId?: string;
|
|
70
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Per-turn metrics, mirroring Camunda's `AgentHistoryMetricsValue`: the token
|
|
74
|
+
* counts consumed/produced by the turn's LLM call and its wall-clock duration.
|
|
75
|
+
*/
|
|
76
|
+
export interface TranscriptTurnMetrics {
|
|
77
|
+
readonly inputTokens: number;
|
|
78
|
+
readonly outputTokens: number;
|
|
79
|
+
readonly reasoningTokenCount: number;
|
|
80
|
+
readonly cacheCreationTokenCount: number;
|
|
81
|
+
readonly cacheReadTokenCount: number;
|
|
82
|
+
readonly durationMs: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A structured transcript turn — the additive Camunda `AgentHistoryRecordValue`
|
|
86
|
+
* parity view (issue #475). `sequence` is the stream-local append order and the
|
|
87
|
+
* idempotency key (mirroring a chunk's `offset`); `loopIteration` is the
|
|
88
|
+
* agent-loop turn counter carried as data (several role-split turns can share one
|
|
89
|
+
* iteration). Recording turns never touches the raw chunk stream.
|
|
90
|
+
*/
|
|
91
|
+
export interface TranscriptTurn {
|
|
92
|
+
/** Stream-local append order + idempotency key (like a chunk's `offset`). */
|
|
93
|
+
readonly sequence: number;
|
|
94
|
+
/** The agent-loop turn counter (Camunda `loopIteration`). */
|
|
95
|
+
readonly loopIteration: number;
|
|
96
|
+
readonly role: TranscriptTurnRole;
|
|
97
|
+
readonly content: readonly TranscriptContentBlock[];
|
|
98
|
+
readonly toolCalls: readonly TranscriptToolCall[];
|
|
99
|
+
/** Per-turn metrics; undefined when the worker reported none for this turn. */
|
|
100
|
+
readonly metrics?: TranscriptTurnMetrics;
|
|
101
|
+
/** Epoch-millis timestamp the turn was produced; undefined when unreported. */
|
|
102
|
+
readonly producedAt?: number;
|
|
103
|
+
}
|
|
37
104
|
/** Per-stream transcript metadata. */
|
|
38
105
|
export interface TranscriptStream {
|
|
39
106
|
readonly stream: string;
|
|
@@ -110,9 +177,11 @@ export declare class TranscriptStore {
|
|
|
110
177
|
get ephemeralRetentionMs(): number;
|
|
111
178
|
/**
|
|
112
179
|
* Apply the canonical transcript DDL (idempotent). Callers that let the app
|
|
113
|
-
* DataLayer migration runner apply
|
|
114
|
-
*
|
|
115
|
-
*
|
|
180
|
+
* DataLayer migration runner apply the transcript migrations
|
|
181
|
+
* (`db/migrations/002_agentic_transcript.sql` for the chunk stream and
|
|
182
|
+
* `db/migrations/008_agentic_transcript_turns.sql` for the turn-structured
|
|
183
|
+
* view) do not need this — but it is provided so the store is usable against a
|
|
184
|
+
* bare source too. The DDL is identical to the migrations (drift-guarded).
|
|
116
185
|
*/
|
|
117
186
|
ensureSchema(): void;
|
|
118
187
|
/**
|
|
@@ -163,6 +232,24 @@ export declare class TranscriptStore {
|
|
|
163
232
|
since(stream: string, from: number): TranscriptSlice;
|
|
164
233
|
/** Read a stream's whole durable transcript in offset order. */
|
|
165
234
|
read(stream: string): TranscriptChunk[];
|
|
235
|
+
/**
|
|
236
|
+
* Record structured turns into a stream's additive turn-structured view — the
|
|
237
|
+
* Camunda `AgentHistoryRecordValue` parity layer (issue #475). Each turn is
|
|
238
|
+
* keyed `(stream, sequence)` so re-recording an already-stored sequence (a
|
|
239
|
+
* retry, a re-emit, an overlapping reattach) is a no-op — never a duplicate,
|
|
240
|
+
* exactly the idempotency the chunk stream gets from `(stream, offset)`.
|
|
241
|
+
*
|
|
242
|
+
* This is purely additive: it never reads or writes the raw chunk stream or the
|
|
243
|
+
* stream's offset window, so it cannot regress any existing chunk reader. It
|
|
244
|
+
* auto-opens the stream (default `long-lived`) so the turns hang off a stream
|
|
245
|
+
* row; a lifecycle mismatch throws a {@link TranscriptLifecycleError} before
|
|
246
|
+
* writing anything (lifecycle is first-wins). The batch is atomic: an invalid
|
|
247
|
+
* turn (or any failed write) partway through rolls the whole call back — it
|
|
248
|
+
* records every turn or none. Returns the number of newly-persisted turns.
|
|
249
|
+
*/
|
|
250
|
+
recordTurns(stream: string, turns: Iterable<TranscriptTurn>, lifecycle?: TranscriptLifecycle): number;
|
|
251
|
+
/** Read a stream's whole turn-structured transcript in `sequence` order. */
|
|
252
|
+
readTurns(stream: string): TranscriptTurn[];
|
|
166
253
|
/**
|
|
167
254
|
* Apply a rolling retention window to a long-lived stream: drop every chunk with
|
|
168
255
|
* `offset < before`. A subsequent {@link since} from an offset older than
|
|
@@ -174,8 +261,9 @@ export declare class TranscriptStore {
|
|
|
174
261
|
/**
|
|
175
262
|
* Retention sweep for completed ephemeral transcripts: drop every stream whose
|
|
176
263
|
* `status = 'completed'` and whose `completed_at` is older than the retention
|
|
177
|
-
* window, along with its chunks. Long-lived streams are
|
|
178
|
-
* are bounded by {@link truncateBefore} instead). Returns
|
|
264
|
+
* window, along with its chunks and structured turns. Long-lived streams are
|
|
265
|
+
* never time-swept (they are bounded by {@link truncateBefore} instead). Returns
|
|
266
|
+
* the removed stream ids.
|
|
179
267
|
*
|
|
180
268
|
* The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
|
|
181
269
|
* sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
|
package/dist/transcript/store.js
CHANGED
|
@@ -22,9 +22,17 @@
|
|
|
22
22
|
* ({@link SqliteDb}), so it works against any app DataLayer source without pulling
|
|
23
23
|
* in the whole runtime.
|
|
24
24
|
*/
|
|
25
|
-
import { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE } from "./schema.js";
|
|
25
|
+
import { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_SCHEMA_SQL, TRANSCRIPT_TURN_TABLE, } from "./schema.js";
|
|
26
26
|
/** The default clock: `Date.now()`. */
|
|
27
27
|
export const systemClock = { now: () => Date.now() };
|
|
28
|
+
const TURN_ROLES = [
|
|
29
|
+
"USER",
|
|
30
|
+
"ASSISTANT",
|
|
31
|
+
"TOOL_RESULT",
|
|
32
|
+
"CONFIGURATION",
|
|
33
|
+
"UNSPECIFIED",
|
|
34
|
+
];
|
|
35
|
+
const CONTENT_TYPES = ["TEXT", "DOCUMENT", "OBJECT", "UNSPECIFIED"];
|
|
28
36
|
const DEFAULT_EPHEMERAL_RETENTION_MS = 86_400_000;
|
|
29
37
|
function isNonNegInt(value) {
|
|
30
38
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
@@ -63,6 +71,208 @@ function toStream(row) {
|
|
|
63
71
|
out.firstOffset = row.first_offset;
|
|
64
72
|
return out;
|
|
65
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A plain JSON-shaped object: an ordinary or null-prototype object, never an array or a
|
|
76
|
+
* class instance (e.g. `Date`, `Map`). Rejecting exotic instances at the boundary stops a
|
|
77
|
+
* value that would serialise via `toJSON()` into a non-object (making the stored turn
|
|
78
|
+
* unreadable) from ever being persisted.
|
|
79
|
+
*/
|
|
80
|
+
function isPlainObject(value) {
|
|
81
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
82
|
+
return false;
|
|
83
|
+
const proto = Object.getPrototypeOf(value);
|
|
84
|
+
return proto === Object.prototype || proto === null;
|
|
85
|
+
}
|
|
86
|
+
function toTurnRole(value) {
|
|
87
|
+
const match = TURN_ROLES.find((role) => role === value);
|
|
88
|
+
if (match !== undefined)
|
|
89
|
+
return match;
|
|
90
|
+
throw new TranscriptCorruptionError(`invalid transcript turn role: ${JSON.stringify(value)}`);
|
|
91
|
+
}
|
|
92
|
+
function toContentType(value) {
|
|
93
|
+
const match = CONTENT_TYPES.find((type) => type === value);
|
|
94
|
+
if (match !== undefined)
|
|
95
|
+
return match;
|
|
96
|
+
throw new TranscriptCorruptionError(`invalid transcript content type: ${JSON.stringify(value)}`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The payload key each content type carries: TEXT→`text`, DOCUMENT→`documentReference`,
|
|
100
|
+
* OBJECT→`object`; UNSPECIFIED carries none. Drives the per-`contentType` payload
|
|
101
|
+
* invariant enforced by {@link toContentBlock}.
|
|
102
|
+
*/
|
|
103
|
+
const CONTENT_PAYLOAD_KEYS = ["text", "documentReference", "object"];
|
|
104
|
+
const CONTENT_TYPE_PAYLOAD = {
|
|
105
|
+
TEXT: "text",
|
|
106
|
+
DOCUMENT: "documentReference",
|
|
107
|
+
OBJECT: "object",
|
|
108
|
+
UNSPECIFIED: null,
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Validate a value into a canonical content block, enforcing the per-`contentType`
|
|
112
|
+
* payload invariant: a block carries exactly the one payload its type mandates
|
|
113
|
+
* (TEXT→text, DOCUMENT→documentReference, OBJECT→object) — never a missing, mismatched
|
|
114
|
+
* or multiple payload — and UNSPECIFIED carries none. This is the single validator both
|
|
115
|
+
* the write path (record) and the read path (deserialise) run through, so the two can
|
|
116
|
+
* never drift and a malformed block can neither persist nor round-trip. It emits only
|
|
117
|
+
* that one payload key, so serialise/deserialise round-trips exactly.
|
|
118
|
+
*/
|
|
119
|
+
function toContentBlock(value) {
|
|
120
|
+
if (!isPlainObject(value)) {
|
|
121
|
+
throw new TranscriptCorruptionError(`transcript content block must be an object, got ${JSON.stringify(value)}`);
|
|
122
|
+
}
|
|
123
|
+
const contentType = toContentType(value.contentType);
|
|
124
|
+
const present = CONTENT_PAYLOAD_KEYS.filter((key) => key in value && value[key] !== undefined);
|
|
125
|
+
const expected = CONTENT_TYPE_PAYLOAD[contentType];
|
|
126
|
+
if (expected === null) {
|
|
127
|
+
if (present.length > 0) {
|
|
128
|
+
throw new TranscriptCorruptionError(`${contentType} content block must carry no payload, got ${JSON.stringify(present)}`);
|
|
129
|
+
}
|
|
130
|
+
return { contentType };
|
|
131
|
+
}
|
|
132
|
+
if (present.length !== 1 || present[0] !== expected) {
|
|
133
|
+
throw new TranscriptCorruptionError(`${contentType} content block must carry exactly its ${expected} payload, got ${JSON.stringify(present)}`);
|
|
134
|
+
}
|
|
135
|
+
if (expected === "text") {
|
|
136
|
+
const text = value.text;
|
|
137
|
+
if (typeof text !== "string") {
|
|
138
|
+
throw new TranscriptCorruptionError(`content block text must be a string, got ${JSON.stringify(text)}`);
|
|
139
|
+
}
|
|
140
|
+
return { contentType, text };
|
|
141
|
+
}
|
|
142
|
+
if (expected === "documentReference") {
|
|
143
|
+
const documentReference = value.documentReference;
|
|
144
|
+
if (typeof documentReference !== "string") {
|
|
145
|
+
throw new TranscriptCorruptionError(`content block documentReference must be a string, got ${JSON.stringify(documentReference)}`);
|
|
146
|
+
}
|
|
147
|
+
return { contentType, documentReference };
|
|
148
|
+
}
|
|
149
|
+
return { contentType, object: value.object };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Validate a value into a canonical tool call — the single validator both the write and
|
|
153
|
+
* read paths run through. Mirrors Camunda's `AgentHistoryEmbeddedToolCallValue`.
|
|
154
|
+
*/
|
|
155
|
+
function toToolCall(value) {
|
|
156
|
+
if (!isPlainObject(value)) {
|
|
157
|
+
throw new TranscriptCorruptionError(`transcript tool call must be an object, got ${JSON.stringify(value)}`);
|
|
158
|
+
}
|
|
159
|
+
const { toolCallId, toolName, elementId } = value;
|
|
160
|
+
if (typeof toolCallId !== "string") {
|
|
161
|
+
throw new TranscriptCorruptionError(`tool call toolCallId must be a string, got ${JSON.stringify(toolCallId)}`);
|
|
162
|
+
}
|
|
163
|
+
if (typeof toolName !== "string") {
|
|
164
|
+
throw new TranscriptCorruptionError(`tool call toolName must be a string, got ${JSON.stringify(toolName)}`);
|
|
165
|
+
}
|
|
166
|
+
const args = value.arguments;
|
|
167
|
+
if (!isPlainObject(args)) {
|
|
168
|
+
throw new TranscriptCorruptionError(`tool call arguments must be an object, got ${JSON.stringify(args)}`);
|
|
169
|
+
}
|
|
170
|
+
const out = {
|
|
171
|
+
toolCallId,
|
|
172
|
+
toolName,
|
|
173
|
+
arguments: args,
|
|
174
|
+
};
|
|
175
|
+
if (elementId !== undefined) {
|
|
176
|
+
if (typeof elementId !== "string") {
|
|
177
|
+
throw new TranscriptCorruptionError(`tool call elementId must be a string, got ${JSON.stringify(elementId)}`);
|
|
178
|
+
}
|
|
179
|
+
out.elementId = elementId;
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
function readMetric(source, key) {
|
|
184
|
+
const value = source[key];
|
|
185
|
+
if (!isNonNegInt(value)) {
|
|
186
|
+
throw new TranscriptCorruptionError(`transcript metric "${key}" must be a non-negative safe integer, got ${JSON.stringify(value)}`);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Validate a value into canonical per-turn metrics — the single validator both the write
|
|
192
|
+
* and read paths run through. Every field must be a non-negative safe integer: Camunda's
|
|
193
|
+
* `AgentHistoryMetricsValue` token counts and `durationMs` are integer-valued and cannot be
|
|
194
|
+
* negative, so an out-of-domain value is treated as corruption rather than round-tripped.
|
|
195
|
+
*/
|
|
196
|
+
function toMetrics(value) {
|
|
197
|
+
if (!isPlainObject(value)) {
|
|
198
|
+
throw new TranscriptCorruptionError(`transcript turn metrics must be an object, got ${JSON.stringify(value)}`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
inputTokens: readMetric(value, "inputTokens"),
|
|
202
|
+
outputTokens: readMetric(value, "outputTokens"),
|
|
203
|
+
reasoningTokenCount: readMetric(value, "reasoningTokenCount"),
|
|
204
|
+
cacheCreationTokenCount: readMetric(value, "cacheCreationTokenCount"),
|
|
205
|
+
cacheReadTokenCount: readMetric(value, "cacheReadTokenCount"),
|
|
206
|
+
durationMs: readMetric(value, "durationMs"),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Parse a JSON column, re-raising a malformed-JSON `SyntaxError` as a
|
|
211
|
+
* {@link TranscriptCorruptionError} so every read-back failure surfaces through the store's
|
|
212
|
+
* single corruption signal rather than a raw parse error.
|
|
213
|
+
*/
|
|
214
|
+
function parseJsonColumn(json, label) {
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(json);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
throw new TranscriptCorruptionError(`transcript turn ${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Parse a JSON array column, failing with a corruption error on invalid JSON or non-arrays. */
|
|
223
|
+
function parseJsonArray(json, label) {
|
|
224
|
+
const parsed = parseJsonColumn(json, label);
|
|
225
|
+
if (!Array.isArray(parsed)) {
|
|
226
|
+
throw new TranscriptCorruptionError(`transcript turn ${label} must be a JSON array, got ${JSON.stringify(parsed)}`);
|
|
227
|
+
}
|
|
228
|
+
return parsed;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Serialise a validated turn payload for storage, re-raising a non-serialisable value
|
|
232
|
+
* (e.g. a `bigint`, which makes `JSON.stringify` throw a `TypeError`) as a
|
|
233
|
+
* {@link TranscriptCorruptionError} so the untyped write boundary only ever surfaces the
|
|
234
|
+
* store's own error taxonomy.
|
|
235
|
+
*/
|
|
236
|
+
function stringifyJson(value, label) {
|
|
237
|
+
try {
|
|
238
|
+
return JSON.stringify(value);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
throw new TranscriptCorruptionError(`transcript turn ${label} is not JSON-serialisable: ${err instanceof Error ? err.message : String(err)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Reconstruct a structured turn from its DB row, validating every field so a corrupted
|
|
246
|
+
* or hand-edited row fails fast with a {@link TranscriptCorruptionError} rather than
|
|
247
|
+
* silently propagating an out-of-domain value. `turn_sequence`, `loop_iteration` and
|
|
248
|
+
* `produced_at` are held to the same non-negative-safe-integer domain the write path
|
|
249
|
+
* enforces.
|
|
250
|
+
*/
|
|
251
|
+
function toTurn(row) {
|
|
252
|
+
if (!isRecordableOffset(row.turn_sequence)) {
|
|
253
|
+
throw new TranscriptCorruptionError(`transcript turn sequence must be a non-negative safe integer, got ${JSON.stringify(row.turn_sequence)}`);
|
|
254
|
+
}
|
|
255
|
+
if (!isNonNegInt(row.loop_iteration)) {
|
|
256
|
+
throw new TranscriptCorruptionError(`transcript turn loopIteration must be a non-negative safe integer, got ${JSON.stringify(row.loop_iteration)}`);
|
|
257
|
+
}
|
|
258
|
+
const out = {
|
|
259
|
+
sequence: row.turn_sequence,
|
|
260
|
+
loopIteration: row.loop_iteration,
|
|
261
|
+
role: toTurnRole(row.role),
|
|
262
|
+
content: parseJsonArray(row.content, "content").map(toContentBlock),
|
|
263
|
+
toolCalls: parseJsonArray(row.tool_calls, "toolCalls").map(toToolCall),
|
|
264
|
+
};
|
|
265
|
+
if (row.metrics !== null) {
|
|
266
|
+
out.metrics = toMetrics(parseJsonColumn(row.metrics, "metrics"));
|
|
267
|
+
}
|
|
268
|
+
if (row.produced_at !== null) {
|
|
269
|
+
if (!isNonNegInt(row.produced_at)) {
|
|
270
|
+
throw new TranscriptCorruptionError(`transcript turn producedAt must be a non-negative safe integer, got ${JSON.stringify(row.produced_at)}`);
|
|
271
|
+
}
|
|
272
|
+
out.producedAt = row.produced_at;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
66
276
|
/**
|
|
67
277
|
* Raised when a transcript row read back from storage holds a value outside its
|
|
68
278
|
* domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a
|
|
@@ -106,12 +316,15 @@ export class TranscriptStore {
|
|
|
106
316
|
}
|
|
107
317
|
/**
|
|
108
318
|
* Apply the canonical transcript DDL (idempotent). Callers that let the app
|
|
109
|
-
* DataLayer migration runner apply
|
|
110
|
-
*
|
|
111
|
-
*
|
|
319
|
+
* DataLayer migration runner apply the transcript migrations
|
|
320
|
+
* (`db/migrations/002_agentic_transcript.sql` for the chunk stream and
|
|
321
|
+
* `db/migrations/008_agentic_transcript_turns.sql` for the turn-structured
|
|
322
|
+
* view) do not need this — but it is provided so the store is usable against a
|
|
323
|
+
* bare source too. The DDL is identical to the migrations (drift-guarded).
|
|
112
324
|
*/
|
|
113
325
|
ensureSchema() {
|
|
114
326
|
this.#db.exec(TRANSCRIPT_SCHEMA_SQL);
|
|
327
|
+
this.#db.exec(TRANSCRIPT_TURN_SCHEMA_SQL);
|
|
115
328
|
}
|
|
116
329
|
/**
|
|
117
330
|
* Open (or fetch) a stream's transcript with the given lifecycle. Idempotent:
|
|
@@ -228,6 +441,72 @@ export class TranscriptStore {
|
|
|
228
441
|
.all(`SELECT chunk_offset, chunk FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ? ORDER BY chunk_offset`, [stream])
|
|
229
442
|
.map((r) => ({ offset: r.chunk_offset, chunk: r.chunk }));
|
|
230
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Record structured turns into a stream's additive turn-structured view — the
|
|
446
|
+
* Camunda `AgentHistoryRecordValue` parity layer (issue #475). Each turn is
|
|
447
|
+
* keyed `(stream, sequence)` so re-recording an already-stored sequence (a
|
|
448
|
+
* retry, a re-emit, an overlapping reattach) is a no-op — never a duplicate,
|
|
449
|
+
* exactly the idempotency the chunk stream gets from `(stream, offset)`.
|
|
450
|
+
*
|
|
451
|
+
* This is purely additive: it never reads or writes the raw chunk stream or the
|
|
452
|
+
* stream's offset window, so it cannot regress any existing chunk reader. It
|
|
453
|
+
* auto-opens the stream (default `long-lived`) so the turns hang off a stream
|
|
454
|
+
* row; a lifecycle mismatch throws a {@link TranscriptLifecycleError} before
|
|
455
|
+
* writing anything (lifecycle is first-wins). The batch is atomic: an invalid
|
|
456
|
+
* turn (or any failed write) partway through rolls the whole call back — it
|
|
457
|
+
* records every turn or none. Returns the number of newly-persisted turns.
|
|
458
|
+
*/
|
|
459
|
+
recordTurns(stream, turns, lifecycle = "long-lived") {
|
|
460
|
+
const meta = this.open(stream, lifecycle);
|
|
461
|
+
if (meta.lifecycle !== lifecycle) {
|
|
462
|
+
throw new TranscriptLifecycleError(stream, `refusing to record turns into "${stream}" with lifecycle=${lifecycle}; the stream is ${meta.lifecycle} (lifecycle is first-wins)`);
|
|
463
|
+
}
|
|
464
|
+
const at = new Date(this.#clock.now()).toISOString();
|
|
465
|
+
return this.#atomic(() => {
|
|
466
|
+
let written = 0;
|
|
467
|
+
for (const turn of turns) {
|
|
468
|
+
if (!isPlainObject(turn)) {
|
|
469
|
+
throw new TranscriptCorruptionError(`transcript turn must be an object, got ${JSON.stringify(turn)}`);
|
|
470
|
+
}
|
|
471
|
+
if (!isRecordableOffset(turn.sequence)) {
|
|
472
|
+
throw new RangeError(`transcript turn sequence must be a non-negative safe integer below Number.MAX_SAFE_INTEGER, got ${turn.sequence}`);
|
|
473
|
+
}
|
|
474
|
+
if (!isNonNegInt(turn.loopIteration)) {
|
|
475
|
+
throw new RangeError(`transcript turn loopIteration must be a non-negative safe integer, got ${turn.loopIteration}`);
|
|
476
|
+
}
|
|
477
|
+
const role = toTurnRole(turn.role);
|
|
478
|
+
if (!Array.isArray(turn.content)) {
|
|
479
|
+
throw new TranscriptCorruptionError(`transcript turn content must be an array, got ${JSON.stringify(turn.content)}`);
|
|
480
|
+
}
|
|
481
|
+
if (!Array.isArray(turn.toolCalls)) {
|
|
482
|
+
throw new TranscriptCorruptionError(`transcript turn toolCalls must be an array, got ${JSON.stringify(turn.toolCalls)}`);
|
|
483
|
+
}
|
|
484
|
+
const content = stringifyJson(turn.content.map(toContentBlock), "content");
|
|
485
|
+
const toolCalls = stringifyJson(turn.toolCalls.map(toToolCall), "toolCalls");
|
|
486
|
+
const metrics = turn.metrics === undefined ? null : stringifyJson(toMetrics(turn.metrics), "metrics");
|
|
487
|
+
let producedAt = null;
|
|
488
|
+
if (turn.producedAt !== undefined) {
|
|
489
|
+
if (!isNonNegInt(turn.producedAt)) {
|
|
490
|
+
throw new RangeError(`transcript turn producedAt must be a non-negative safe integer (epoch millis), got ${turn.producedAt}`);
|
|
491
|
+
}
|
|
492
|
+
producedAt = turn.producedAt;
|
|
493
|
+
}
|
|
494
|
+
const { changes } = this.#db.run(`INSERT INTO ${TRANSCRIPT_TURN_TABLE}
|
|
495
|
+
(stream, turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at, recorded_at)
|
|
496
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
497
|
+
ON CONFLICT(stream, turn_sequence) DO NOTHING`, [stream, turn.sequence, turn.loopIteration, role, content, toolCalls, metrics, producedAt, at]);
|
|
498
|
+
written += changes;
|
|
499
|
+
}
|
|
500
|
+
return written;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/** Read a stream's whole turn-structured transcript in `sequence` order. */
|
|
504
|
+
readTurns(stream) {
|
|
505
|
+
return this.#db
|
|
506
|
+
.all(`SELECT turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at
|
|
507
|
+
FROM ${TRANSCRIPT_TURN_TABLE} WHERE stream = ? ORDER BY turn_sequence`, [stream])
|
|
508
|
+
.map(toTurn);
|
|
509
|
+
}
|
|
231
510
|
/**
|
|
232
511
|
* Apply a rolling retention window to a long-lived stream: drop every chunk with
|
|
233
512
|
* `offset < before`. A subsequent {@link since} from an offset older than
|
|
@@ -253,8 +532,9 @@ export class TranscriptStore {
|
|
|
253
532
|
/**
|
|
254
533
|
* Retention sweep for completed ephemeral transcripts: drop every stream whose
|
|
255
534
|
* `status = 'completed'` and whose `completed_at` is older than the retention
|
|
256
|
-
* window, along with its chunks. Long-lived streams are
|
|
257
|
-
* are bounded by {@link truncateBefore} instead). Returns
|
|
535
|
+
* window, along with its chunks and structured turns. Long-lived streams are
|
|
536
|
+
* never time-swept (they are bounded by {@link truncateBefore} instead). Returns
|
|
537
|
+
* the removed stream ids.
|
|
258
538
|
*
|
|
259
539
|
* The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
|
|
260
540
|
* sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
|
|
@@ -271,6 +551,7 @@ export class TranscriptStore {
|
|
|
271
551
|
.map((r) => r.stream);
|
|
272
552
|
for (const stream of removed) {
|
|
273
553
|
this.#db.run(`DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`, [stream]);
|
|
554
|
+
this.#db.run(`DELETE FROM ${TRANSCRIPT_TURN_TABLE} WHERE stream = ?`, [stream]);
|
|
274
555
|
this.#db.run(`DELETE FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`, [stream]);
|
|
275
556
|
}
|
|
276
557
|
return removed;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/agentic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The Nano agentic protocol (ADR 0056): one app-tier channel carrying agent presence/registry, demand×supply, a shared blackboard and live terminal relay — with the wire contract, channel/hub, family modules and the operator cockpit, as subpath exports.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTROL_FRAME_MARKER,
|
|
3
|
+
CONTROL_FRAME_VERSION,
|
|
4
|
+
type InboundControlErrorCode,
|
|
5
|
+
type InboundControlFrame,
|
|
6
|
+
} from "../control.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared corpus for the INBOUND control vocabulary (steer-in). These vectors are
|
|
10
|
+
* exported so cross-repo consumers (the c8ctl harness) decode the SAME frames.
|
|
11
|
+
*
|
|
12
|
+
* `chunk` is the raw inbound steer string a peer sends; `frame` is the typed
|
|
13
|
+
* {@link InboundControlFrame} it must decode to; `structured` records whether it
|
|
14
|
+
* is a recognised control envelope (`true`) or the legacy bare-string-as-prompt
|
|
15
|
+
* fall-back (`false`). A `roundTrips: false` vector decodes to `frame` but does
|
|
16
|
+
* NOT re-encode to the same `chunk` (a legacy bare string, or an envelope with
|
|
17
|
+
* extra tolerated fields), so the round-trip test skips re-encoding it.
|
|
18
|
+
*/
|
|
19
|
+
export interface ValidControlFrame {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly chunk: string;
|
|
22
|
+
readonly frame: InboundControlFrame;
|
|
23
|
+
readonly structured: boolean;
|
|
24
|
+
readonly roundTrips: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MARKER = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION };
|
|
28
|
+
|
|
29
|
+
export const VALID_CONTROL_FRAMES: readonly ValidControlFrame[] = [
|
|
30
|
+
{
|
|
31
|
+
name: "prompt-structured",
|
|
32
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "run the tests" }),
|
|
33
|
+
frame: { kind: "prompt", text: "run the tests" },
|
|
34
|
+
structured: true,
|
|
35
|
+
roundTrips: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "prompt-structured-empty-text",
|
|
39
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "" }),
|
|
40
|
+
frame: { kind: "prompt", text: "" },
|
|
41
|
+
structured: true,
|
|
42
|
+
roundTrips: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "cancel-structured",
|
|
46
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel" }),
|
|
47
|
+
frame: { kind: "cancel" },
|
|
48
|
+
structured: true,
|
|
49
|
+
roundTrips: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "cancel-structured-with-reason",
|
|
53
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: "operator interrupt" }),
|
|
54
|
+
frame: { kind: "cancel", reason: "operator interrupt" },
|
|
55
|
+
structured: true,
|
|
56
|
+
roundTrips: true,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "permission-granted",
|
|
60
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-7", outcome: "granted" }),
|
|
61
|
+
frame: { kind: "permission", requestId: "req-7", outcome: "granted" },
|
|
62
|
+
structured: true,
|
|
63
|
+
roundTrips: true,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "permission-denied",
|
|
67
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-8", outcome: "denied" }),
|
|
68
|
+
frame: { kind: "permission", requestId: "req-8", outcome: "denied" },
|
|
69
|
+
structured: true,
|
|
70
|
+
roundTrips: true,
|
|
71
|
+
},
|
|
72
|
+
// Legacy raw-byte steer: a bare keystroke/line is NOT a control envelope and
|
|
73
|
+
// must decode as a prompt carrying the chunk verbatim — the no-regression path.
|
|
74
|
+
{
|
|
75
|
+
name: "legacy-keystroke-line",
|
|
76
|
+
chunk: "ls -la\n",
|
|
77
|
+
frame: { kind: "prompt", text: "ls -la\n" },
|
|
78
|
+
structured: false,
|
|
79
|
+
roundTrips: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "legacy-control-c",
|
|
83
|
+
chunk: "\u0003",
|
|
84
|
+
frame: { kind: "prompt", text: "\u0003" },
|
|
85
|
+
structured: false,
|
|
86
|
+
roundTrips: false,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "legacy-json-number-is-not-a-frame",
|
|
90
|
+
chunk: "42",
|
|
91
|
+
frame: { kind: "prompt", text: "42" },
|
|
92
|
+
structured: false,
|
|
93
|
+
roundTrips: false,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "legacy-untagged-json-object",
|
|
97
|
+
chunk: JSON.stringify({ kind: "prompt", text: "not tagged" }),
|
|
98
|
+
frame: { kind: "prompt", text: JSON.stringify({ kind: "prompt", text: "not tagged" }) },
|
|
99
|
+
structured: false,
|
|
100
|
+
roundTrips: false,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Adversarial inbound control vectors: a chunk TAGGED as a control envelope but
|
|
106
|
+
* malformed. Each MUST be rejected with the exact {@link InboundControlErrorCode}.
|
|
107
|
+
* A missing tag is NOT here — an untagged chunk is a valid legacy prompt (see
|
|
108
|
+
* {@link VALID_CONTROL_FRAMES}), never an error.
|
|
109
|
+
*/
|
|
110
|
+
export interface MalformedControlFrame {
|
|
111
|
+
readonly name: string;
|
|
112
|
+
readonly chunk: string;
|
|
113
|
+
readonly expected: InboundControlErrorCode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const MALFORMED_CONTROL_FRAMES: readonly MalformedControlFrame[] = [
|
|
117
|
+
{
|
|
118
|
+
name: "tagged-missing-kind",
|
|
119
|
+
chunk: JSON.stringify({ ...MARKER, text: "no kind" }),
|
|
120
|
+
expected: "bad-kind",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "tagged-unknown-kind",
|
|
124
|
+
chunk: JSON.stringify({ ...MARKER, kind: "explode" }),
|
|
125
|
+
expected: "bad-kind",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "prompt-missing-text",
|
|
129
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt" }),
|
|
130
|
+
expected: "bad-prompt-text",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "prompt-non-string-text",
|
|
134
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: 123 }),
|
|
135
|
+
expected: "bad-prompt-text",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "cancel-non-string-reason",
|
|
139
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: 5 }),
|
|
140
|
+
expected: "bad-cancel-reason",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "permission-missing-request-id",
|
|
144
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", outcome: "granted" }),
|
|
145
|
+
expected: "bad-permission-request-id",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "permission-bad-outcome",
|
|
149
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-9", outcome: "maybe" }),
|
|
150
|
+
expected: "bad-permission-outcome",
|
|
151
|
+
},
|
|
152
|
+
];
|