@axiom-lattice/protocols 4.10.0 → 5.0.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A tenant-scoped namespaced key-value item.
3
+ */
4
+ export interface StoreItem {
5
+ /** Hierarchical namespace path. */
6
+ namespace: string[];
7
+ /** Item key, unique within `(tenantId, namespace)`. */
8
+ key: string;
9
+ /** Arbitrary JSON object stored under the key. */
10
+ value: Record<string, unknown>;
11
+ /** Creation timestamp. */
12
+ createdAt: Date;
13
+ /** Last write timestamp. */
14
+ updatedAt: Date;
15
+ /** Optional expiry timestamp; expired items are invisible. */
16
+ expiresAt?: Date;
17
+ }
18
+
19
+ /** Input for {@link StoreProtocol.put}. */
20
+ export interface StorePutInput {
21
+ /** Non-empty namespace path. */
22
+ namespace: string[];
23
+ /** Non-empty item key. */
24
+ key: string;
25
+ /** JSON object value. */
26
+ value: Record<string, unknown>;
27
+ /**
28
+ * Optional time-to-live in minutes.
29
+ *
30
+ * @remarks Positive finite numbers are accepted; stores compute `expiresAt`.
31
+ */
32
+ ttlMinutes?: number;
33
+ /**
34
+ * Optional index hint accepted for future indexing.
35
+ *
36
+ * @remarks No vector index is created in this slice.
37
+ */
38
+ index?: boolean | string[];
39
+ }
40
+
41
+ /** Input for {@link StoreProtocol.search}. */
42
+ export interface StoreSearchInput {
43
+ /** Only return items whose namespace starts with this path. */
44
+ namespacePrefix?: string[];
45
+ /** Deep partial-equality filter over `value`. */
46
+ filter?: Record<string, unknown>;
47
+ /** Case-insensitive substring over the serialized value. */
48
+ query?: string;
49
+ /** Maximum items to return. */
50
+ limit?: number;
51
+ /** Pagination offset. */
52
+ offset?: number;
53
+ }
54
+
55
+ /**
56
+ * Tenant-scoped namespaced key-value store with TTL and simple search.
57
+ *
58
+ * @remarks
59
+ * - Unique identity is `(tenantId, namespace, key)`.
60
+ * - Expired items are invisible to {@link get} and {@link search}.
61
+ * - `filter` is deep partial equality over `value`; `query` is a
62
+ * case-insensitive substring over the serialized value. Semantic/embedding
63
+ * search is deferred.
64
+ */
65
+ export interface StoreProtocol {
66
+ /** Create or replace an item. */
67
+ put(tenantId: string, input: StorePutInput): Promise<StoreItem>;
68
+ /** Read an unexpired item. */
69
+ get(tenantId: string, namespace: string[], key: string): Promise<StoreItem | null>;
70
+ /** Delete an item regardless of expiry. */
71
+ delete(tenantId: string, namespace: string[], key: string): Promise<boolean>;
72
+ /** Search unexpired items. */
73
+ search(tenantId: string, input: StoreSearchInput): Promise<StoreItem[]>;
74
+ /** List distinct namespace paths under a prefix. */
75
+ listNamespaces(tenantId: string, prefix?: string[], limit?: number): Promise<string[][]>;
76
+ /** Hard-delete expired items across all tenants. */
77
+ deleteExpired(now: Date, limit: number): Promise<number>;
78
+ }
79
+
80
+
81
+ /** Separator reserved for encoded namespace paths. */
82
+ export const STORE_IDENTITY_SEPARATOR = "\u001f";
83
+
84
+ /** Raised when a store namespace/key identity is malformed. */
85
+ export class StoreValidationError extends Error {
86
+ readonly code = "STORE_IDENTITY_INVALID" as const;
87
+
88
+ constructor(message: string) {
89
+ super(message);
90
+ this.name = "StoreValidationError";
91
+ }
92
+ }
93
+
94
+ function validStoreSegment(value: unknown): value is string {
95
+ return typeof value === "string"
96
+ && value.length > 0
97
+ && !value.includes(STORE_IDENTITY_SEPARATOR);
98
+ }
99
+
100
+ /**
101
+ * Assert a non-empty namespace array of non-empty segments without the
102
+ * reserved separator.
103
+ *
104
+ * @param namespace - Candidate namespace path.
105
+ * @throws {StoreValidationError} When the namespace is malformed.
106
+ */
107
+ export function assertStoreIdentity(namespace: unknown, key: unknown): asserts namespace is string[] {
108
+ if (!Array.isArray(namespace) || namespace.length === 0) {
109
+ throw new StoreValidationError("namespace must be a non-empty string array");
110
+ }
111
+ for (const segment of namespace) {
112
+ if (!validStoreSegment(segment)) {
113
+ throw new StoreValidationError(
114
+ "namespace segments must be non-empty strings without the reserved separator",
115
+ );
116
+ }
117
+ }
118
+ if (!validStoreSegment(key)) {
119
+ throw new StoreValidationError(
120
+ "key must be a non-empty string without the reserved separator",
121
+ );
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Assert a namespace prefix (which may be empty for "all namespaces").
127
+ *
128
+ * @param prefix - Candidate prefix path.
129
+ * @throws {StoreValidationError} When any segment is malformed.
130
+ */
131
+ export function assertStoreNamespacePrefix(prefix: unknown): asserts prefix is string[] {
132
+ if (!Array.isArray(prefix)) {
133
+ throw new StoreValidationError("namespace prefix must be a string array");
134
+ }
135
+ for (const segment of prefix) {
136
+ if (!validStoreSegment(segment)) {
137
+ throw new StoreValidationError(
138
+ "namespace segments must be non-empty strings without the reserved separator",
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Validate and compute an expiry date from `ttlMinutes`.
146
+ *
147
+ * @param ttlMinutes - Optional positive finite TTL in minutes.
148
+ * @param now - Base time (default current time).
149
+ * @returns The expiry date, or `undefined` when no TTL was supplied.
150
+ * @throws {StoreValidationError} When the TTL is invalid or out of range.
151
+ */
152
+ export function storeExpiresAt(
153
+ ttlMinutes: number | undefined,
154
+ now: Date = new Date(),
155
+ ): Date | undefined {
156
+ if (ttlMinutes === undefined) return undefined;
157
+ if (typeof ttlMinutes !== "number" || !Number.isFinite(ttlMinutes) || ttlMinutes <= 0) {
158
+ throw new StoreValidationError("ttlMinutes must be a positive number");
159
+ }
160
+ const expiresAt = new Date(now.getTime() + ttlMinutes * 60_000);
161
+ if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getUTCFullYear() > 9999) {
162
+ throw new StoreValidationError("ttlMinutes is out of range");
163
+ }
164
+ return expiresAt;
165
+ }
166
+
167
+ /**
168
+ * Validate a non-negative safe-integer pagination value.
169
+ *
170
+ * @param value - Candidate limit/offset.
171
+ * @param fallback - Value used when omitted.
172
+ * @returns The validated number.
173
+ * @throws {StoreValidationError} When the value is not a non-negative safe integer.
174
+ */
175
+ export function storePaginationValue(value: number | undefined, fallback: number): number {
176
+ if (value === undefined) return fallback;
177
+ if (!Number.isSafeInteger(value) || value < 0) {
178
+ throw new StoreValidationError("limit/offset must be a non-negative safe integer");
179
+ }
180
+ return value;
181
+ }
@@ -38,6 +38,13 @@ export interface Thread {
38
38
  * Thread last update timestamp
39
39
  */
40
40
  updatedAt: Date;
41
+
42
+ /**
43
+ * Optional expiry timestamp used by the thread TTL sweeper.
44
+ *
45
+ * @remarks Absent means the thread never expires.
46
+ */
47
+ expiresAt?: Date;
41
48
  }
42
49
 
43
50
  /**
@@ -48,6 +55,14 @@ export interface CreateThreadRequest {
48
55
  * Thread metadata
49
56
  */
50
57
  metadata?: Record<string, any>;
58
+
59
+ /**
60
+ * Optional thread expiry timestamp.
61
+ *
62
+ * @remarks Routes compute this from `ttlSeconds`/`expiresAt`; the store only
63
+ * persists it.
64
+ */
65
+ expiresAt?: Date;
51
66
  }
52
67
 
53
68
  /**
@@ -123,4 +138,13 @@ export interface ThreadStore {
123
138
  * @returns true if thread exists, false otherwise
124
139
  */
125
140
  hasThread(tenantId: string, threadId: string): Promise<boolean>;
141
+
142
+ /**
143
+ * List expired threads across all tenants.
144
+ *
145
+ * @param now Current time; threads with `expiresAt <= now` are returned.
146
+ * @param limit Maximum number of threads to return.
147
+ * @returns Expired threads ordered by `expiresAt` ascending.
148
+ */
149
+ listExpiredThreads(now: Date, limit: number): Promise<Thread[]>;
126
150
  }
@@ -17,6 +17,15 @@ export interface VectorStoreCreateParams {
17
17
  tableName: string;
18
18
  /** Embedding model key to use for embedding operations */
19
19
  embeddingKey: string;
20
+ /**
21
+ * Tenant whose embedding model the key belongs to.
22
+ *
23
+ * Tenant model keys are `${providerId}/${modelId}` and resolve per tenant, so
24
+ * a provider cannot look one up without knowing whose it is. Omitted means the
25
+ * deployment namespace (`"default"`), which is where boot-time registrations
26
+ * live.
27
+ */
28
+ tenantId?: string;
20
29
  /** Provider-specific configuration */
21
30
  config?: Record<string, unknown>;
22
31
  }
@@ -0,0 +1,148 @@
1
+ import {
2
+ a2aEventCursorSchema,
3
+ interruptAnswerKeySchema,
4
+ runCancelSignalSchema,
5
+ } from "../RunCancelProtocol";
6
+ import type {
7
+ A2AEventCursor,
8
+ InterruptAnswerKey,
9
+ RunCancelSignal,
10
+ } from "../RunCancelProtocol";
11
+ import {
12
+ a2aEventCursorSchema as a2aEventCursorSchemaFromIndex,
13
+ interruptAnswerKeySchema as interruptAnswerKeySchemaFromIndex,
14
+ runCancelSignalSchema as runCancelSignalSchemaFromIndex,
15
+ } from "../index";
16
+
17
+ const MAX_FIELD_LENGTH = 64 * 1024;
18
+
19
+ const validCancelSignal: RunCancelSignal = {
20
+ commandId: "command-1",
21
+ tenantId: "tenant-1",
22
+ assistantId: "assistant-1",
23
+ threadId: "thread-1",
24
+ runId: "run-1",
25
+ reason: "abort",
26
+ createdAt: "2026-10-20T00:00:00.000Z",
27
+ };
28
+
29
+ const validInterruptAnswerKey: InterruptAnswerKey = {
30
+ tenantId: "tenant-1",
31
+ assistantId: "assistant-1",
32
+ threadId: "thread-1",
33
+ interruptId: "interrupt-1",
34
+ };
35
+
36
+ const validA2AEventCursor: A2AEventCursor = {
37
+ taskId: "task-1",
38
+ lastEventId: "event-1",
39
+ updatedAt: "2026-10-20T00:00:00.000Z",
40
+ };
41
+
42
+ describe("runCancelSignalSchema", () => {
43
+ it("round-trips a valid cancellation signal", () => {
44
+ expect(runCancelSignalSchema.parse(validCancelSignal)).toEqual(validCancelSignal);
45
+ });
46
+
47
+ it("accepts every supported reason", () => {
48
+ for (const reason of ["abort", "a2a.cancel", "shutdown"] as const) {
49
+ expect(runCancelSignalSchema.parse({ ...validCancelSignal, reason }).reason).toBe(reason);
50
+ }
51
+ });
52
+
53
+ it.each(["commandId", "tenantId", "assistantId", "threadId", "runId", "createdAt"] as const)(
54
+ "rejects a signal missing %s",
55
+ (field) => {
56
+ const invalid: Record<string, unknown> = { ...validCancelSignal };
57
+ delete invalid[field];
58
+
59
+ expect(runCancelSignalSchema.safeParse(invalid).success).toBe(false);
60
+ },
61
+ );
62
+
63
+ it.each(["commandId", "tenantId", "threadId", "runId"] as const)(
64
+ "rejects an empty %s",
65
+ (field) => {
66
+ expect(runCancelSignalSchema.safeParse({ ...validCancelSignal, [field]: "" }).success).toBe(false);
67
+ },
68
+ );
69
+
70
+ it("rejects an unknown reason", () => {
71
+ expect(runCancelSignalSchema.safeParse({ ...validCancelSignal, reason: "restart" }).success).toBe(false);
72
+ });
73
+
74
+ it("rejects an oversized field", () => {
75
+ const oversized = "x".repeat(MAX_FIELD_LENGTH + 1);
76
+
77
+ expect(runCancelSignalSchema.safeParse({ ...validCancelSignal, runId: oversized }).success).toBe(false);
78
+ expect(runCancelSignalSchema.safeParse({ ...validCancelSignal, createdAt: oversized }).success).toBe(false);
79
+ });
80
+
81
+ it("rejects unknown keys", () => {
82
+ expect(runCancelSignalSchema.safeParse({ ...validCancelSignal, extra: true }).success).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("interruptAnswerKeySchema", () => {
87
+ it("round-trips a valid interrupt answer key", () => {
88
+ expect(interruptAnswerKeySchema.parse(validInterruptAnswerKey)).toEqual(validInterruptAnswerKey);
89
+ });
90
+
91
+ it.each(["tenantId", "assistantId", "threadId", "interruptId"] as const)(
92
+ "rejects a key missing %s",
93
+ (field) => {
94
+ const invalid: Record<string, unknown> = { ...validInterruptAnswerKey };
95
+ delete invalid[field];
96
+
97
+ expect(interruptAnswerKeySchema.safeParse(invalid).success).toBe(false);
98
+ },
99
+ );
100
+
101
+ it("rejects an oversized field", () => {
102
+ const oversized = "x".repeat(MAX_FIELD_LENGTH + 1);
103
+
104
+ expect(interruptAnswerKeySchema.safeParse({ ...validInterruptAnswerKey, interruptId: oversized }).success)
105
+ .toBe(false);
106
+ });
107
+
108
+ it("rejects unknown keys", () => {
109
+ expect(interruptAnswerKeySchema.safeParse({ ...validInterruptAnswerKey, runId: "run-1" }).success)
110
+ .toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("a2aEventCursorSchema", () => {
115
+ it("round-trips a valid A2A event cursor", () => {
116
+ expect(a2aEventCursorSchema.parse(validA2AEventCursor)).toEqual(validA2AEventCursor);
117
+ });
118
+
119
+ it.each(["taskId", "lastEventId", "updatedAt"] as const)(
120
+ "rejects a cursor missing %s",
121
+ (field) => {
122
+ const invalid: Record<string, unknown> = { ...validA2AEventCursor };
123
+ delete invalid[field];
124
+
125
+ expect(a2aEventCursorSchema.safeParse(invalid).success).toBe(false);
126
+ },
127
+ );
128
+
129
+ it("rejects an oversized field", () => {
130
+ const oversized = "x".repeat(MAX_FIELD_LENGTH + 1);
131
+
132
+ expect(a2aEventCursorSchema.safeParse({ ...validA2AEventCursor, lastEventId: oversized }).success)
133
+ .toBe(false);
134
+ });
135
+
136
+ it("rejects unknown keys", () => {
137
+ expect(a2aEventCursorSchema.safeParse({ ...validA2AEventCursor, tenantId: "tenant-1" }).success)
138
+ .toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("package exports", () => {
143
+ it("exposes the contracts from the package entry point", () => {
144
+ expect(runCancelSignalSchemaFromIndex).toBe(runCancelSignalSchema);
145
+ expect(interruptAnswerKeySchemaFromIndex).toBe(interruptAnswerKeySchema);
146
+ expect(a2aEventCursorSchemaFromIndex).toBe(a2aEventCursorSchema);
147
+ });
148
+ });
@@ -140,6 +140,8 @@ const errorCodes = [
140
140
  "THREAD_NOT_FOUND",
141
141
  "STREAM_CONFLICT",
142
142
  "STREAM_FAILED",
143
+ "RUN_CANCEL_UNAVAILABLE",
144
+ "RUN_CANCEL_STORE_UNAVAILABLE",
143
145
  "INVALID_REQUEST",
144
146
  "INTERNAL_ERROR",
145
147
  ] as const satisfies readonly AgentWebAppErrorCode[];
@@ -309,6 +311,8 @@ describe("Agent Web App protocol types", () => {
309
311
  "THREAD_NOT_FOUND",
310
312
  "STREAM_CONFLICT",
311
313
  "STREAM_FAILED",
314
+ "RUN_CANCEL_UNAVAILABLE",
315
+ "RUN_CANCEL_STORE_UNAVAILABLE",
312
316
  "INVALID_REQUEST",
313
317
  "INTERNAL_ERROR",
314
318
  ]);
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./STTModelLatticeProtocol";
17
17
  export * from "./VectorStoreLatticeProtocol";
18
18
  export * from "./LoggerLatticeProtocol";
19
19
  export * from "./MessageProtocol";
20
+ export * from "./AgentInterruptProtocol";
20
21
  export * from "./ThreadStoreProtocol";
21
22
  export * from "./AssistantStoreProtocol";
22
23
  export * from "./SkillLatticeProtocol";
@@ -62,6 +63,9 @@ export * from "./UserPushSubscriptionStoreProtocol";
62
63
  export * from "./PluginDeploymentStoreProtocol";
63
64
  export * from "./LeaseStoreProtocol";
64
65
  export * from "./OutboxStoreProtocol";
66
+ export * from "./CheckpointStorageProtocol";
67
+ export * from "./StoreProtocol";
68
+ export * from "./ChannelReplyJobProtocol";
65
69
  export * from "./ExactDataSnapshot";
66
70
  export * from "./ProjectRoomRealtimeProtocol";
67
71
 
@@ -98,6 +102,7 @@ export * from "./SandboxPluginProtocol";
98
102
  export * from "./types";
99
103
  export * from "./TrustedRunContextProtocol";
100
104
  export * from "./RunScope";
105
+ export * from "./RunCancelProtocol";
101
106
  export * from "./AgentAffinity";
102
107
  export * from "./OpenProtocol";
103
108
  export * from "./McpUiProtocol";