@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
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The **Nano agentic protocol** (ADR 0056): one app-tier channel carrying agent pr
|
|
|
13
13
|
| `@nanobpm/agentic/vocab` | Vocab resolver + core vocabulary (S3) |
|
|
14
14
|
| `@nanobpm/agentic/demand` | Demand×supply model (S4) |
|
|
15
15
|
| `@nanobpm/agentic/relay` | Relay ring + QoS scheduler (S5) |
|
|
16
|
-
| `@nanobpm/agentic/transcript` | Transcript store, retention-by-lifecycle (S6) |
|
|
16
|
+
| `@nanobpm/agentic/transcript` | Transcript store, retention-by-lifecycle (S6) + turn-structured view (Camunda `AgentHistoryRecordValue` parity) |
|
|
17
17
|
| `@nanobpm/agentic/blackboard` | Blackboard channel family (S7) |
|
|
18
18
|
| `@nanobpm/agentic/cockpit` | Operator visibility page — the cockpit (S8) |
|
|
19
19
|
| `@nanobpm/agentic/session` | Canonical `SessionEvent` + authoritative session log for durable agent-session resume (ADR 0062) |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type InboundControlErrorCode, type InboundControlFrame } from "../control.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Shared corpus for the INBOUND control vocabulary (steer-in). These vectors are
|
|
4
|
+
* exported so cross-repo consumers (the c8ctl harness) decode the SAME frames.
|
|
5
|
+
*
|
|
6
|
+
* `chunk` is the raw inbound steer string a peer sends; `frame` is the typed
|
|
7
|
+
* {@link InboundControlFrame} it must decode to; `structured` records whether it
|
|
8
|
+
* is a recognised control envelope (`true`) or the legacy bare-string-as-prompt
|
|
9
|
+
* fall-back (`false`). A `roundTrips: false` vector decodes to `frame` but does
|
|
10
|
+
* NOT re-encode to the same `chunk` (a legacy bare string, or an envelope with
|
|
11
|
+
* extra tolerated fields), so the round-trip test skips re-encoding it.
|
|
12
|
+
*/
|
|
13
|
+
export interface ValidControlFrame {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly chunk: string;
|
|
16
|
+
readonly frame: InboundControlFrame;
|
|
17
|
+
readonly structured: boolean;
|
|
18
|
+
readonly roundTrips: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare const VALID_CONTROL_FRAMES: readonly ValidControlFrame[];
|
|
21
|
+
/**
|
|
22
|
+
* Adversarial inbound control vectors: a chunk TAGGED as a control envelope but
|
|
23
|
+
* malformed. Each MUST be rejected with the exact {@link InboundControlErrorCode}.
|
|
24
|
+
* A missing tag is NOT here — an untagged chunk is a valid legacy prompt (see
|
|
25
|
+
* {@link VALID_CONTROL_FRAMES}), never an error.
|
|
26
|
+
*/
|
|
27
|
+
export interface MalformedControlFrame {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly chunk: string;
|
|
30
|
+
readonly expected: InboundControlErrorCode;
|
|
31
|
+
}
|
|
32
|
+
export declare const MALFORMED_CONTROL_FRAMES: readonly MalformedControlFrame[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CONTROL_FRAME_MARKER, CONTROL_FRAME_VERSION, } from "../control.js";
|
|
2
|
+
const MARKER = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION };
|
|
3
|
+
export const VALID_CONTROL_FRAMES = [
|
|
4
|
+
{
|
|
5
|
+
name: "prompt-structured",
|
|
6
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "run the tests" }),
|
|
7
|
+
frame: { kind: "prompt", text: "run the tests" },
|
|
8
|
+
structured: true,
|
|
9
|
+
roundTrips: true,
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "prompt-structured-empty-text",
|
|
13
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "" }),
|
|
14
|
+
frame: { kind: "prompt", text: "" },
|
|
15
|
+
structured: true,
|
|
16
|
+
roundTrips: true,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "cancel-structured",
|
|
20
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel" }),
|
|
21
|
+
frame: { kind: "cancel" },
|
|
22
|
+
structured: true,
|
|
23
|
+
roundTrips: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "cancel-structured-with-reason",
|
|
27
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: "operator interrupt" }),
|
|
28
|
+
frame: { kind: "cancel", reason: "operator interrupt" },
|
|
29
|
+
structured: true,
|
|
30
|
+
roundTrips: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "permission-granted",
|
|
34
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-7", outcome: "granted" }),
|
|
35
|
+
frame: { kind: "permission", requestId: "req-7", outcome: "granted" },
|
|
36
|
+
structured: true,
|
|
37
|
+
roundTrips: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "permission-denied",
|
|
41
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-8", outcome: "denied" }),
|
|
42
|
+
frame: { kind: "permission", requestId: "req-8", outcome: "denied" },
|
|
43
|
+
structured: true,
|
|
44
|
+
roundTrips: true,
|
|
45
|
+
},
|
|
46
|
+
// Legacy raw-byte steer: a bare keystroke/line is NOT a control envelope and
|
|
47
|
+
// must decode as a prompt carrying the chunk verbatim — the no-regression path.
|
|
48
|
+
{
|
|
49
|
+
name: "legacy-keystroke-line",
|
|
50
|
+
chunk: "ls -la\n",
|
|
51
|
+
frame: { kind: "prompt", text: "ls -la\n" },
|
|
52
|
+
structured: false,
|
|
53
|
+
roundTrips: false,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "legacy-control-c",
|
|
57
|
+
chunk: "\u0003",
|
|
58
|
+
frame: { kind: "prompt", text: "\u0003" },
|
|
59
|
+
structured: false,
|
|
60
|
+
roundTrips: false,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "legacy-json-number-is-not-a-frame",
|
|
64
|
+
chunk: "42",
|
|
65
|
+
frame: { kind: "prompt", text: "42" },
|
|
66
|
+
structured: false,
|
|
67
|
+
roundTrips: false,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "legacy-untagged-json-object",
|
|
71
|
+
chunk: JSON.stringify({ kind: "prompt", text: "not tagged" }),
|
|
72
|
+
frame: { kind: "prompt", text: JSON.stringify({ kind: "prompt", text: "not tagged" }) },
|
|
73
|
+
structured: false,
|
|
74
|
+
roundTrips: false,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
export const MALFORMED_CONTROL_FRAMES = [
|
|
78
|
+
{
|
|
79
|
+
name: "tagged-missing-kind",
|
|
80
|
+
chunk: JSON.stringify({ ...MARKER, text: "no kind" }),
|
|
81
|
+
expected: "bad-kind",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "tagged-unknown-kind",
|
|
85
|
+
chunk: JSON.stringify({ ...MARKER, kind: "explode" }),
|
|
86
|
+
expected: "bad-kind",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "prompt-missing-text",
|
|
90
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt" }),
|
|
91
|
+
expected: "bad-prompt-text",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "prompt-non-string-text",
|
|
95
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: 123 }),
|
|
96
|
+
expected: "bad-prompt-text",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "cancel-non-string-reason",
|
|
100
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: 5 }),
|
|
101
|
+
expected: "bad-cancel-reason",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "permission-missing-request-id",
|
|
105
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", outcome: "granted" }),
|
|
106
|
+
expected: "bad-permission-request-id",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "permission-bad-outcome",
|
|
110
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-9", outcome: "maybe" }),
|
|
111
|
+
expected: "bad-permission-outcome",
|
|
112
|
+
},
|
|
113
|
+
];
|
|
@@ -11,3 +11,4 @@ export { GOLDEN_FRAMES, type GoldenFrame, } from "./frames.ts";
|
|
|
11
11
|
export { MALFORMED_FRAMES, type MalformedFrame, } from "./malformed.ts";
|
|
12
12
|
export { VALID_VOCABS, INVALID_VOCABS, type ValidVocab, type InvalidVocab, } from "./vocab.ts";
|
|
13
13
|
export { VALID_TOKENS, INVALID_TOKENS, type ValidToken, type InvalidToken, } from "./tokens.ts";
|
|
14
|
+
export { VALID_CONTROL_FRAMES, MALFORMED_CONTROL_FRAMES, type ValidControlFrame, type MalformedControlFrame, } from "./control.ts";
|
|
@@ -11,3 +11,4 @@ export { GOLDEN_FRAMES, } from "./frames.js";
|
|
|
11
11
|
export { MALFORMED_FRAMES, } from "./malformed.js";
|
|
12
12
|
export { VALID_VOCABS, INVALID_VOCABS, } from "./vocab.js";
|
|
13
13
|
export { VALID_TOKENS, INVALID_TOKENS, } from "./tokens.js";
|
|
14
|
+
export { VALID_CONTROL_FRAMES, MALFORMED_CONTROL_FRAMES, } from "./control.js";
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
* The marker key that tags a chunk as a structured inbound control envelope.
|
|
36
|
+
* Its value is the {@link CONTROL_FRAME_VERSION}. A chunk without this key is a
|
|
37
|
+
* legacy bare-string steer and decodes as a `prompt`.
|
|
38
|
+
*/
|
|
39
|
+
export declare const CONTROL_FRAME_MARKER: "nanoControlFrame";
|
|
40
|
+
/** The schema version carried by the {@link CONTROL_FRAME_MARKER}. */
|
|
41
|
+
export declare const CONTROL_FRAME_VERSION: 1;
|
|
42
|
+
/** The three inbound steer intents. */
|
|
43
|
+
export type InboundControlKind = "prompt" | "cancel" | "permission";
|
|
44
|
+
/** How a blocked `session/request_permission` was answered. */
|
|
45
|
+
export type PermissionOutcome = "granted" | "denied";
|
|
46
|
+
/** Start a new turn with the given prompt text. */
|
|
47
|
+
export interface PromptControlFrame {
|
|
48
|
+
readonly kind: "prompt";
|
|
49
|
+
readonly text: string;
|
|
50
|
+
}
|
|
51
|
+
/** Interrupt the current turn. `reason` is advisory, for telemetry/audit. */
|
|
52
|
+
export interface CancelControlFrame {
|
|
53
|
+
readonly kind: "cancel";
|
|
54
|
+
readonly reason?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Answer a blocked `session/request_permission` identified by `requestId`. */
|
|
57
|
+
export interface PermissionControlFrame {
|
|
58
|
+
readonly kind: "permission";
|
|
59
|
+
readonly requestId: string;
|
|
60
|
+
readonly outcome: PermissionOutcome;
|
|
61
|
+
}
|
|
62
|
+
/** The typed inbound control vocabulary — a discriminated union on `kind`. */
|
|
63
|
+
export type InboundControlFrame = PromptControlFrame | CancelControlFrame | PermissionControlFrame;
|
|
64
|
+
export interface InboundControlError {
|
|
65
|
+
readonly code: InboundControlErrorCode;
|
|
66
|
+
readonly message: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The closed set of validation-error codes a tagged-but-malformed control
|
|
70
|
+
* envelope can raise. The conformance corpus derives its coverage from this
|
|
71
|
+
* union, so a new code cannot be added without a covering malformed vector.
|
|
72
|
+
*/
|
|
73
|
+
export type InboundControlErrorCode = "bad-kind" | "bad-prompt-text" | "bad-cancel-reason" | "bad-permission-request-id" | "bad-permission-outcome";
|
|
74
|
+
/**
|
|
75
|
+
* The result of decoding an inbound steer chunk. `structured` distinguishes a
|
|
76
|
+
* recognised control envelope from the legacy bare-string-as-prompt fall-back,
|
|
77
|
+
* so a consumer can tell an explicit `prompt` frame from a raw keystroke.
|
|
78
|
+
*/
|
|
79
|
+
export type InboundControlDecodeResult = {
|
|
80
|
+
readonly ok: true;
|
|
81
|
+
readonly frame: InboundControlFrame;
|
|
82
|
+
readonly structured: boolean;
|
|
83
|
+
} | {
|
|
84
|
+
readonly ok: false;
|
|
85
|
+
readonly errors: readonly InboundControlError[];
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Is `value` a structured inbound control envelope (a plain object tagged with
|
|
89
|
+
* the marker at the current version)? This is the discriminator the decoder uses
|
|
90
|
+
* to choose the structured path over the legacy bare-string fall-back — it does
|
|
91
|
+
* NOT assert the envelope's `kind`-specific fields are well-formed.
|
|
92
|
+
*/
|
|
93
|
+
export declare function isInboundControlEnvelope(value: unknown): value is Record<string, unknown>;
|
|
94
|
+
/**
|
|
95
|
+
* Decode a raw inbound steer chunk into a typed {@link InboundControlFrame}.
|
|
96
|
+
*
|
|
97
|
+
* A chunk tagged as a control envelope is validated and returned as its typed
|
|
98
|
+
* frame (`structured: true`); a tagged-but-malformed envelope (bad/missing
|
|
99
|
+
* discriminant, missing/ill-typed field) is a validation ERROR. Any other chunk
|
|
100
|
+
* — a bare keystroke, a shell line, unrelated JSON — is treated as the legacy
|
|
101
|
+
* raw-byte steer and returned as a `prompt` whose `text` is the chunk verbatim
|
|
102
|
+
* (`structured: false`). See the module header for the full decode order.
|
|
103
|
+
*/
|
|
104
|
+
export declare function parseInboundRelayChunk(chunk: string): InboundControlDecodeResult;
|
|
105
|
+
/**
|
|
106
|
+
* Encode a typed {@link InboundControlFrame} as its canonical wire chunk — a
|
|
107
|
+
* JSON envelope tagged with {@link CONTROL_FRAME_MARKER}. The result round-trips
|
|
108
|
+
* back through {@link parseInboundRelayChunk} to an equal frame.
|
|
109
|
+
*/
|
|
110
|
+
export declare function encodeInboundControlFrame(frame: InboundControlFrame): string;
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
* The marker key that tags a chunk as a structured inbound control envelope.
|
|
36
|
+
* Its value is the {@link CONTROL_FRAME_VERSION}. A chunk without this key is a
|
|
37
|
+
* legacy bare-string steer and decodes as a `prompt`.
|
|
38
|
+
*/
|
|
39
|
+
export const CONTROL_FRAME_MARKER = "nanoControlFrame";
|
|
40
|
+
/** The schema version carried by the {@link CONTROL_FRAME_MARKER}. */
|
|
41
|
+
export const CONTROL_FRAME_VERSION = 1;
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Is `value` a structured inbound control envelope (a plain object tagged with
|
|
47
|
+
* the marker at the current version)? This is the discriminator the decoder uses
|
|
48
|
+
* to choose the structured path over the legacy bare-string fall-back — it does
|
|
49
|
+
* NOT assert the envelope's `kind`-specific fields are well-formed.
|
|
50
|
+
*/
|
|
51
|
+
export function isInboundControlEnvelope(value) {
|
|
52
|
+
return isPlainObject(value) && value[CONTROL_FRAME_MARKER] === CONTROL_FRAME_VERSION;
|
|
53
|
+
}
|
|
54
|
+
function validateEnvelope(env) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
const kind = env.kind;
|
|
57
|
+
switch (kind) {
|
|
58
|
+
case "prompt": {
|
|
59
|
+
const text = env.text;
|
|
60
|
+
if (typeof text !== "string") {
|
|
61
|
+
errors.push({ code: "bad-prompt-text", message: "control.prompt.text must be a string" });
|
|
62
|
+
return { ok: false, errors };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, structured: true, frame: { kind: "prompt", text } };
|
|
65
|
+
}
|
|
66
|
+
case "cancel": {
|
|
67
|
+
if ("reason" in env && typeof env.reason !== "string") {
|
|
68
|
+
errors.push({ code: "bad-cancel-reason", message: "control.cancel.reason must be a string when present" });
|
|
69
|
+
}
|
|
70
|
+
if (errors.length > 0)
|
|
71
|
+
return { ok: false, errors };
|
|
72
|
+
const frame = typeof env.reason === "string" ? { kind: "cancel", reason: env.reason } : { kind: "cancel" };
|
|
73
|
+
return { ok: true, structured: true, frame };
|
|
74
|
+
}
|
|
75
|
+
case "permission": {
|
|
76
|
+
const requestId = env.requestId;
|
|
77
|
+
const outcome = env.outcome;
|
|
78
|
+
const validRequestId = typeof requestId === "string" && requestId.length > 0 ? requestId : undefined;
|
|
79
|
+
if (validRequestId === undefined) {
|
|
80
|
+
errors.push({
|
|
81
|
+
code: "bad-permission-request-id",
|
|
82
|
+
message: "control.permission.requestId must be a non-empty string",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const validOutcome = outcome === "granted" || outcome === "denied" ? outcome : undefined;
|
|
86
|
+
if (validOutcome === undefined) {
|
|
87
|
+
errors.push({
|
|
88
|
+
code: "bad-permission-outcome",
|
|
89
|
+
message: "control.permission.outcome must be 'granted' or 'denied'",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (errors.length > 0 || validRequestId === undefined || validOutcome === undefined) {
|
|
93
|
+
return { ok: false, errors };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
structured: true,
|
|
98
|
+
frame: {
|
|
99
|
+
kind: "permission",
|
|
100
|
+
requestId: validRequestId,
|
|
101
|
+
outcome: validOutcome,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
default: {
|
|
106
|
+
const seen = kind === undefined ? "<missing>" : JSON.stringify(kind);
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
errors: [
|
|
110
|
+
{
|
|
111
|
+
code: "bad-kind",
|
|
112
|
+
message: `control.kind must be one of prompt|cancel|permission, got ${seen}`,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether `chunk` begins with a JSON object — i.e. its first character that is
|
|
121
|
+
* not JSON-insignificant whitespace (space, tab, LF, CR) is `{`. Used as a
|
|
122
|
+
* cheap, allocation-free gate before attempting a full `JSON.parse`: only a
|
|
123
|
+
* chunk that starts a JSON object can possibly be a tagged control envelope.
|
|
124
|
+
*/
|
|
125
|
+
function startsWithJsonObject(chunk) {
|
|
126
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
127
|
+
const code = chunk.charCodeAt(i);
|
|
128
|
+
// JSON insignificant whitespace: space (0x20), tab (0x09), LF (0x0a), CR (0x0d).
|
|
129
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
return code === 0x7b; // '{'
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Decode a raw inbound steer chunk into a typed {@link InboundControlFrame}.
|
|
138
|
+
*
|
|
139
|
+
* A chunk tagged as a control envelope is validated and returned as its typed
|
|
140
|
+
* frame (`structured: true`); a tagged-but-malformed envelope (bad/missing
|
|
141
|
+
* discriminant, missing/ill-typed field) is a validation ERROR. Any other chunk
|
|
142
|
+
* — a bare keystroke, a shell line, unrelated JSON — is treated as the legacy
|
|
143
|
+
* raw-byte steer and returned as a `prompt` whose `text` is the chunk verbatim
|
|
144
|
+
* (`structured: false`). See the module header for the full decode order.
|
|
145
|
+
*/
|
|
146
|
+
export function parseInboundRelayChunk(chunk) {
|
|
147
|
+
// Fast path for the common per-keystroke steer: a control envelope is always a
|
|
148
|
+
// JSON *object*, so it must begin with `{` after any insignificant leading
|
|
149
|
+
// whitespace. Any chunk that does not is (and always was) a legacy bare-string
|
|
150
|
+
// prompt carrying the chunk verbatim, so skip the JSON.parse attempt entirely.
|
|
151
|
+
if (!startsWithJsonObject(chunk)) {
|
|
152
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
153
|
+
}
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(chunk);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Started with `{` but is not valid JSON. Legacy bare-string prompt.
|
|
160
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
161
|
+
}
|
|
162
|
+
if (!isInboundControlEnvelope(parsed)) {
|
|
163
|
+
// Valid JSON but not a control envelope (unrelated JSON, or a plain string
|
|
164
|
+
// like "ls\n"): still a legacy bare-string prompt carrying the chunk as-is.
|
|
165
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
166
|
+
}
|
|
167
|
+
return validateEnvelope(parsed);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Encode a typed {@link InboundControlFrame} as its canonical wire chunk — a
|
|
171
|
+
* JSON envelope tagged with {@link CONTROL_FRAME_MARKER}. The result round-trips
|
|
172
|
+
* back through {@link parseInboundRelayChunk} to an equal frame.
|
|
173
|
+
*/
|
|
174
|
+
export function encodeInboundControlFrame(frame) {
|
|
175
|
+
const marker = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION };
|
|
176
|
+
switch (frame.kind) {
|
|
177
|
+
case "prompt":
|
|
178
|
+
return JSON.stringify({ ...marker, kind: "prompt", text: frame.text });
|
|
179
|
+
case "cancel":
|
|
180
|
+
return JSON.stringify(frame.reason === undefined
|
|
181
|
+
? { ...marker, kind: "cancel" }
|
|
182
|
+
: { ...marker, kind: "cancel", reason: frame.reason });
|
|
183
|
+
case "permission":
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
...marker,
|
|
186
|
+
kind: "permission",
|
|
187
|
+
requestId: frame.requestId,
|
|
188
|
+
outcome: frame.outcome,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
package/dist/protocol/index.d.ts
CHANGED
|
@@ -20,4 +20,5 @@ export { encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, FRAME_MAG
|
|
|
20
20
|
export { parseToken, formatToken, isValidToken, isSegmentName, isSeatLabel, TokenParseError, type RoutingToken, type TokenParseErrorCode, } from "./token.ts";
|
|
21
21
|
export { validateVocabDocument, type VocabDocument, type VocabNetwork, type VocabRole, type VocabError, type VocabValidationResult, } from "./vocab/schema.ts";
|
|
22
22
|
export { validatePayload, type Capability, type RegisterPayload, type HeartbeatPayload, type DeregisterPayload, type ServePayload, type DemandPayload, type BlackboardPayload, type BlackboardOp, type RelayPayload, type RelayProducePayload, type RelaySubscribePayload, type RelayCreditPayload, type RelaySubscribedPayload, type PayloadError, type PayloadValidationResult, } from "./payloads.ts";
|
|
23
|
+
export { CONTROL_FRAME_MARKER, CONTROL_FRAME_VERSION, isInboundControlEnvelope, parseInboundRelayChunk, encodeInboundControlFrame, type InboundControlKind, type PermissionOutcome, type PromptControlFrame, type CancelControlFrame, type PermissionControlFrame, type InboundControlFrame, type InboundControlError, type InboundControlErrorCode, type InboundControlDecodeResult, } from "./control.ts";
|
|
23
24
|
export { bytesToHex, hexToBytes } from "./hex.ts";
|
package/dist/protocol/index.js
CHANGED
|
@@ -20,4 +20,5 @@ export { encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, FRAME_MAG
|
|
|
20
20
|
export { parseToken, formatToken, isValidToken, isSegmentName, isSeatLabel, TokenParseError, } from "./token.js";
|
|
21
21
|
export { validateVocabDocument, } from "./vocab/schema.js";
|
|
22
22
|
export { validatePayload, } from "./payloads.js";
|
|
23
|
+
export { CONTROL_FRAME_MARKER, CONTROL_FRAME_VERSION, isInboundControlEnvelope, parseInboundRelayChunk, encodeInboundControlFrame, } from "./control.js";
|
|
23
24
|
export { bytesToHex, hexToBytes } from "./hex.js";
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
* and kept in lockstep by a drift-guard test.
|
|
15
15
|
*/
|
|
16
16
|
export { TranscriptStore, TranscriptCorruptionError, TranscriptLifecycleError, systemClock } from "./store.ts";
|
|
17
|
-
export type { Clock, SqliteDb, TranscriptChunk, TranscriptLifecycle, TranscriptRing, TranscriptSlice, TranscriptStatus, TranscriptStoreOptions, TranscriptStream, } from "./store.ts";
|
|
18
|
-
export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, } from "./schema.ts";
|
|
17
|
+
export type { Clock, SqliteDb, TranscriptChunk, TranscriptContentBlock, TranscriptContentType, TranscriptLifecycle, TranscriptRing, TranscriptSlice, TranscriptStatus, TranscriptStoreOptions, TranscriptStream, TranscriptToolCall, TranscriptTurn, TranscriptTurnMetrics, TranscriptTurnRole, } from "./store.ts";
|
|
18
|
+
export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_SCHEMA_SQL, TRANSCRIPT_TURN_TABLE, } from "./schema.ts";
|
package/dist/transcript/index.js
CHANGED
|
@@ -14,4 +14,4 @@
|
|
|
14
14
|
* and kept in lockstep by a drift-guard test.
|
|
15
15
|
*/
|
|
16
16
|
export { TranscriptStore, TranscriptCorruptionError, TranscriptLifecycleError, systemClock } from "./store.js";
|
|
17
|
-
export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, } from "./schema.js";
|
|
17
|
+
export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_SCHEMA_SQL, TRANSCRIPT_TURN_TABLE, } from "./schema.js";
|
|
@@ -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.
|
|
@@ -23,6 +26,8 @@
|
|
|
23
26
|
export declare const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
24
27
|
/** The durable per-chunk table name. */
|
|
25
28
|
export declare const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
29
|
+
/** The durable per-turn (structured-view) table name. */
|
|
30
|
+
export declare const TRANSCRIPT_TURN_TABLE = "agentic_transcript_turn";
|
|
26
31
|
/**
|
|
27
32
|
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
28
33
|
* added here must also be added to the boot migration (the drift guard enforces
|
|
@@ -30,3 +35,20 @@ export declare const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
|
30
35
|
* it never rewrites a chunk.
|
|
31
36
|
*/
|
|
32
37
|
export declare const TRANSCRIPT_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_transcript_stream (\n stream TEXT PRIMARY KEY,\n lifecycle TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'open',\n created_at TEXT NOT NULL,\n completed_at TEXT,\n first_offset INTEGER,\n next_offset INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS agentic_transcript_chunk (\n stream TEXT NOT NULL,\n chunk_offset INTEGER NOT NULL,\n chunk TEXT NOT NULL,\n appended_at TEXT NOT NULL,\n PRIMARY KEY (stream, chunk_offset)\n);\nCREATE INDEX IF NOT EXISTS idx_agentic_transcript_stream_retention ON agentic_transcript_stream (lifecycle, status, completed_at);";
|
|
38
|
+
/**
|
|
39
|
+
* The turn-structured transcript DDL — the additive, Camunda-`AgentHistoryRecordValue`
|
|
40
|
+
* parity view layered over the raw chunk stream (issue #475). It ships as its own
|
|
41
|
+
* forward-only migration `db/migrations/008_agentic_transcript_turns.sql` (the raw
|
|
42
|
+
* chunk stream in {@link TRANSCRIPT_SCHEMA_SQL} is untouched — additive, no regression
|
|
43
|
+
* to existing readers), mirrored here as the single source of truth applied by
|
|
44
|
+
* {@link TranscriptStore.ensureSchema} and kept in lockstep by a drift-guard test.
|
|
45
|
+
*
|
|
46
|
+
* One row per structured turn, keyed `(stream, turn_sequence)` so an append/re-record
|
|
47
|
+
* is idempotent (exactly the `(stream, chunk_offset)` discipline of the chunk table).
|
|
48
|
+
* `turn_sequence` is the stream-local append order and idempotency key;
|
|
49
|
+
* `loop_iteration` is the agent-loop turn counter carried as data (Camunda allows
|
|
50
|
+
* several role-split records — e.g. ASSISTANT then TOOL_RESULT — within one iteration).
|
|
51
|
+
* `content`, `tool_calls` and `metrics` hold the typed content blocks, tool calls and
|
|
52
|
+
* per-turn metrics as JSON.
|
|
53
|
+
*/
|
|
54
|
+
export declare const TRANSCRIPT_TURN_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_transcript_turn (\n stream TEXT NOT NULL,\n turn_sequence INTEGER NOT NULL,\n loop_iteration INTEGER NOT NULL,\n role TEXT NOT NULL,\n content TEXT NOT NULL,\n tool_calls TEXT NOT NULL,\n metrics TEXT,\n produced_at INTEGER,\n recorded_at TEXT NOT NULL,\n PRIMARY KEY (stream, turn_sequence)\n);";
|
|
@@ -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.
|
|
@@ -23,6 +26,8 @@
|
|
|
23
26
|
export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
24
27
|
/** The durable per-chunk table name. */
|
|
25
28
|
export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
29
|
+
/** The durable per-turn (structured-view) table name. */
|
|
30
|
+
export const TRANSCRIPT_TURN_TABLE = "agentic_transcript_turn";
|
|
26
31
|
/**
|
|
27
32
|
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
28
33
|
* added here must also be added to the boot migration (the drift guard enforces
|
|
@@ -46,3 +51,31 @@ CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
|
|
|
46
51
|
PRIMARY KEY (stream, chunk_offset)
|
|
47
52
|
);
|
|
48
53
|
CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;
|
|
54
|
+
/**
|
|
55
|
+
* The turn-structured transcript DDL — the additive, Camunda-`AgentHistoryRecordValue`
|
|
56
|
+
* parity view layered over the raw chunk stream (issue #475). It ships as its own
|
|
57
|
+
* forward-only migration `db/migrations/008_agentic_transcript_turns.sql` (the raw
|
|
58
|
+
* chunk stream in {@link TRANSCRIPT_SCHEMA_SQL} is untouched — additive, no regression
|
|
59
|
+
* to existing readers), mirrored here as the single source of truth applied by
|
|
60
|
+
* {@link TranscriptStore.ensureSchema} and kept in lockstep by a drift-guard test.
|
|
61
|
+
*
|
|
62
|
+
* One row per structured turn, keyed `(stream, turn_sequence)` so an append/re-record
|
|
63
|
+
* is idempotent (exactly the `(stream, chunk_offset)` discipline of the chunk table).
|
|
64
|
+
* `turn_sequence` is the stream-local append order and idempotency key;
|
|
65
|
+
* `loop_iteration` is the agent-loop turn counter carried as data (Camunda allows
|
|
66
|
+
* several role-split records — e.g. ASSISTANT then TOOL_RESULT — within one iteration).
|
|
67
|
+
* `content`, `tool_calls` and `metrics` hold the typed content blocks, tool calls and
|
|
68
|
+
* per-turn metrics as JSON.
|
|
69
|
+
*/
|
|
70
|
+
export const TRANSCRIPT_TURN_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_TURN_TABLE} (
|
|
71
|
+
stream TEXT NOT NULL,
|
|
72
|
+
turn_sequence INTEGER NOT NULL,
|
|
73
|
+
loop_iteration INTEGER NOT NULL,
|
|
74
|
+
role TEXT NOT NULL,
|
|
75
|
+
content TEXT NOT NULL,
|
|
76
|
+
tool_calls TEXT NOT NULL,
|
|
77
|
+
metrics TEXT,
|
|
78
|
+
produced_at INTEGER,
|
|
79
|
+
recorded_at TEXT NOT NULL,
|
|
80
|
+
PRIMARY KEY (stream, turn_sequence)
|
|
81
|
+
);`;
|