@intx/inference 0.1.2 → 0.3.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/LICENSE +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +40 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +75 -0
- package/dist/assembly.js +133 -0
- package/dist/audit-collector.d.ts +10 -0
- package/dist/audit-collector.js +139 -0
- package/dist/auth.d.ts +24 -0
- package/{src/auth.ts → dist/auth.js} +13 -19
- package/dist/authz-extension.d.ts +46 -0
- package/dist/authz-extension.js +184 -0
- package/dist/correlation.d.ts +26 -0
- package/dist/correlation.js +39 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +228 -0
- package/dist/director.d.ts +6 -0
- package/dist/director.js +56 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +83 -0
- package/dist/gates.d.ts +28 -0
- package/dist/gates.js +103 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1407 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +21 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +44 -0
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.js +917 -0
- package/dist/providers/google-genai-files.d.ts +48 -0
- package/dist/providers/google-genai-files.js +205 -0
- package/dist/providers/google-genai.d.ts +5 -0
- package/dist/providers/google-genai.js +1205 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.js +903 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +1233 -0
- package/dist/retry-policy.d.ts +31 -0
- package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +63 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +100 -0
- package/dist/tool-name.d.ts +6 -0
- package/dist/tool-name.js +110 -0
- package/dist/transform.d.ts +11 -0
- package/dist/transform.js +132 -0
- package/dist/transforms/index.d.ts +2 -0
- package/dist/transforms/index.js +1 -0
- package/dist/transforms/size-cap.d.ts +12 -0
- package/dist/transforms/size-cap.js +80 -0
- package/dist/turns.d.ts +21 -0
- package/dist/turns.js +135 -0
- package/package.json +22 -6
- package/src/actions.ts +0 -245
- package/src/adapter.ts +0 -57
- package/src/assembly.test.ts +0 -728
- package/src/assembly.ts +0 -250
- package/src/audit-collector.test.ts +0 -332
- package/src/audit-collector.ts +0 -172
- package/src/auth.test.ts +0 -117
- package/src/authz-extension.test.ts +0 -269
- package/src/authz-extension.ts +0 -145
- package/src/correlation.ts +0 -61
- package/src/default-director.test.ts +0 -314
- package/src/default-director.ts +0 -344
- package/src/director.ts +0 -87
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -115
- package/src/gates.ts +0 -128
- package/src/harness.test.ts +0 -655
- package/src/harness.ts +0 -1571
- package/src/index.ts +0 -76
- package/src/providers/anthropic.test.ts +0 -771
- package/src/providers/anthropic.ts +0 -810
- package/src/providers/google-genai-files.ts +0 -289
- package/src/providers/google-genai.ts +0 -1518
- package/src/providers/openai.ts +0 -719
- package/src/providers/registry.ts +0 -33
- package/src/reactor.test.ts +0 -3660
- package/src/reactor.ts +0 -1058
- package/src/scheduler.test.ts +0 -41
- package/src/sse.test.ts +0 -133
- package/src/sse.ts +0 -76
- package/src/state.ts +0 -135
- package/src/transform.test.ts +0 -207
- package/src/transform.ts +0 -159
- package/src/transforms/index.ts +0 -2
- package/src/transforms/size-cap.test.ts +0 -172
- package/src/transforms/size-cap.ts +0 -110
- package/src/turns.ts +0 -54
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/src/assembly.test.ts
DELETED
|
@@ -1,728 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { createReactorAssembly } from "./assembly";
|
|
4
|
-
import { createInboundMessage } from "@intx/mime";
|
|
5
|
-
import type { AuthzCallResult } from "./authz-extension";
|
|
6
|
-
import type { ReactorEmittedEvent } from "./reactor";
|
|
7
|
-
|
|
8
|
-
import type {
|
|
9
|
-
AuditStore,
|
|
10
|
-
BeforeToolExtension,
|
|
11
|
-
ContextCommit,
|
|
12
|
-
ContextStore,
|
|
13
|
-
ConversationTurn,
|
|
14
|
-
InboundMessage,
|
|
15
|
-
InferenceSource,
|
|
16
|
-
ReactorCapabilities,
|
|
17
|
-
ReactorDirector,
|
|
18
|
-
ReactorState,
|
|
19
|
-
TokenUsage,
|
|
20
|
-
ToolResultTransform,
|
|
21
|
-
ToolRunner,
|
|
22
|
-
} from "@intx/types/runtime";
|
|
23
|
-
|
|
24
|
-
import type { AuditRecord } from "@intx/types/audit";
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Fakes
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
function emptyUsage(): TokenUsage {
|
|
31
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
type RecordingContextStore = ContextStore & {
|
|
35
|
-
blobs: Map<string, { bytes: Uint8Array; contentType: string | undefined }>;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function makeContextStore(
|
|
39
|
-
turns: ConversationTurn[] = [],
|
|
40
|
-
): RecordingContextStore {
|
|
41
|
-
const blobs = new Map<
|
|
42
|
-
string,
|
|
43
|
-
{ bytes: Uint8Array; contentType: string | undefined }
|
|
44
|
-
>();
|
|
45
|
-
async function commit(
|
|
46
|
-
options: { message: string },
|
|
47
|
-
_signal?: AbortSignal,
|
|
48
|
-
): Promise<ContextCommit> {
|
|
49
|
-
return { hash: "abc", message: options.message, timestamp: Date.now() };
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
blobs,
|
|
53
|
-
async load() {
|
|
54
|
-
return {
|
|
55
|
-
turns,
|
|
56
|
-
pendingOperations: [],
|
|
57
|
-
tokenUsage: emptyUsage(),
|
|
58
|
-
connectorState: null,
|
|
59
|
-
};
|
|
60
|
-
},
|
|
61
|
-
setConnectorState() {
|
|
62
|
-
/* noop */
|
|
63
|
-
},
|
|
64
|
-
commit,
|
|
65
|
-
async branch() {
|
|
66
|
-
/* noop */
|
|
67
|
-
},
|
|
68
|
-
async log() {
|
|
69
|
-
return [];
|
|
70
|
-
},
|
|
71
|
-
async readAt() {
|
|
72
|
-
return [];
|
|
73
|
-
},
|
|
74
|
-
async writeBlob(key, bytes, contentType) {
|
|
75
|
-
blobs.set(key, { bytes, contentType });
|
|
76
|
-
},
|
|
77
|
-
async readBlob(key) {
|
|
78
|
-
const entry = blobs.get(key);
|
|
79
|
-
if (entry === undefined) {
|
|
80
|
-
throw new Error(`no blob for key ${key}`);
|
|
81
|
-
}
|
|
82
|
-
return entry.bytes;
|
|
83
|
-
},
|
|
84
|
-
async writePrompt() {
|
|
85
|
-
/* noop */
|
|
86
|
-
},
|
|
87
|
-
async writeResponse() {
|
|
88
|
-
/* noop */
|
|
89
|
-
},
|
|
90
|
-
async writeManifest() {
|
|
91
|
-
/* noop */
|
|
92
|
-
},
|
|
93
|
-
async writeTurns() {
|
|
94
|
-
/* noop */
|
|
95
|
-
},
|
|
96
|
-
async writeMetadata() {
|
|
97
|
-
/* noop */
|
|
98
|
-
},
|
|
99
|
-
async readManifestHistory() {
|
|
100
|
-
return [];
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function makeToolRunner(content: string | Record<string, unknown>): ToolRunner {
|
|
106
|
-
return {
|
|
107
|
-
async run(call) {
|
|
108
|
-
return { callId: call.id, content };
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function makeInboundMessage(): InboundMessage {
|
|
114
|
-
return createInboundMessage({
|
|
115
|
-
from: "test@example.com",
|
|
116
|
-
to: "agent@example.com",
|
|
117
|
-
content: "hello",
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Director that executes a single tool call on message.received and ends on
|
|
122
|
-
// tool.done, optionally with a checkpoint to trigger afterCheckpoint flush.
|
|
123
|
-
function makeToolExecDirector(
|
|
124
|
-
toolName: string,
|
|
125
|
-
toolArgs: Record<string, unknown> = {},
|
|
126
|
-
options: { checkpoint?: boolean; callId?: string } = {},
|
|
127
|
-
): ReactorDirector {
|
|
128
|
-
const callId = options.callId ?? `call-${toolName}`;
|
|
129
|
-
return {
|
|
130
|
-
async decide(
|
|
131
|
-
event: { type: string },
|
|
132
|
-
_state: ReactorState,
|
|
133
|
-
caps: ReactorCapabilities,
|
|
134
|
-
) {
|
|
135
|
-
if (event.type === "message.received") {
|
|
136
|
-
return caps.executeTools([
|
|
137
|
-
{ id: callId, name: toolName, arguments: toolArgs },
|
|
138
|
-
]);
|
|
139
|
-
}
|
|
140
|
-
if (event.type === "tool.done") {
|
|
141
|
-
if (options.checkpoint === true) {
|
|
142
|
-
return [caps.checkpoint(), caps.done()];
|
|
143
|
-
}
|
|
144
|
-
return caps.done();
|
|
145
|
-
}
|
|
146
|
-
return caps.done();
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function waitForDone(events: ReactorEmittedEvent[]): Promise<void> {
|
|
152
|
-
return new Promise((resolve, reject) => {
|
|
153
|
-
const deadline = setTimeout(
|
|
154
|
-
() => reject(new Error("Timed out waiting for reactor.done")),
|
|
155
|
-
5000,
|
|
156
|
-
);
|
|
157
|
-
const check = () => {
|
|
158
|
-
if (events.some((e) => e.type === "reactor.done")) {
|
|
159
|
-
clearTimeout(deadline);
|
|
160
|
-
resolve();
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
setTimeout(check, 10);
|
|
164
|
-
};
|
|
165
|
-
check();
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function source(): InferenceSource {
|
|
170
|
-
return {
|
|
171
|
-
id: "anthropic:test-model",
|
|
172
|
-
provider: "anthropic",
|
|
173
|
-
baseURL: "https://api.anthropic.com",
|
|
174
|
-
apiKey: "test",
|
|
175
|
-
model: "test-model",
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function makeRecordingAuditStore(): AuditStore & {
|
|
180
|
-
getCommitted(): AuditRecord[][];
|
|
181
|
-
} {
|
|
182
|
-
const committed: AuditRecord[][] = [];
|
|
183
|
-
return {
|
|
184
|
-
async commitAudit(records: AuditRecord[]) {
|
|
185
|
-
committed.push([...records]);
|
|
186
|
-
},
|
|
187
|
-
async loadAudit() {
|
|
188
|
-
return committed.flat();
|
|
189
|
-
},
|
|
190
|
-
async commitErrors() {
|
|
191
|
-
/* noop */
|
|
192
|
-
},
|
|
193
|
-
getCommitted() {
|
|
194
|
-
return committed;
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function allowAuthorize(): Promise<AuthzCallResult> {
|
|
200
|
-
return Promise.resolve({
|
|
201
|
-
effect: "allow" as const,
|
|
202
|
-
matchingGrants: [],
|
|
203
|
-
resolvedBy: null,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// A recording before-tool extension that captures every call and the order in
|
|
208
|
-
// which it ran relative to peers via the shared trace array.
|
|
209
|
-
function makeRecordingExtension(
|
|
210
|
-
name: string,
|
|
211
|
-
trace: string[],
|
|
212
|
-
): BeforeToolExtension {
|
|
213
|
-
return {
|
|
214
|
-
async beforeTool(_call) {
|
|
215
|
-
trace.push(name);
|
|
216
|
-
return undefined;
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// A recording tool-result transform that captures the order of invocation.
|
|
222
|
-
function makeRecordingTransform(
|
|
223
|
-
name: string,
|
|
224
|
-
trace: string[],
|
|
225
|
-
): ToolResultTransform {
|
|
226
|
-
return {
|
|
227
|
-
name,
|
|
228
|
-
version: "1",
|
|
229
|
-
async apply(input) {
|
|
230
|
-
trace.push(name);
|
|
231
|
-
return {
|
|
232
|
-
output: input.result,
|
|
233
|
-
record: {
|
|
234
|
-
strategy: name,
|
|
235
|
-
version: "1",
|
|
236
|
-
parameters: {},
|
|
237
|
-
reason: "noop",
|
|
238
|
-
decisions: {},
|
|
239
|
-
},
|
|
240
|
-
};
|
|
241
|
-
},
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function collectEvents(): {
|
|
246
|
-
events: ReactorEmittedEvent[];
|
|
247
|
-
onEvent: (e: ReactorEmittedEvent) => void;
|
|
248
|
-
} {
|
|
249
|
-
const events: ReactorEmittedEvent[] = [];
|
|
250
|
-
return { events, onEvent: (e) => events.push(e) };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
// Tests
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
|
|
257
|
-
describe("createReactorAssembly", () => {
|
|
258
|
-
test("default size-cap transform is the first tool-result transform when no caller transforms are provided", async () => {
|
|
259
|
-
// We observe the size-cap transform via its side effect: a large tool
|
|
260
|
-
// result spills to ContextStore.writeBlob. With no caller transforms,
|
|
261
|
-
// the helper-supplied size-cap is the only transform and the spill must
|
|
262
|
-
// appear in the blob store.
|
|
263
|
-
const contextStore = makeContextStore();
|
|
264
|
-
const events = collectEvents();
|
|
265
|
-
const big = "x".repeat(200);
|
|
266
|
-
|
|
267
|
-
const { reactor } = createReactorAssembly({
|
|
268
|
-
sessionId: "s1",
|
|
269
|
-
director: makeToolExecDirector("t", {}, { callId: "c1" }),
|
|
270
|
-
source: source(),
|
|
271
|
-
toolRunner: makeToolRunner(big),
|
|
272
|
-
contextStore,
|
|
273
|
-
onEvent: events.onEvent,
|
|
274
|
-
sizeCapMaxChars: 50,
|
|
275
|
-
shutdownTimeoutMs: 100,
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
reactor.start();
|
|
279
|
-
reactor.deliver(makeInboundMessage());
|
|
280
|
-
await waitForDone(events.events);
|
|
281
|
-
|
|
282
|
-
const spill = contextStore.blobs.get("c1");
|
|
283
|
-
expect(spill).toBeDefined();
|
|
284
|
-
if (spill === undefined) throw new Error("expected spill");
|
|
285
|
-
expect(new TextDecoder().decode(spill.bytes)).toBe(big);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
test("caller's toolResultTransforms run after the default size-cap transform (order preserved)", async () => {
|
|
289
|
-
// Drive an over-cap result. The caller's first transform should observe
|
|
290
|
-
// an already-truncated payload (size-cap ran first), and both caller
|
|
291
|
-
// transforms should run in the order supplied.
|
|
292
|
-
const trace: string[] = [];
|
|
293
|
-
const seen: string[] = [];
|
|
294
|
-
const captureTransform: ToolResultTransform = {
|
|
295
|
-
name: "capture",
|
|
296
|
-
version: "1",
|
|
297
|
-
async apply(input) {
|
|
298
|
-
trace.push("capture");
|
|
299
|
-
seen.push(
|
|
300
|
-
typeof input.result.content === "string"
|
|
301
|
-
? input.result.content.slice(0, 5)
|
|
302
|
-
: "non-string",
|
|
303
|
-
);
|
|
304
|
-
return {
|
|
305
|
-
output: input.result,
|
|
306
|
-
record: {
|
|
307
|
-
strategy: "capture",
|
|
308
|
-
version: "1",
|
|
309
|
-
parameters: {},
|
|
310
|
-
reason: "noop",
|
|
311
|
-
decisions: {},
|
|
312
|
-
},
|
|
313
|
-
};
|
|
314
|
-
},
|
|
315
|
-
};
|
|
316
|
-
const tailTransform = makeRecordingTransform("tail", trace);
|
|
317
|
-
|
|
318
|
-
const contextStore = makeContextStore();
|
|
319
|
-
const events = collectEvents();
|
|
320
|
-
const big = "y".repeat(200);
|
|
321
|
-
|
|
322
|
-
const { reactor } = createReactorAssembly({
|
|
323
|
-
sessionId: "s2",
|
|
324
|
-
director: makeToolExecDirector("t", {}, { callId: "c2" }),
|
|
325
|
-
source: source(),
|
|
326
|
-
toolRunner: makeToolRunner(big),
|
|
327
|
-
contextStore,
|
|
328
|
-
onEvent: events.onEvent,
|
|
329
|
-
sizeCapMaxChars: 20,
|
|
330
|
-
toolResultTransforms: [captureTransform, tailTransform],
|
|
331
|
-
shutdownTimeoutMs: 100,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
reactor.start();
|
|
335
|
-
reactor.deliver(makeInboundMessage());
|
|
336
|
-
await waitForDone(events.events);
|
|
337
|
-
|
|
338
|
-
expect(trace).toEqual(["capture", "tail"]);
|
|
339
|
-
// The captured prefix is from `big`, confirming the caller transform
|
|
340
|
-
// saw the size-cap's truncated marker (which begins with the kept
|
|
341
|
-
// characters from `big`).
|
|
342
|
-
expect(seen[0]).toBe("yyyyy");
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
test("custom sizeCapMaxChars flows into the size-cap transform", async () => {
|
|
346
|
-
// Drive an over-cap result with maxChars=10 and assert the spilled blob's
|
|
347
|
-
// bytes equal the original content (size-cap always spills the full
|
|
348
|
-
// payload when it caps).
|
|
349
|
-
const contextStore = makeContextStore();
|
|
350
|
-
const events = collectEvents();
|
|
351
|
-
const payload = "z".repeat(50);
|
|
352
|
-
|
|
353
|
-
const { reactor } = createReactorAssembly({
|
|
354
|
-
sessionId: "s3",
|
|
355
|
-
director: makeToolExecDirector("t", {}, { callId: "c3" }),
|
|
356
|
-
source: source(),
|
|
357
|
-
toolRunner: makeToolRunner(payload),
|
|
358
|
-
contextStore,
|
|
359
|
-
onEvent: events.onEvent,
|
|
360
|
-
sizeCapMaxChars: 10,
|
|
361
|
-
shutdownTimeoutMs: 100,
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
reactor.start();
|
|
365
|
-
reactor.deliver(makeInboundMessage());
|
|
366
|
-
await waitForDone(events.events);
|
|
367
|
-
|
|
368
|
-
const spill = contextStore.blobs.get("c3");
|
|
369
|
-
expect(spill).toBeDefined();
|
|
370
|
-
if (spill === undefined) throw new Error("expected spill");
|
|
371
|
-
expect(spill.bytes.byteLength).toBe(50);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
test("with authorize set, an authz before-tool extension is composed in front of caller's beforeToolExtensions", async () => {
|
|
375
|
-
// No caller extensions: a deny authorize should still block the call by
|
|
376
|
-
// virtue of the assembly-built authz extension running first.
|
|
377
|
-
const contextStore = makeContextStore();
|
|
378
|
-
const events = collectEvents();
|
|
379
|
-
const auditStore = makeRecordingAuditStore();
|
|
380
|
-
|
|
381
|
-
const { reactor } = createReactorAssembly({
|
|
382
|
-
sessionId: "s4",
|
|
383
|
-
director: makeToolExecDirector("forbidden", {}, { callId: "c4" }),
|
|
384
|
-
source: source(),
|
|
385
|
-
toolRunner: makeToolRunner("never runs"),
|
|
386
|
-
contextStore,
|
|
387
|
-
onEvent: events.onEvent,
|
|
388
|
-
authorize: () =>
|
|
389
|
-
Promise.resolve({
|
|
390
|
-
effect: "deny" as const,
|
|
391
|
-
matchingGrants: [],
|
|
392
|
-
resolvedBy: null,
|
|
393
|
-
}),
|
|
394
|
-
auditStore,
|
|
395
|
-
shutdownTimeoutMs: 100,
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
reactor.start();
|
|
399
|
-
reactor.deliver(makeInboundMessage());
|
|
400
|
-
await waitForDone(events.events);
|
|
401
|
-
|
|
402
|
-
const records = auditStore.getCommitted().flat();
|
|
403
|
-
expect(records.length).toBe(1);
|
|
404
|
-
const record = records[0];
|
|
405
|
-
if (record === undefined) throw new Error("expected record");
|
|
406
|
-
expect(record.tool).toBe("forbidden");
|
|
407
|
-
if (record.authz === null) throw new Error("expected authz");
|
|
408
|
-
expect(record.authz.blocked).toBe(true);
|
|
409
|
-
expect(record.authz.effect).toBe("deny");
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
test("with authorize + caller's beforeToolExtensions, authz runs first; caller's extensions follow in order", async () => {
|
|
413
|
-
// The authz extension allows; the recording extension records that it
|
|
414
|
-
// saw the call. If authz had been ordered after the recording extension,
|
|
415
|
-
// a deny would still block — but we want to verify ordering specifically,
|
|
416
|
-
// so we use allow and assert the recording extension still ran (proves
|
|
417
|
-
// authz didn't block) and we observe the order via the trace.
|
|
418
|
-
const contextStore = makeContextStore();
|
|
419
|
-
const events = collectEvents();
|
|
420
|
-
const trace: string[] = [];
|
|
421
|
-
const recordingExt = makeRecordingExtension("caller-ext", trace);
|
|
422
|
-
|
|
423
|
-
let authzRan = false;
|
|
424
|
-
const authorize = (): Promise<AuthzCallResult> => {
|
|
425
|
-
// Authz runs synchronously here; appending before the await guarantees
|
|
426
|
-
// ordering relative to the caller-ext (which appends in beforeTool).
|
|
427
|
-
authzRan = true;
|
|
428
|
-
trace.push("authz");
|
|
429
|
-
return Promise.resolve({
|
|
430
|
-
effect: "allow" as const,
|
|
431
|
-
matchingGrants: [],
|
|
432
|
-
resolvedBy: null,
|
|
433
|
-
});
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
const { reactor } = createReactorAssembly({
|
|
437
|
-
sessionId: "s5",
|
|
438
|
-
director: makeToolExecDirector("t", {}, { callId: "c5" }),
|
|
439
|
-
source: source(),
|
|
440
|
-
toolRunner: makeToolRunner("ok"),
|
|
441
|
-
contextStore,
|
|
442
|
-
onEvent: events.onEvent,
|
|
443
|
-
authorize,
|
|
444
|
-
beforeToolExtensions: [recordingExt],
|
|
445
|
-
shutdownTimeoutMs: 100,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
reactor.start();
|
|
449
|
-
reactor.deliver(makeInboundMessage());
|
|
450
|
-
await waitForDone(events.events);
|
|
451
|
-
|
|
452
|
-
expect(authzRan).toBe(true);
|
|
453
|
-
expect(trace).toEqual(["authz", "caller-ext"]);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
test("with auditStore set, an audit collector is returned and afterCheckpoint flushes records to the store", async () => {
|
|
457
|
-
const contextStore = makeContextStore();
|
|
458
|
-
const events = collectEvents();
|
|
459
|
-
const auditStore = makeRecordingAuditStore();
|
|
460
|
-
|
|
461
|
-
const { reactor, auditCollector } = createReactorAssembly({
|
|
462
|
-
sessionId: "s6",
|
|
463
|
-
director: makeToolExecDirector(
|
|
464
|
-
"t",
|
|
465
|
-
{ k: "v" },
|
|
466
|
-
{
|
|
467
|
-
checkpoint: true,
|
|
468
|
-
callId: "c6",
|
|
469
|
-
},
|
|
470
|
-
),
|
|
471
|
-
source: source(),
|
|
472
|
-
toolRunner: makeToolRunner("ok"),
|
|
473
|
-
contextStore,
|
|
474
|
-
onEvent: events.onEvent,
|
|
475
|
-
auditStore,
|
|
476
|
-
authorize: () => allowAuthorize(),
|
|
477
|
-
shutdownTimeoutMs: 100,
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
expect(auditCollector).toBeDefined();
|
|
481
|
-
|
|
482
|
-
reactor.start();
|
|
483
|
-
reactor.deliver(makeInboundMessage());
|
|
484
|
-
await waitForDone(events.events);
|
|
485
|
-
|
|
486
|
-
const batches = auditStore.getCommitted();
|
|
487
|
-
// Director checkpoints then done — flush happens at checkpoint and again
|
|
488
|
-
// at shutdown. The shutdown flush finds an empty buffer and skips.
|
|
489
|
-
expect(batches.length).toBe(1);
|
|
490
|
-
expect(batches[0]?.length).toBe(1);
|
|
491
|
-
expect(batches[0]?.[0]?.callId).toBe("c6");
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("with auditStore + authorize, authz onDecision decisions appear in the audit collector's flushed records", async () => {
|
|
495
|
-
const contextStore = makeContextStore();
|
|
496
|
-
const events = collectEvents();
|
|
497
|
-
const auditStore = makeRecordingAuditStore();
|
|
498
|
-
|
|
499
|
-
const { reactor } = createReactorAssembly({
|
|
500
|
-
sessionId: "s7",
|
|
501
|
-
director: makeToolExecDirector(
|
|
502
|
-
"t",
|
|
503
|
-
{ x: 1 },
|
|
504
|
-
{
|
|
505
|
-
checkpoint: true,
|
|
506
|
-
callId: "c7",
|
|
507
|
-
},
|
|
508
|
-
),
|
|
509
|
-
source: source(),
|
|
510
|
-
toolRunner: makeToolRunner("ok"),
|
|
511
|
-
contextStore,
|
|
512
|
-
onEvent: events.onEvent,
|
|
513
|
-
auditStore,
|
|
514
|
-
authorize: () => allowAuthorize(),
|
|
515
|
-
shutdownTimeoutMs: 100,
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
reactor.start();
|
|
519
|
-
reactor.deliver(makeInboundMessage());
|
|
520
|
-
await waitForDone(events.events);
|
|
521
|
-
|
|
522
|
-
const records = auditStore.getCommitted().flat();
|
|
523
|
-
expect(records.length).toBe(1);
|
|
524
|
-
const record = records[0];
|
|
525
|
-
if (record === undefined) throw new Error("expected record");
|
|
526
|
-
if (record.authz === null) throw new Error("expected authz on record");
|
|
527
|
-
expect(record.authz.effect).toBe("allow");
|
|
528
|
-
expect(record.authz.blocked).toBe(false);
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
test("caller's afterCheckpoint runs after the helper's audit flush", async () => {
|
|
532
|
-
const contextStore = makeContextStore();
|
|
533
|
-
const events = collectEvents();
|
|
534
|
-
const auditStore = makeRecordingAuditStore();
|
|
535
|
-
|
|
536
|
-
const trace: string[] = [];
|
|
537
|
-
// Wrap commitAudit so we can record the moment of the helper's flush.
|
|
538
|
-
const wrappedAuditStore: AuditStore & { getCommitted(): AuditRecord[][] } =
|
|
539
|
-
{
|
|
540
|
-
...auditStore,
|
|
541
|
-
async commitAudit(records) {
|
|
542
|
-
trace.push("helper-flush");
|
|
543
|
-
await auditStore.commitAudit(records);
|
|
544
|
-
},
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
const { reactor } = createReactorAssembly({
|
|
548
|
-
sessionId: "s8",
|
|
549
|
-
director: makeToolExecDirector(
|
|
550
|
-
"t",
|
|
551
|
-
{},
|
|
552
|
-
{
|
|
553
|
-
checkpoint: true,
|
|
554
|
-
callId: "c8",
|
|
555
|
-
},
|
|
556
|
-
),
|
|
557
|
-
source: source(),
|
|
558
|
-
toolRunner: makeToolRunner("ok"),
|
|
559
|
-
contextStore,
|
|
560
|
-
onEvent: events.onEvent,
|
|
561
|
-
auditStore: wrappedAuditStore,
|
|
562
|
-
authorize: () => allowAuthorize(),
|
|
563
|
-
afterCheckpoint: async () => {
|
|
564
|
-
trace.push("caller-after-checkpoint");
|
|
565
|
-
},
|
|
566
|
-
shutdownTimeoutMs: 100,
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
reactor.start();
|
|
570
|
-
reactor.deliver(makeInboundMessage());
|
|
571
|
-
await waitForDone(events.events);
|
|
572
|
-
|
|
573
|
-
expect(trace).toEqual(["helper-flush", "caller-after-checkpoint"]);
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
test("caller's onShutdown runs after the helper's audit-flush-on-shutdown", async () => {
|
|
577
|
-
const contextStore = makeContextStore();
|
|
578
|
-
const events = collectEvents();
|
|
579
|
-
const auditStore = makeRecordingAuditStore();
|
|
580
|
-
|
|
581
|
-
const trace: string[] = [];
|
|
582
|
-
const wrappedAuditStore: AuditStore & { getCommitted(): AuditRecord[][] } =
|
|
583
|
-
{
|
|
584
|
-
...auditStore,
|
|
585
|
-
async commitAudit(records) {
|
|
586
|
-
trace.push("helper-flush");
|
|
587
|
-
await auditStore.commitAudit(records);
|
|
588
|
-
},
|
|
589
|
-
};
|
|
590
|
-
|
|
591
|
-
// Director that does NOT checkpoint — only the shutdown path flushes.
|
|
592
|
-
const director: ReactorDirector = {
|
|
593
|
-
async decide(
|
|
594
|
-
event: { type: string },
|
|
595
|
-
_state: ReactorState,
|
|
596
|
-
caps: ReactorCapabilities,
|
|
597
|
-
) {
|
|
598
|
-
if (event.type === "message.received") {
|
|
599
|
-
return caps.executeTools([{ id: "c9", name: "t", arguments: {} }]);
|
|
600
|
-
}
|
|
601
|
-
if (event.type === "tool.done") {
|
|
602
|
-
return caps.done();
|
|
603
|
-
}
|
|
604
|
-
return caps.done();
|
|
605
|
-
},
|
|
606
|
-
};
|
|
607
|
-
|
|
608
|
-
const { reactor } = createReactorAssembly({
|
|
609
|
-
sessionId: "s9",
|
|
610
|
-
director,
|
|
611
|
-
source: source(),
|
|
612
|
-
toolRunner: makeToolRunner("ok"),
|
|
613
|
-
contextStore,
|
|
614
|
-
onEvent: events.onEvent,
|
|
615
|
-
auditStore: wrappedAuditStore,
|
|
616
|
-
authorize: () => allowAuthorize(),
|
|
617
|
-
onShutdown: async () => {
|
|
618
|
-
trace.push("caller-shutdown");
|
|
619
|
-
},
|
|
620
|
-
shutdownTimeoutMs: 100,
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
reactor.start();
|
|
624
|
-
reactor.deliver(makeInboundMessage());
|
|
625
|
-
await waitForDone(events.events);
|
|
626
|
-
|
|
627
|
-
expect(trace).toEqual(["helper-flush", "caller-shutdown"]);
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
test("blobReader is wired against the supplied contextStore", async () => {
|
|
631
|
-
const contextStore = makeContextStore();
|
|
632
|
-
const { blobReader } = createReactorAssembly({
|
|
633
|
-
sessionId: "s10",
|
|
634
|
-
director: {
|
|
635
|
-
async decide(_e, _s, caps) {
|
|
636
|
-
return caps.done();
|
|
637
|
-
},
|
|
638
|
-
},
|
|
639
|
-
source: source(),
|
|
640
|
-
toolRunner: makeToolRunner("ok"),
|
|
641
|
-
contextStore,
|
|
642
|
-
onEvent: () => {
|
|
643
|
-
/* noop */
|
|
644
|
-
},
|
|
645
|
-
shutdownTimeoutMs: 100,
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const bytes = new TextEncoder().encode("hello-blob");
|
|
649
|
-
await contextStore.writeBlob("k1", bytes, "text/plain");
|
|
650
|
-
const out = await blobReader.read("tool-output:///k1");
|
|
651
|
-
expect(new TextDecoder().decode(out)).toBe("hello-blob");
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("without auditStore, auditCollector is undefined and afterCheckpoint is only wired when caller provided one", async () => {
|
|
655
|
-
const contextStore = makeContextStore();
|
|
656
|
-
const events = collectEvents();
|
|
657
|
-
let callerAfterCheckpointCalls = 0;
|
|
658
|
-
|
|
659
|
-
const { auditCollector, reactor } = createReactorAssembly({
|
|
660
|
-
sessionId: "s11",
|
|
661
|
-
director: makeToolExecDirector(
|
|
662
|
-
"t",
|
|
663
|
-
{},
|
|
664
|
-
{
|
|
665
|
-
checkpoint: true,
|
|
666
|
-
callId: "c11",
|
|
667
|
-
},
|
|
668
|
-
),
|
|
669
|
-
source: source(),
|
|
670
|
-
toolRunner: makeToolRunner("ok"),
|
|
671
|
-
contextStore,
|
|
672
|
-
onEvent: events.onEvent,
|
|
673
|
-
afterCheckpoint: async () => {
|
|
674
|
-
callerAfterCheckpointCalls++;
|
|
675
|
-
},
|
|
676
|
-
shutdownTimeoutMs: 100,
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
expect(auditCollector).toBeUndefined();
|
|
680
|
-
|
|
681
|
-
reactor.start();
|
|
682
|
-
reactor.deliver(makeInboundMessage());
|
|
683
|
-
await waitForDone(events.events);
|
|
684
|
-
|
|
685
|
-
// Caller's afterCheckpoint still fires — the helper passes it through
|
|
686
|
-
// unchanged when there is no audit collector to chain in front of it.
|
|
687
|
-
expect(callerAfterCheckpointCalls).toBe(1);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
test("without authorize, the reactor's beforeToolExtensions is exactly the caller's array", async () => {
|
|
691
|
-
// Observable assertion: a caller-supplied extension that blocks should
|
|
692
|
-
// produce a blocked tool result, and no authz-supplied extension should
|
|
693
|
-
// be interposed (we'd otherwise see two extension invocations).
|
|
694
|
-
const contextStore = makeContextStore();
|
|
695
|
-
const events = collectEvents();
|
|
696
|
-
const trace: string[] = [];
|
|
697
|
-
const ext: BeforeToolExtension = {
|
|
698
|
-
async beforeTool() {
|
|
699
|
-
trace.push("caller-ext");
|
|
700
|
-
return "blocked-by-caller";
|
|
701
|
-
},
|
|
702
|
-
};
|
|
703
|
-
|
|
704
|
-
const { reactor } = createReactorAssembly({
|
|
705
|
-
sessionId: "s12",
|
|
706
|
-
director: makeToolExecDirector("t", {}, { callId: "c12" }),
|
|
707
|
-
source: source(),
|
|
708
|
-
toolRunner: makeToolRunner("never reached"),
|
|
709
|
-
contextStore,
|
|
710
|
-
onEvent: events.onEvent,
|
|
711
|
-
beforeToolExtensions: [ext],
|
|
712
|
-
shutdownTimeoutMs: 100,
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
reactor.start();
|
|
716
|
-
reactor.deliver(makeInboundMessage());
|
|
717
|
-
await waitForDone(events.events);
|
|
718
|
-
|
|
719
|
-
expect(trace).toEqual(["caller-ext"]);
|
|
720
|
-
const toolDone = events.events.find(
|
|
721
|
-
(e): e is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
|
|
722
|
-
e.type === "tool.done",
|
|
723
|
-
);
|
|
724
|
-
if (toolDone === undefined) throw new Error("expected tool.done");
|
|
725
|
-
expect(toolDone.data.result.isError).toBe(true);
|
|
726
|
-
expect(toolDone.data.result.content).toBe("blocked-by-caller");
|
|
727
|
-
});
|
|
728
|
-
});
|