@intx/inference 0.1.2

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.
Files changed (43) hide show
  1. package/README.md +46 -0
  2. package/package.json +20 -0
  3. package/src/actions.ts +245 -0
  4. package/src/adapter.ts +57 -0
  5. package/src/assembly.test.ts +728 -0
  6. package/src/assembly.ts +250 -0
  7. package/src/audit-collector.test.ts +332 -0
  8. package/src/audit-collector.ts +172 -0
  9. package/src/auth.test.ts +117 -0
  10. package/src/auth.ts +61 -0
  11. package/src/authz-extension.test.ts +269 -0
  12. package/src/authz-extension.ts +145 -0
  13. package/src/correlation.ts +61 -0
  14. package/src/default-director.test.ts +314 -0
  15. package/src/default-director.ts +344 -0
  16. package/src/director.ts +87 -0
  17. package/src/errors.test.ts +133 -0
  18. package/src/errors.ts +115 -0
  19. package/src/gates.ts +128 -0
  20. package/src/harness.test.ts +655 -0
  21. package/src/harness.ts +1571 -0
  22. package/src/index.ts +76 -0
  23. package/src/providers/anthropic.test.ts +771 -0
  24. package/src/providers/anthropic.ts +810 -0
  25. package/src/providers/google-genai-files.ts +289 -0
  26. package/src/providers/google-genai.ts +1518 -0
  27. package/src/providers/openai.ts +719 -0
  28. package/src/providers/registry.ts +33 -0
  29. package/src/reactor.test.ts +3660 -0
  30. package/src/reactor.ts +1058 -0
  31. package/src/retry-policy.ts +99 -0
  32. package/src/scheduler.test.ts +41 -0
  33. package/src/sse.test.ts +133 -0
  34. package/src/sse.ts +76 -0
  35. package/src/state.ts +135 -0
  36. package/src/transform.test.ts +207 -0
  37. package/src/transform.ts +159 -0
  38. package/src/transforms/index.ts +2 -0
  39. package/src/transforms/size-cap.test.ts +172 -0
  40. package/src/transforms/size-cap.ts +110 -0
  41. package/src/turns.ts +54 -0
  42. package/tsconfig.json +4 -0
  43. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,145 @@
1
+ // Authz-based BeforeToolExtension.
2
+ //
3
+ // Creates an extension that authorizes tool calls against a policy before
4
+ // execution. The caller provides a pre-bound authorize function that
5
+ // encapsulates store, principal, tenant, and condition registry details.
6
+ //
7
+ // Effects:
8
+ // allow → tool proceeds
9
+ // deny → tool blocked
10
+ // ask → tool blocked (gate-based approval deferred to a future commit)
11
+ // null → tool blocked (fail-closed: no grants matched)
12
+ //
13
+ // The action is always "invoke" — all tool calls are invocations. If
14
+ // additional action granularity is needed later, the action becomes a
15
+ // parameter.
16
+ //
17
+ // Signal propagation into the authorize function is deferred — the caller
18
+ // can capture the signal in their closure if cancellation is needed.
19
+ //
20
+ // The onDecision callback must not throw. If it does, the exception is
21
+ // logged but swallowed so it cannot interfere with the authorization
22
+ // decision or mask the original error.
23
+
24
+ import type { BeforeToolExtension } from "@intx/types/runtime";
25
+ import type { Effect } from "@intx/types/authz";
26
+
27
+ export type AuthzMatchedGrant = {
28
+ id: string;
29
+ resource: string;
30
+ action: string;
31
+ effect: Effect;
32
+ origin: "system" | "role" | "creator" | "invoker";
33
+ specificity: number;
34
+ };
35
+
36
+ export type AuthzCallResult = {
37
+ effect: Effect | null;
38
+ matchingGrants: AuthzMatchedGrant[];
39
+ resolvedBy: AuthzMatchedGrant | null;
40
+ };
41
+
42
+ export type AuthzDecision = {
43
+ callId: string;
44
+ tool: string;
45
+ resource: string;
46
+ action: string;
47
+ effect: Effect | null;
48
+ resolvedBy: AuthzMatchedGrant | null;
49
+ matchingGrants: AuthzMatchedGrant[];
50
+ blocked: boolean;
51
+ blockReason: string | undefined;
52
+ error: string | undefined;
53
+ };
54
+
55
+ export type AuthzExtensionOptions = {
56
+ authorize: (resource: string, action: string) => Promise<AuthzCallResult>;
57
+ onDecision?: (decision: AuthzDecision) => void;
58
+ };
59
+
60
+ type BlockEffect = "deny" | "ask" | null;
61
+
62
+ function formatBlockReason(
63
+ effect: BlockEffect,
64
+ resource: string,
65
+ action: string,
66
+ ): string {
67
+ switch (effect) {
68
+ case "deny":
69
+ return `Denied by policy: ${resource}/${action}`;
70
+ case "ask":
71
+ return `Requires approval: ${resource}/${action}`;
72
+ case null:
73
+ return `No matching grants for ${resource}/${action}`;
74
+ }
75
+ }
76
+
77
+ function safeOnDecision(
78
+ callback: ((decision: AuthzDecision) => void) | undefined,
79
+ decision: AuthzDecision,
80
+ ): void {
81
+ if (callback === undefined) return;
82
+ try {
83
+ callback(decision);
84
+ } catch {
85
+ // onDecision must not throw. If it does, swallow the exception so
86
+ // it cannot interfere with the authorization decision or mask the
87
+ // original error from authorize().
88
+ }
89
+ }
90
+
91
+ export function createAuthzExtension(
92
+ opts: AuthzExtensionOptions,
93
+ ): BeforeToolExtension {
94
+ return {
95
+ async beforeTool(call) {
96
+ const resource = `tool:${call.name}`;
97
+ const action = "invoke";
98
+
99
+ let result: AuthzCallResult;
100
+ try {
101
+ result = await opts.authorize(resource, action);
102
+ } catch (cause) {
103
+ const msg = cause instanceof Error ? cause.message : String(cause);
104
+ const decision: AuthzDecision = {
105
+ callId: call.id,
106
+ tool: call.name,
107
+ resource,
108
+ action,
109
+ effect: null,
110
+ resolvedBy: null,
111
+ matchingGrants: [],
112
+ blocked: true,
113
+ blockReason: `Authorization failed: ${msg}`,
114
+ error: msg,
115
+ };
116
+ safeOnDecision(opts.onDecision, decision);
117
+ throw cause;
118
+ }
119
+
120
+ const blocked = result.effect !== "allow";
121
+ const blockReason =
122
+ result.effect === "deny" ||
123
+ result.effect === "ask" ||
124
+ result.effect === null
125
+ ? formatBlockReason(result.effect, resource, action)
126
+ : undefined;
127
+
128
+ const decision: AuthzDecision = {
129
+ callId: call.id,
130
+ tool: call.name,
131
+ resource,
132
+ action,
133
+ effect: result.effect,
134
+ resolvedBy: result.resolvedBy,
135
+ matchingGrants: result.matchingGrants,
136
+ blocked,
137
+ blockReason,
138
+ error: undefined,
139
+ };
140
+ safeOnDecision(opts.onDecision, decision);
141
+
142
+ return blockReason;
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,61 @@
1
+ // Correlation registry and validator interface for the agent reactor.
2
+ //
3
+ // Correlation connects outbound async tool calls to inbound responses. The
4
+ // reactor owns the matching; the director does not participate.
5
+ //
6
+ // (INFERENCE.md § Correlation)
7
+
8
+ import type { InboundMessage, PendingOperation } from "@intx/types/runtime";
9
+
10
+ /**
11
+ * Validates whether an inbound message is an authentic response to a
12
+ * registered pending operation. Consumers provide this at reactor construction
13
+ * time to enforce sender identity and signature checks.
14
+ */
15
+ export interface CorrelationValidator {
16
+ /**
17
+ * Return true if `message` is a valid resolution for `pending`.
18
+ * False causes the message to be delivered as a regular uncorrelated event.
19
+ */
20
+ validate(
21
+ pending: PendingOperation,
22
+ message: InboundMessage,
23
+ ): Promise<boolean>;
24
+ }
25
+
26
+ /**
27
+ * Tracks pending async operations. Each entry maps a correlation ID to the
28
+ * operation metadata and the gate that is waiting for it.
29
+ */
30
+ export function createCorrelationRegistry() {
31
+ const operations = new Map<string, PendingOperation>();
32
+
33
+ function register(op: PendingOperation): void {
34
+ if (operations.has(op.correlationId)) {
35
+ throw new Error(
36
+ `Correlation ID "${op.correlationId}" is already registered`,
37
+ );
38
+ }
39
+ operations.set(op.correlationId, op);
40
+ }
41
+
42
+ function lookup(correlationId: string): PendingOperation | undefined {
43
+ return operations.get(correlationId);
44
+ }
45
+
46
+ function remove(correlationId: string): boolean {
47
+ return operations.delete(correlationId);
48
+ }
49
+
50
+ function all(): PendingOperation[] {
51
+ return Array.from(operations.values());
52
+ }
53
+
54
+ function hasAny(): boolean {
55
+ return operations.size > 0;
56
+ }
57
+
58
+ return { register, lookup, remove, all, hasAny };
59
+ }
60
+
61
+ export type CorrelationRegistry = ReturnType<typeof createCorrelationRegistry>;
@@ -0,0 +1,314 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { createInboundMessage } from "@intx/mime";
4
+ import type {
5
+ AssistantTurn,
6
+ InferenceError,
7
+ LastCycleSource,
8
+ PartialMessage,
9
+ ReactorAction,
10
+ ReactorInboundEvent,
11
+ ReactorState,
12
+ ToolResult,
13
+ TokenUsage,
14
+ } from "@intx/types/runtime";
15
+
16
+ import { createDefaultDirector } from "./default-director";
17
+ import type {
18
+ AfterInferenceDecision,
19
+ AfterInferenceHook,
20
+ DefaultDirectorPolicy,
21
+ } from "./default-director";
22
+ import { createCapabilities } from "./director";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Fixtures
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const TEST_SOURCE: LastCycleSource = {
29
+ sourceId: "anthropic:claude-test",
30
+ provider: "anthropic",
31
+ model: "claude-test",
32
+ };
33
+
34
+ const TEST_USAGE: TokenUsage = {
35
+ input: 100,
36
+ output: 50,
37
+ cacheRead: 0,
38
+ cacheWrite: 0,
39
+ thinking: 0,
40
+ };
41
+
42
+ function makeState(overrides: Partial<ReactorState> = {}): ReactorState {
43
+ return {
44
+ sessionId: "test-session",
45
+ turns: [],
46
+ activeForks: [],
47
+ pendingOperations: [],
48
+ activeGates: [],
49
+ tokenUsage: { ...TEST_USAGE },
50
+ lastCycleUsage: { ...TEST_USAGE },
51
+ lastCycleSource: { ...TEST_SOURCE },
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ function makeAssistantTurn(text: string): AssistantTurn {
57
+ return {
58
+ role: "assistant",
59
+ content: [{ type: "text", text }],
60
+ model: "claude-test",
61
+ timestamp: 1000,
62
+ };
63
+ }
64
+
65
+ function makeAssistantTurnWithToolCall(
66
+ callId: string,
67
+ name: string,
68
+ ): AssistantTurn {
69
+ return {
70
+ role: "assistant",
71
+ content: [
72
+ { type: "tool_call", id: callId, name, arguments: { q: "test" } },
73
+ ],
74
+ model: "claude-test",
75
+ timestamp: 1000,
76
+ };
77
+ }
78
+
79
+ function makeInferenceDoneEvent(turn: AssistantTurn): ReactorInboundEvent {
80
+ return {
81
+ type: "inference.done",
82
+ turn,
83
+ usage: TEST_USAGE,
84
+ source: TEST_SOURCE,
85
+ };
86
+ }
87
+
88
+ function makeInferenceErrorEvent(): ReactorInboundEvent {
89
+ const error: InferenceError = {
90
+ category: "fatal",
91
+ message: "test error",
92
+ };
93
+ const partial: PartialMessage = { text: "" };
94
+ return { type: "inference.error", error, partial };
95
+ }
96
+
97
+ async function decide(
98
+ policy: DefaultDirectorPolicy,
99
+ event: ReactorInboundEvent,
100
+ state: ReactorState = makeState(),
101
+ ): Promise<ReactorAction[]> {
102
+ const director = createDefaultDirector("You are a test agent.", [], policy);
103
+ const result = await director.decide(event, state, createCapabilities());
104
+ return Array.isArray(result) ? result : [result];
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Hook decisions
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("DefaultDirector — afterInferenceDone hook", () => {
112
+ test("continue: existing post-inference logic runs unchanged", async () => {
113
+ let receivedState: ReactorState | undefined;
114
+ let receivedTurn: AssistantTurn | undefined;
115
+ const hook: AfterInferenceHook = (state, turn) => {
116
+ receivedState = state;
117
+ receivedTurn = turn;
118
+ return { type: "continue" };
119
+ };
120
+ const turn = makeAssistantTurn("Hello from the model");
121
+ const actions = await decide(
122
+ { afterInferenceDone: hook },
123
+ makeInferenceDoneEvent(turn),
124
+ );
125
+
126
+ // The hook saw the post-cycle state and the turn — verifies the
127
+ // director plumbed both arguments through, not just called the hook.
128
+ expect(receivedTurn).toEqual(turn);
129
+ expect(receivedState?.lastCycleSource).toEqual(TEST_SOURCE);
130
+ expect(receivedState?.lastCycleUsage).toEqual(TEST_USAGE);
131
+
132
+ // Continue falls through to the existing reply path.
133
+ expect(actions).toEqual([
134
+ { type: "checkpoint", message: "checkpoint: inference-done" },
135
+ { type: "reply", content: "Hello from the model" },
136
+ ]);
137
+ });
138
+
139
+ test("abort: returns [checkpoint, reply, done] with the policy reason", async () => {
140
+ const hook: AfterInferenceHook = () => ({
141
+ type: "abort",
142
+ reason: "budget exhausted",
143
+ });
144
+ const actions = await decide(
145
+ { afterInferenceDone: hook },
146
+ makeInferenceDoneEvent(makeAssistantTurn("ignored")),
147
+ );
148
+ expect(actions).toEqual([
149
+ { type: "checkpoint", message: "checkpoint: after-inference-abort" },
150
+ { type: "reply", content: "budget exhausted" },
151
+ { type: "done" },
152
+ ]);
153
+ });
154
+
155
+ test("halt: returns [checkpoint, reply, wait] with the policy reason", async () => {
156
+ const hook: AfterInferenceHook = () => ({
157
+ type: "halt",
158
+ reason: "paused for top-up",
159
+ });
160
+ const actions = await decide(
161
+ { afterInferenceDone: hook },
162
+ makeInferenceDoneEvent(makeAssistantTurn("ignored")),
163
+ );
164
+ expect(actions).toEqual([
165
+ { type: "checkpoint", message: "checkpoint: after-inference-halt" },
166
+ { type: "reply", content: "paused for top-up" },
167
+ { type: "wait" },
168
+ ]);
169
+ });
170
+
171
+ test("hook not set: existing behavior preserved", async () => {
172
+ const actions = await decide(
173
+ {},
174
+ makeInferenceDoneEvent(makeAssistantTurn("Hello")),
175
+ );
176
+ expect(actions).toEqual([
177
+ { type: "checkpoint", message: "checkpoint: inference-done" },
178
+ { type: "reply", content: "Hello" },
179
+ ]);
180
+ });
181
+
182
+ test("hook throws: caught, routed to abort with synthesized reason", async () => {
183
+ const hook: AfterInferenceHook = () => {
184
+ throw new Error("policy died");
185
+ };
186
+ const actions = await decide(
187
+ { afterInferenceDone: hook },
188
+ makeInferenceDoneEvent(makeAssistantTurn("ignored")),
189
+ );
190
+ expect(actions).toEqual([
191
+ { type: "checkpoint", message: "checkpoint: after-inference-abort" },
192
+ {
193
+ type: "reply",
194
+ content: "afterInferenceDone policy threw: policy died",
195
+ },
196
+ { type: "done" },
197
+ ]);
198
+ });
199
+
200
+ test("hook returning a Promise is awaited", async () => {
201
+ const hook: AfterInferenceHook = async () => {
202
+ await new Promise((resolve) => setTimeout(resolve, 5));
203
+ const decision: AfterInferenceDecision = {
204
+ type: "abort",
205
+ reason: "async abort",
206
+ };
207
+ return decision;
208
+ };
209
+ const actions = await decide(
210
+ { afterInferenceDone: hook },
211
+ makeInferenceDoneEvent(makeAssistantTurn("ignored")),
212
+ );
213
+ expect(actions).toEqual([
214
+ { type: "checkpoint", message: "checkpoint: after-inference-abort" },
215
+ { type: "reply", content: "async abort" },
216
+ { type: "done" },
217
+ ]);
218
+ });
219
+
220
+ test("hook does NOT fire on inference.error", async () => {
221
+ let hookFired = false;
222
+ const hook: AfterInferenceHook = () => {
223
+ hookFired = true;
224
+ return { type: "abort", reason: "should not run" };
225
+ };
226
+ const actions = await decide(
227
+ { afterInferenceDone: hook },
228
+ makeInferenceErrorEvent(),
229
+ );
230
+ expect(hookFired).toBe(false);
231
+ // The inference.error branch produces its own checkpoint + reply
232
+ // shape; the hook is not in that path.
233
+ expect(actions[0]).toEqual({
234
+ type: "checkpoint",
235
+ message: "checkpoint: inference-error",
236
+ });
237
+ });
238
+
239
+ test("abort fires before tool calls execute (tool calls dropped)", async () => {
240
+ const hook: AfterInferenceHook = () => ({
241
+ type: "abort",
242
+ reason: "stop now",
243
+ });
244
+ const turn = makeAssistantTurnWithToolCall("call_1", "search");
245
+ const actions = await decide(
246
+ { afterInferenceDone: hook },
247
+ makeInferenceDoneEvent(turn),
248
+ );
249
+ // The model's tool call is on the turn, but the hook's abort
250
+ // routes to done before execute_tools is reached. The TSDoc warns
251
+ // policy authors about this; the test pins the behavior.
252
+ expect(actions).toEqual([
253
+ { type: "checkpoint", message: "checkpoint: after-inference-abort" },
254
+ { type: "reply", content: "stop now" },
255
+ { type: "done" },
256
+ ]);
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Firing boundary: hook fires only on inference.done
262
+ //
263
+ // The "does NOT fire on inference.error" test above pins one negative
264
+ // case; this block exhausts the rest of the ReactorInboundEvent union
265
+ // so a future switch refactor (e.g. extracting a shared post-event
266
+ // helper) can't quietly start invoking the hook on the wrong branch.
267
+ // ---------------------------------------------------------------------------
268
+
269
+ async function fireHook(event: ReactorInboundEvent): Promise<boolean> {
270
+ let fired = false;
271
+ const hook: AfterInferenceHook = () => {
272
+ fired = true;
273
+ return { type: "continue" };
274
+ };
275
+ await decide({ afterInferenceDone: hook }, event);
276
+ return fired;
277
+ }
278
+
279
+ describe("DefaultDirector — afterInferenceDone firing boundary", () => {
280
+ test("not fired on message.received", async () => {
281
+ const event: ReactorInboundEvent = {
282
+ type: "message.received",
283
+ message: createInboundMessage({
284
+ from: "test@example.com",
285
+ to: "agent@example.com",
286
+ content: "hi",
287
+ }),
288
+ };
289
+ expect(await fireHook(event)).toBe(false);
290
+ });
291
+
292
+ test("not fired on tool.done", async () => {
293
+ const result: ToolResult = { callId: "c1", content: "ok" };
294
+ const event: ReactorInboundEvent = { type: "tool.done", result };
295
+ expect(await fireHook(event)).toBe(false);
296
+ });
297
+
298
+ test("not fired on reactor.gate.cleared", async () => {
299
+ const event: ReactorInboundEvent = {
300
+ type: "reactor.gate.cleared",
301
+ gateId: "g1",
302
+ reason: "resolved",
303
+ };
304
+ expect(await fireHook(event)).toBe(false);
305
+ });
306
+
307
+ test("not fired on abort", async () => {
308
+ const event: ReactorInboundEvent = {
309
+ type: "abort",
310
+ reason: "user_disconnect",
311
+ };
312
+ expect(await fireHook(event)).toBe(false);
313
+ });
314
+ });