@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
|
@@ -6,6 +6,13 @@ import { GOLDEN_FRAMES } from "./frames.ts";
|
|
|
6
6
|
import type { FrameDirection } from "./frames.ts";
|
|
7
7
|
import { MALFORMED_FRAMES } from "./malformed.ts";
|
|
8
8
|
import type { FrameDecodeErrorCode } from "../frame.ts";
|
|
9
|
+
import {
|
|
10
|
+
parseInboundRelayChunk,
|
|
11
|
+
encodeInboundControlFrame,
|
|
12
|
+
type InboundControlErrorCode,
|
|
13
|
+
type InboundControlKind,
|
|
14
|
+
} from "../control.ts";
|
|
15
|
+
import { VALID_CONTROL_FRAMES, MALFORMED_CONTROL_FRAMES } from "./control.ts";
|
|
9
16
|
|
|
10
17
|
// The corpus is only a defence against drift if it is exhaustive. These tests
|
|
11
18
|
// fail if a new family, lane, or decode-error code is added without a covering
|
|
@@ -64,3 +71,72 @@ test("golden frame names are unique", () => {
|
|
|
64
71
|
const names = GOLDEN_FRAMES.map((g) => g.name);
|
|
65
72
|
assert.equal(new Set(names).size, names.length);
|
|
66
73
|
});
|
|
74
|
+
|
|
75
|
+
// --- Inbound control vocabulary (steer-in) -------------------------------
|
|
76
|
+
|
|
77
|
+
test("valid control corpus decodes to its declared frame", () => {
|
|
78
|
+
for (const v of VALID_CONTROL_FRAMES) {
|
|
79
|
+
const result = parseInboundRelayChunk(v.chunk);
|
|
80
|
+
assert.ok(result.ok, `${v.name}: ${result.ok ? "" : JSON.stringify(result.errors)}`);
|
|
81
|
+
assert.deepEqual(result.frame, v.frame, v.name);
|
|
82
|
+
assert.equal(result.structured, v.structured, `${v.name}: structured flag`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("structured control frames round-trip through the encoder", () => {
|
|
87
|
+
for (const v of VALID_CONTROL_FRAMES) {
|
|
88
|
+
if (!v.roundTrips) continue;
|
|
89
|
+
const encoded = encodeInboundControlFrame(v.frame);
|
|
90
|
+
const back = parseInboundRelayChunk(encoded);
|
|
91
|
+
assert.ok(back.ok, `${v.name}: re-decode failed`);
|
|
92
|
+
assert.deepEqual(back.frame, v.frame, `${v.name}: round-trip frame`);
|
|
93
|
+
assert.equal(back.structured, true, `${v.name}: round-trip is structured`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("valid control corpus covers every control kind", () => {
|
|
98
|
+
const ALL_KINDS: Record<InboundControlKind, true> = {
|
|
99
|
+
prompt: true,
|
|
100
|
+
cancel: true,
|
|
101
|
+
permission: true,
|
|
102
|
+
};
|
|
103
|
+
const covered = new Set<string>(VALID_CONTROL_FRAMES.map((v) => v.frame.kind));
|
|
104
|
+
for (const kind of Object.keys(ALL_KINDS)) {
|
|
105
|
+
assert.ok(covered.has(kind), `no valid control vector covers kind: ${kind}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("legacy bare-string steer still decodes as a prompt (no regression)", () => {
|
|
110
|
+
const legacy = VALID_CONTROL_FRAMES.filter((v) => !v.structured);
|
|
111
|
+
assert.ok(legacy.length > 0, "corpus must retain legacy bare-string vectors");
|
|
112
|
+
for (const v of legacy) {
|
|
113
|
+
const result = parseInboundRelayChunk(v.chunk);
|
|
114
|
+
assert.ok(result.ok && result.frame.kind === "prompt", v.name);
|
|
115
|
+
assert.equal(result.ok && result.frame.kind === "prompt" && result.frame.text, v.chunk, v.name);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("malformed control corpus is rejected with the expected code", () => {
|
|
120
|
+
for (const m of MALFORMED_CONTROL_FRAMES) {
|
|
121
|
+
const result = parseInboundRelayChunk(m.chunk);
|
|
122
|
+
assert.ok(!result.ok, `${m.name}: expected rejection`);
|
|
123
|
+
assert.ok(
|
|
124
|
+
!result.ok && result.errors.some((e) => e.code === m.expected),
|
|
125
|
+
`${m.name}: expected code ${m.expected}, got ${result.ok ? "ok" : JSON.stringify(result.errors)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("malformed control corpus covers every control error code", () => {
|
|
131
|
+
const ALL_CODES: Record<InboundControlErrorCode, true> = {
|
|
132
|
+
"bad-kind": true,
|
|
133
|
+
"bad-prompt-text": true,
|
|
134
|
+
"bad-cancel-reason": true,
|
|
135
|
+
"bad-permission-request-id": true,
|
|
136
|
+
"bad-permission-outcome": true,
|
|
137
|
+
};
|
|
138
|
+
const covered = new Set<string>(MALFORMED_CONTROL_FRAMES.map((m) => m.expected));
|
|
139
|
+
for (const code of Object.keys(ALL_CODES)) {
|
|
140
|
+
assert.ok(covered.has(code), `no malformed control vector covers code: ${code}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
CONTROL_FRAME_MARKER,
|
|
5
|
+
CONTROL_FRAME_VERSION,
|
|
6
|
+
isInboundControlEnvelope,
|
|
7
|
+
parseInboundRelayChunk,
|
|
8
|
+
encodeInboundControlFrame,
|
|
9
|
+
type InboundControlFrame,
|
|
10
|
+
} from "./control.ts";
|
|
11
|
+
|
|
12
|
+
test("marker + version are the canonical tag", () => {
|
|
13
|
+
assert.equal(CONTROL_FRAME_MARKER, "nanoControlFrame");
|
|
14
|
+
assert.equal(CONTROL_FRAME_VERSION, 1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("isInboundControlEnvelope only accepts the tagged object at the current version", () => {
|
|
18
|
+
assert.ok(isInboundControlEnvelope({ [CONTROL_FRAME_MARKER]: 1, kind: "cancel" }));
|
|
19
|
+
assert.ok(!isInboundControlEnvelope({ [CONTROL_FRAME_MARKER]: 2, kind: "cancel" }));
|
|
20
|
+
assert.ok(!isInboundControlEnvelope({ kind: "cancel" }));
|
|
21
|
+
assert.ok(!isInboundControlEnvelope("cancel"));
|
|
22
|
+
assert.ok(!isInboundControlEnvelope(null));
|
|
23
|
+
assert.ok(!isInboundControlEnvelope([{ [CONTROL_FRAME_MARKER]: 1 }]));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("prompt frame decodes with its text", () => {
|
|
27
|
+
const chunk = encodeInboundControlFrame({ kind: "prompt", text: "hello" });
|
|
28
|
+
const result = parseInboundRelayChunk(chunk);
|
|
29
|
+
assert.ok(result.ok);
|
|
30
|
+
assert.equal(result.structured, true);
|
|
31
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: "hello" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("cancel frame decodes with and without a reason", () => {
|
|
35
|
+
const bare = parseInboundRelayChunk(encodeInboundControlFrame({ kind: "cancel" }));
|
|
36
|
+
assert.ok(bare.ok && bare.frame.kind === "cancel");
|
|
37
|
+
assert.equal(bare.ok && "reason" in bare.frame, false);
|
|
38
|
+
|
|
39
|
+
const withReason = parseInboundRelayChunk(
|
|
40
|
+
encodeInboundControlFrame({ kind: "cancel", reason: "stop" }),
|
|
41
|
+
);
|
|
42
|
+
assert.ok(withReason.ok);
|
|
43
|
+
assert.deepEqual(withReason.frame, { kind: "cancel", reason: "stop" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("permission frame carries the request id and outcome", () => {
|
|
47
|
+
for (const outcome of ["granted", "denied"] as const) {
|
|
48
|
+
const chunk = encodeInboundControlFrame({ kind: "permission", requestId: "r1", outcome });
|
|
49
|
+
const result = parseInboundRelayChunk(chunk);
|
|
50
|
+
assert.ok(result.ok);
|
|
51
|
+
assert.deepEqual(result.frame, { kind: "permission", requestId: "r1", outcome });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a bare keystroke string decodes as a prompt verbatim (legacy raw-byte steer)", () => {
|
|
56
|
+
for (const raw of ["ls -la\n", "\u0003", "y", "{not json", "42", "true"]) {
|
|
57
|
+
const result = parseInboundRelayChunk(raw);
|
|
58
|
+
assert.ok(result.ok, raw);
|
|
59
|
+
assert.equal(result.structured, false, raw);
|
|
60
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: raw }, raw);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an untagged JSON object is a legacy prompt, not a control frame", () => {
|
|
65
|
+
const chunk = JSON.stringify({ kind: "cancel" });
|
|
66
|
+
const result = parseInboundRelayChunk(chunk);
|
|
67
|
+
assert.ok(result.ok);
|
|
68
|
+
assert.equal(result.structured, false);
|
|
69
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: chunk });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a tagged-but-malformed envelope is an error, never a silent prompt", () => {
|
|
73
|
+
const cases: Array<{ chunk: string; code: string }> = [
|
|
74
|
+
{ chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1 }), code: "bad-kind" },
|
|
75
|
+
{ chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1, kind: "nope" }), code: "bad-kind" },
|
|
76
|
+
{ chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1, kind: "prompt" }), code: "bad-prompt-text" },
|
|
77
|
+
{
|
|
78
|
+
chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1, kind: "permission", outcome: "granted" }),
|
|
79
|
+
code: "bad-permission-request-id",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
chunk: JSON.stringify({
|
|
83
|
+
[CONTROL_FRAME_MARKER]: 1,
|
|
84
|
+
kind: "permission",
|
|
85
|
+
requestId: "r",
|
|
86
|
+
outcome: "meh",
|
|
87
|
+
}),
|
|
88
|
+
code: "bad-permission-outcome",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
for (const c of cases) {
|
|
92
|
+
const result = parseInboundRelayChunk(c.chunk);
|
|
93
|
+
assert.ok(!result.ok, c.chunk);
|
|
94
|
+
assert.ok(!result.ok && result.errors.some((e) => e.code === c.code), c.chunk);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a control envelope with leading whitespace still decodes structured (fast-path scans past whitespace)", () => {
|
|
99
|
+
const envelope = encodeInboundControlFrame({ kind: "cancel", reason: "stop" });
|
|
100
|
+
for (const prefix of [" ", "\t", "\n", "\r\n", " \t\n "]) {
|
|
101
|
+
const result = parseInboundRelayChunk(prefix + envelope);
|
|
102
|
+
assert.ok(result.ok, prefix);
|
|
103
|
+
assert.equal(result.structured, true, prefix);
|
|
104
|
+
assert.deepEqual(result.frame, { kind: "cancel", reason: "stop" }, prefix);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("chunks whose first non-whitespace char is not { stay legacy prompts verbatim", () => {
|
|
109
|
+
// Includes valid non-object JSON (number/array/string) that must NOT be
|
|
110
|
+
// mistaken for a control envelope by the JSON.parse fast-path gate.
|
|
111
|
+
for (const raw of [" ls\n", "\t\ty", "42", " 3.14", "[1,2,3]", '"quoted"', "", " "]) {
|
|
112
|
+
const result = parseInboundRelayChunk(raw);
|
|
113
|
+
assert.ok(result.ok, raw);
|
|
114
|
+
assert.equal(result.structured, false, raw);
|
|
115
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: raw }, raw);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("every frame kind round-trips through encode/parse", () => {
|
|
120
|
+
const frames: InboundControlFrame[] = [
|
|
121
|
+
{ kind: "prompt", text: "多 bytes ✓" },
|
|
122
|
+
{ kind: "cancel" },
|
|
123
|
+
{ kind: "cancel", reason: "why" },
|
|
124
|
+
{ kind: "permission", requestId: "req-42", outcome: "denied" },
|
|
125
|
+
];
|
|
126
|
+
for (const frame of frames) {
|
|
127
|
+
const back = parseInboundRelayChunk(encodeInboundControlFrame(frame));
|
|
128
|
+
assert.ok(back.ok);
|
|
129
|
+
assert.deepEqual(back.frame, frame);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed INBOUND control vocabulary for the `relay` family (ADR 0056, ACP steer).
|
|
3
|
+
*
|
|
4
|
+
* The relay data frame ({@link RelayPayload} `{ stream, offset, chunk }`) is the
|
|
5
|
+
* OUTBOUND (hub → consumer) byte lane and is left untouched. This module adds an
|
|
6
|
+
* ADDITIVE vocabulary for the INBOUND (consumer → agent) steer lane, where a
|
|
7
|
+
* peer historically sent a bare `chunk` string that a PTY role fed straight to
|
|
8
|
+
* the terminal as keystrokes. ACP steering must now distinguish three intents:
|
|
9
|
+
*
|
|
10
|
+
* - `prompt` — start a new turn (carries the prompt text);
|
|
11
|
+
* - `cancel` — interrupt the current turn (optionally with a reason);
|
|
12
|
+
* - `permission` — answer/release a blocked `session/request_permission`
|
|
13
|
+
* (carries the request id being answered + the outcome).
|
|
14
|
+
*
|
|
15
|
+
* ## Backward compatibility (raw-byte steer)
|
|
16
|
+
*
|
|
17
|
+
* The vocabulary is a STRICT superset of the legacy raw-byte path. A structured
|
|
18
|
+
* frame is a JSON envelope TAGGED with {@link CONTROL_FRAME_MARKER}; a bare
|
|
19
|
+
* inbound string that is NOT such a tagged envelope (a keystroke, a shell line,
|
|
20
|
+
* unrelated JSON) continues to decode as a `prompt` whose `text` is the original
|
|
21
|
+
* chunk verbatim. Existing raw-byte producers and consumers need not change.
|
|
22
|
+
*
|
|
23
|
+
* ## Decode order (see {@link parseInboundRelayChunk})
|
|
24
|
+
*
|
|
25
|
+
* 1. If the chunk parses as a control envelope (a JSON object tagged with the
|
|
26
|
+
* marker at the current version) → validate it and return the typed frame;
|
|
27
|
+
* a tagged-but-malformed envelope is an ERROR, never a silent prompt.
|
|
28
|
+
* 2. Otherwise → legacy fall-back: `{ kind: "prompt", text: <chunk> }`.
|
|
29
|
+
*
|
|
30
|
+
* This is purely the inbound control vocabulary at the protocol seam: it changes
|
|
31
|
+
* neither the relay transport (ring, QoS, offsets, stream routing) nor the
|
|
32
|
+
* outbound `{ stream, offset, chunk }` data-frame shape.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The marker key that tags a chunk as a structured inbound control envelope.
|
|
37
|
+
* Its value is the {@link CONTROL_FRAME_VERSION}. A chunk without this key is a
|
|
38
|
+
* legacy bare-string steer and decodes as a `prompt`.
|
|
39
|
+
*/
|
|
40
|
+
export const CONTROL_FRAME_MARKER = "nanoControlFrame" as const;
|
|
41
|
+
|
|
42
|
+
/** The schema version carried by the {@link CONTROL_FRAME_MARKER}. */
|
|
43
|
+
export const CONTROL_FRAME_VERSION = 1 as const;
|
|
44
|
+
|
|
45
|
+
/** The three inbound steer intents. */
|
|
46
|
+
export type InboundControlKind = "prompt" | "cancel" | "permission";
|
|
47
|
+
|
|
48
|
+
/** How a blocked `session/request_permission` was answered. */
|
|
49
|
+
export type PermissionOutcome = "granted" | "denied";
|
|
50
|
+
|
|
51
|
+
/** Start a new turn with the given prompt text. */
|
|
52
|
+
export interface PromptControlFrame {
|
|
53
|
+
readonly kind: "prompt";
|
|
54
|
+
readonly text: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Interrupt the current turn. `reason` is advisory, for telemetry/audit. */
|
|
58
|
+
export interface CancelControlFrame {
|
|
59
|
+
readonly kind: "cancel";
|
|
60
|
+
readonly reason?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Answer a blocked `session/request_permission` identified by `requestId`. */
|
|
64
|
+
export interface PermissionControlFrame {
|
|
65
|
+
readonly kind: "permission";
|
|
66
|
+
readonly requestId: string;
|
|
67
|
+
readonly outcome: PermissionOutcome;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The typed inbound control vocabulary — a discriminated union on `kind`. */
|
|
71
|
+
export type InboundControlFrame =
|
|
72
|
+
| PromptControlFrame
|
|
73
|
+
| CancelControlFrame
|
|
74
|
+
| PermissionControlFrame;
|
|
75
|
+
|
|
76
|
+
export interface InboundControlError {
|
|
77
|
+
readonly code: InboundControlErrorCode;
|
|
78
|
+
readonly message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The closed set of validation-error codes a tagged-but-malformed control
|
|
83
|
+
* envelope can raise. The conformance corpus derives its coverage from this
|
|
84
|
+
* union, so a new code cannot be added without a covering malformed vector.
|
|
85
|
+
*/
|
|
86
|
+
export type InboundControlErrorCode =
|
|
87
|
+
| "bad-kind"
|
|
88
|
+
| "bad-prompt-text"
|
|
89
|
+
| "bad-cancel-reason"
|
|
90
|
+
| "bad-permission-request-id"
|
|
91
|
+
| "bad-permission-outcome";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The result of decoding an inbound steer chunk. `structured` distinguishes a
|
|
95
|
+
* recognised control envelope from the legacy bare-string-as-prompt fall-back,
|
|
96
|
+
* so a consumer can tell an explicit `prompt` frame from a raw keystroke.
|
|
97
|
+
*/
|
|
98
|
+
export type InboundControlDecodeResult =
|
|
99
|
+
| { readonly ok: true; readonly frame: InboundControlFrame; readonly structured: boolean }
|
|
100
|
+
| { readonly ok: false; readonly errors: readonly InboundControlError[] };
|
|
101
|
+
|
|
102
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is `value` a structured inbound control envelope (a plain object tagged with
|
|
108
|
+
* the marker at the current version)? This is the discriminator the decoder uses
|
|
109
|
+
* to choose the structured path over the legacy bare-string fall-back — it does
|
|
110
|
+
* NOT assert the envelope's `kind`-specific fields are well-formed.
|
|
111
|
+
*/
|
|
112
|
+
export function isInboundControlEnvelope(value: unknown): value is Record<string, unknown> {
|
|
113
|
+
return isPlainObject(value) && value[CONTROL_FRAME_MARKER] === CONTROL_FRAME_VERSION;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateEnvelope(env: Record<string, unknown>): InboundControlDecodeResult {
|
|
117
|
+
const errors: InboundControlError[] = [];
|
|
118
|
+
const kind = env.kind;
|
|
119
|
+
switch (kind) {
|
|
120
|
+
case "prompt": {
|
|
121
|
+
const text = env.text;
|
|
122
|
+
if (typeof text !== "string") {
|
|
123
|
+
errors.push({ code: "bad-prompt-text", message: "control.prompt.text must be a string" });
|
|
124
|
+
return { ok: false, errors };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, structured: true, frame: { kind: "prompt", text } };
|
|
127
|
+
}
|
|
128
|
+
case "cancel": {
|
|
129
|
+
if ("reason" in env && typeof env.reason !== "string") {
|
|
130
|
+
errors.push({ code: "bad-cancel-reason", message: "control.cancel.reason must be a string when present" });
|
|
131
|
+
}
|
|
132
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
133
|
+
const frame: CancelControlFrame =
|
|
134
|
+
typeof env.reason === "string" ? { kind: "cancel", reason: env.reason } : { kind: "cancel" };
|
|
135
|
+
return { ok: true, structured: true, frame };
|
|
136
|
+
}
|
|
137
|
+
case "permission": {
|
|
138
|
+
const requestId = env.requestId;
|
|
139
|
+
const outcome = env.outcome;
|
|
140
|
+
const validRequestId = typeof requestId === "string" && requestId.length > 0 ? requestId : undefined;
|
|
141
|
+
if (validRequestId === undefined) {
|
|
142
|
+
errors.push({
|
|
143
|
+
code: "bad-permission-request-id",
|
|
144
|
+
message: "control.permission.requestId must be a non-empty string",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const validOutcome: PermissionOutcome | undefined =
|
|
148
|
+
outcome === "granted" || outcome === "denied" ? outcome : undefined;
|
|
149
|
+
if (validOutcome === undefined) {
|
|
150
|
+
errors.push({
|
|
151
|
+
code: "bad-permission-outcome",
|
|
152
|
+
message: "control.permission.outcome must be 'granted' or 'denied'",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (errors.length > 0 || validRequestId === undefined || validOutcome === undefined) {
|
|
156
|
+
return { ok: false, errors };
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
structured: true,
|
|
161
|
+
frame: {
|
|
162
|
+
kind: "permission",
|
|
163
|
+
requestId: validRequestId,
|
|
164
|
+
outcome: validOutcome,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
default: {
|
|
169
|
+
const seen = kind === undefined ? "<missing>" : JSON.stringify(kind);
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
errors: [
|
|
173
|
+
{
|
|
174
|
+
code: "bad-kind",
|
|
175
|
+
message: `control.kind must be one of prompt|cancel|permission, got ${seen}`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether `chunk` begins with a JSON object — i.e. its first character that is
|
|
185
|
+
* not JSON-insignificant whitespace (space, tab, LF, CR) is `{`. Used as a
|
|
186
|
+
* cheap, allocation-free gate before attempting a full `JSON.parse`: only a
|
|
187
|
+
* chunk that starts a JSON object can possibly be a tagged control envelope.
|
|
188
|
+
*/
|
|
189
|
+
function startsWithJsonObject(chunk: string): boolean {
|
|
190
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
191
|
+
const code = chunk.charCodeAt(i);
|
|
192
|
+
// JSON insignificant whitespace: space (0x20), tab (0x09), LF (0x0a), CR (0x0d).
|
|
193
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
return code === 0x7b; // '{'
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decode a raw inbound steer chunk into a typed {@link InboundControlFrame}.
|
|
203
|
+
*
|
|
204
|
+
* A chunk tagged as a control envelope is validated and returned as its typed
|
|
205
|
+
* frame (`structured: true`); a tagged-but-malformed envelope (bad/missing
|
|
206
|
+
* discriminant, missing/ill-typed field) is a validation ERROR. Any other chunk
|
|
207
|
+
* — a bare keystroke, a shell line, unrelated JSON — is treated as the legacy
|
|
208
|
+
* raw-byte steer and returned as a `prompt` whose `text` is the chunk verbatim
|
|
209
|
+
* (`structured: false`). See the module header for the full decode order.
|
|
210
|
+
*/
|
|
211
|
+
export function parseInboundRelayChunk(chunk: string): InboundControlDecodeResult {
|
|
212
|
+
// Fast path for the common per-keystroke steer: a control envelope is always a
|
|
213
|
+
// JSON *object*, so it must begin with `{` after any insignificant leading
|
|
214
|
+
// whitespace. Any chunk that does not is (and always was) a legacy bare-string
|
|
215
|
+
// prompt carrying the chunk verbatim, so skip the JSON.parse attempt entirely.
|
|
216
|
+
if (!startsWithJsonObject(chunk)) {
|
|
217
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
218
|
+
}
|
|
219
|
+
let parsed: unknown;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(chunk);
|
|
222
|
+
} catch {
|
|
223
|
+
// Started with `{` but is not valid JSON. Legacy bare-string prompt.
|
|
224
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
225
|
+
}
|
|
226
|
+
if (!isInboundControlEnvelope(parsed)) {
|
|
227
|
+
// Valid JSON but not a control envelope (unrelated JSON, or a plain string
|
|
228
|
+
// like "ls\n"): still a legacy bare-string prompt carrying the chunk as-is.
|
|
229
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
230
|
+
}
|
|
231
|
+
return validateEnvelope(parsed);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Encode a typed {@link InboundControlFrame} as its canonical wire chunk — a
|
|
236
|
+
* JSON envelope tagged with {@link CONTROL_FRAME_MARKER}. The result round-trips
|
|
237
|
+
* back through {@link parseInboundRelayChunk} to an equal frame.
|
|
238
|
+
*/
|
|
239
|
+
export function encodeInboundControlFrame(frame: InboundControlFrame): string {
|
|
240
|
+
const marker = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION } as const;
|
|
241
|
+
switch (frame.kind) {
|
|
242
|
+
case "prompt":
|
|
243
|
+
return JSON.stringify({ ...marker, kind: "prompt", text: frame.text });
|
|
244
|
+
case "cancel":
|
|
245
|
+
return JSON.stringify(
|
|
246
|
+
frame.reason === undefined
|
|
247
|
+
? { ...marker, kind: "cancel" }
|
|
248
|
+
: { ...marker, kind: "cancel", reason: frame.reason },
|
|
249
|
+
);
|
|
250
|
+
case "permission":
|
|
251
|
+
return JSON.stringify({
|
|
252
|
+
...marker,
|
|
253
|
+
kind: "permission",
|
|
254
|
+
requestId: frame.requestId,
|
|
255
|
+
outcome: frame.outcome,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/protocol/index.ts
CHANGED
|
@@ -85,4 +85,21 @@ export {
|
|
|
85
85
|
type PayloadValidationResult,
|
|
86
86
|
} from "./payloads.ts";
|
|
87
87
|
|
|
88
|
+
export {
|
|
89
|
+
CONTROL_FRAME_MARKER,
|
|
90
|
+
CONTROL_FRAME_VERSION,
|
|
91
|
+
isInboundControlEnvelope,
|
|
92
|
+
parseInboundRelayChunk,
|
|
93
|
+
encodeInboundControlFrame,
|
|
94
|
+
type InboundControlKind,
|
|
95
|
+
type PermissionOutcome,
|
|
96
|
+
type PromptControlFrame,
|
|
97
|
+
type CancelControlFrame,
|
|
98
|
+
type PermissionControlFrame,
|
|
99
|
+
type InboundControlFrame,
|
|
100
|
+
type InboundControlError,
|
|
101
|
+
type InboundControlErrorCode,
|
|
102
|
+
type InboundControlDecodeResult,
|
|
103
|
+
} from "./control.ts";
|
|
104
|
+
|
|
88
105
|
export { bytesToHex, hexToBytes } from "./hex.ts";
|
package/src/transcript/index.ts
CHANGED
|
@@ -18,16 +18,24 @@ export type {
|
|
|
18
18
|
Clock,
|
|
19
19
|
SqliteDb,
|
|
20
20
|
TranscriptChunk,
|
|
21
|
+
TranscriptContentBlock,
|
|
22
|
+
TranscriptContentType,
|
|
21
23
|
TranscriptLifecycle,
|
|
22
24
|
TranscriptRing,
|
|
23
25
|
TranscriptSlice,
|
|
24
26
|
TranscriptStatus,
|
|
25
27
|
TranscriptStoreOptions,
|
|
26
28
|
TranscriptStream,
|
|
29
|
+
TranscriptToolCall,
|
|
30
|
+
TranscriptTurn,
|
|
31
|
+
TranscriptTurnMetrics,
|
|
32
|
+
TranscriptTurnRole,
|
|
27
33
|
} from "./store.ts";
|
|
28
34
|
|
|
29
35
|
export {
|
|
30
36
|
TRANSCRIPT_CHUNK_TABLE,
|
|
31
37
|
TRANSCRIPT_SCHEMA_SQL,
|
|
32
38
|
TRANSCRIPT_STREAM_TABLE,
|
|
39
|
+
TRANSCRIPT_TURN_SCHEMA_SQL,
|
|
40
|
+
TRANSCRIPT_TURN_TABLE,
|
|
33
41
|
} from "./schema.ts";
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
TRANSCRIPT_CHUNK_TABLE,
|
|
7
7
|
TRANSCRIPT_SCHEMA_SQL,
|
|
8
8
|
TRANSCRIPT_STREAM_TABLE,
|
|
9
|
+
TRANSCRIPT_TURN_SCHEMA_SQL,
|
|
10
|
+
TRANSCRIPT_TURN_TABLE,
|
|
9
11
|
} from "./schema.ts";
|
|
10
12
|
import { TranscriptStore } from "./store.ts";
|
|
11
13
|
import { openTestDb } from "./test-db.ts";
|
|
@@ -29,6 +31,10 @@ const migrationPath = fileURLToPath(
|
|
|
29
31
|
new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url),
|
|
30
32
|
);
|
|
31
33
|
|
|
34
|
+
const turnMigrationPath = fileURLToPath(
|
|
35
|
+
new URL("../../../../db/migrations/008_agentic_transcript_turns.sql", import.meta.url),
|
|
36
|
+
);
|
|
37
|
+
|
|
32
38
|
test("the boot migration and TranscriptStore's DDL do not drift", () => {
|
|
33
39
|
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
34
40
|
assert.equal(
|
|
@@ -53,17 +59,38 @@ test("the migration takes prefix 002, after S2's 001 and before S7", () => {
|
|
|
53
59
|
assert.match(stream, /\/002_agentic_transcript\.sql$/);
|
|
54
60
|
});
|
|
55
61
|
|
|
56
|
-
test("
|
|
62
|
+
test("the turn-view boot migration and TranscriptStore's turn DDL do not drift", () => {
|
|
63
|
+
const migrationSql = readFileSync(turnMigrationPath, "utf8");
|
|
64
|
+
assert.equal(
|
|
65
|
+
normaliseSql(migrationSql),
|
|
66
|
+
normaliseSql(TRANSCRIPT_TURN_SCHEMA_SQL),
|
|
67
|
+
"db/migrations/008_agentic_transcript_turns.sql must match TRANSCRIPT_TURN_SCHEMA_SQL — update both together",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the turn-view boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
72
|
+
const migrationSql = readFileSync(turnMigrationPath, "utf8");
|
|
73
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_turn/);
|
|
74
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
75
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the turn-view migration takes prefix 008 (next free after 007)", () => {
|
|
79
|
+
const stream = new URL("../../../../db/migrations/008_agentic_transcript_turns.sql", import.meta.url).pathname;
|
|
80
|
+
assert.match(stream, /\/008_agentic_transcript_turns\.sql$/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("ensureSchema creates all three transcript tables idempotently", () => {
|
|
57
84
|
const db = openTestDb();
|
|
58
85
|
const store = new TranscriptStore(db);
|
|
59
86
|
store.ensureSchema();
|
|
60
87
|
store.ensureSchema();
|
|
61
88
|
const tables = db
|
|
62
89
|
.all<{ name: string }>(
|
|
63
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?) ORDER BY name",
|
|
64
|
-
[TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE],
|
|
90
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?) ORDER BY name",
|
|
91
|
+
[TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_TABLE],
|
|
65
92
|
)
|
|
66
93
|
.map((r) => r.name);
|
|
67
|
-
assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE]);
|
|
94
|
+
assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_TABLE]);
|
|
68
95
|
db.close();
|
|
69
96
|
});
|
package/src/transcript/schema.ts
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
10
|
* production/boot mismatch.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three tables back the store:
|
|
13
13
|
* - `agentic_transcript_stream` — one row per relay stream: its retention
|
|
14
14
|
* lifecycle (`ephemeral` vs `long-lived`), its status (`open`/`completed`),
|
|
15
15
|
* and the offset window (`first_offset` … `next_offset`) currently retained.
|
|
16
16
|
* - `agentic_transcript_chunk` — the durable chunks, keyed `(stream, chunk_offset)`
|
|
17
17
|
* so a flush/append is idempotent and reattach can slice from any offset.
|
|
18
|
+
* - `agentic_transcript_turn` — the additive turn-structured view (Camunda
|
|
19
|
+
* `AgentHistoryRecordValue` parity, issue #475), keyed `(stream, turn_sequence)`.
|
|
20
|
+
* It is layered over — never a replacement for — the raw chunk stream.
|
|
18
21
|
*
|
|
19
22
|
* `chunk_offset` (not `offset`) is deliberate: `OFFSET` is a SQLite keyword, so
|
|
20
23
|
* the column is named to avoid quoting it in every statement.
|
|
@@ -26,6 +29,9 @@ export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
|
26
29
|
/** The durable per-chunk table name. */
|
|
27
30
|
export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
28
31
|
|
|
32
|
+
/** The durable per-turn (structured-view) table name. */
|
|
33
|
+
export const TRANSCRIPT_TURN_TABLE = "agentic_transcript_turn";
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
31
37
|
* added here must also be added to the boot migration (the drift guard enforces
|
|
@@ -49,3 +55,32 @@ CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
|
|
|
49
55
|
PRIMARY KEY (stream, chunk_offset)
|
|
50
56
|
);
|
|
51
57
|
CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The turn-structured transcript DDL — the additive, Camunda-`AgentHistoryRecordValue`
|
|
61
|
+
* parity view layered over the raw chunk stream (issue #475). It ships as its own
|
|
62
|
+
* forward-only migration `db/migrations/008_agentic_transcript_turns.sql` (the raw
|
|
63
|
+
* chunk stream in {@link TRANSCRIPT_SCHEMA_SQL} is untouched — additive, no regression
|
|
64
|
+
* to existing readers), mirrored here as the single source of truth applied by
|
|
65
|
+
* {@link TranscriptStore.ensureSchema} and kept in lockstep by a drift-guard test.
|
|
66
|
+
*
|
|
67
|
+
* One row per structured turn, keyed `(stream, turn_sequence)` so an append/re-record
|
|
68
|
+
* is idempotent (exactly the `(stream, chunk_offset)` discipline of the chunk table).
|
|
69
|
+
* `turn_sequence` is the stream-local append order and idempotency key;
|
|
70
|
+
* `loop_iteration` is the agent-loop turn counter carried as data (Camunda allows
|
|
71
|
+
* several role-split records — e.g. ASSISTANT then TOOL_RESULT — within one iteration).
|
|
72
|
+
* `content`, `tool_calls` and `metrics` hold the typed content blocks, tool calls and
|
|
73
|
+
* per-turn metrics as JSON.
|
|
74
|
+
*/
|
|
75
|
+
export const TRANSCRIPT_TURN_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_TURN_TABLE} (
|
|
76
|
+
stream TEXT NOT NULL,
|
|
77
|
+
turn_sequence INTEGER NOT NULL,
|
|
78
|
+
loop_iteration INTEGER NOT NULL,
|
|
79
|
+
role TEXT NOT NULL,
|
|
80
|
+
content TEXT NOT NULL,
|
|
81
|
+
tool_calls TEXT NOT NULL,
|
|
82
|
+
metrics TEXT,
|
|
83
|
+
produced_at INTEGER,
|
|
84
|
+
recorded_at TEXT NOT NULL,
|
|
85
|
+
PRIMARY KEY (stream, turn_sequence)
|
|
86
|
+
);`;
|