@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.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +37 -0
  31. package/dist/providers/anthropic.js +917 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +5 -0
  35. package/dist/providers/google-genai.js +1205 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +132 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +22 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,133 @@
1
+ // Reactor assembly helper.
2
+ //
3
+ // `createReactorAssembly` is the canonical way to construct a reactor when the
4
+ // caller wants the standard wiring: a default size-cap tool-result transform,
5
+ // authz as a before-tool extension, an audit collector that flushes at
6
+ // checkpoint and shutdown boundaries, and a `BlobReader` over the supplied
7
+ // context store. Future reactor consumers (the harness today; in-process agent
8
+ // runtimes tomorrow) should use this helper rather than calling `createReactor`
9
+ // directly so the wiring stays consistent across composition points.
10
+ import { getLogger } from "@intx/log";
11
+ import { createBlobReader, } from "@intx/types/runtime";
12
+ import { createAuditCollector } from "./audit-collector.js";
13
+ import { createAuthzExtension, } from "./authz-extension.js";
14
+ import { createReactor, } from "./reactor.js";
15
+ import { createSizeCapTransform } from "./transforms/index.js";
16
+ const logger = getLogger(["interchange", "assembly"]);
17
+ const DEFAULT_SIZE_CAP_MAX_CHARS = 10_000;
18
+ /**
19
+ * Build the standard reactor wiring. This is the canonical reactor-assembly
20
+ * path: any consumer that needs the default size-cap transform, authz, audit
21
+ * collection, and blob reader should call this helper instead of constructing
22
+ * a `ReactorConfig` by hand. Direct `createReactor` use is reserved for
23
+ * reactor-internal tests and any future consumer that genuinely needs a
24
+ * different composition.
25
+ */
26
+ export function createReactorAssembly(config) {
27
+ const { sessionId, director, source, failOverToNextSource, resetToPreferredSource, toolRunner, contextStore, onEvent, authorize, toolDefinitions, auditStore, beforeToolExtensions: callerBeforeToolExtensions, toolResultTransforms: callerToolResultTransforms, contextTransforms, compactors, sizeCapMaxChars, afterCheckpoint: callerAfterCheckpoint, onShutdown: callerOnShutdown, deps, correlationValidator, inferenceRunner, gateTimeout, shutdownTimeoutMs, } = config;
28
+ // Audit collector is created up-front so the authz extension can route its
29
+ // decisions through `onDecision`. When no auditStore is supplied, no
30
+ // collector is created and authz runs without decision recording.
31
+ const auditCollector = auditStore !== undefined ? createAuditCollector(sessionId) : undefined;
32
+ // When an audit collector is present, intercept the reactor's event stream
33
+ // to feed it tool.start / tool.done events (the collector correlates these
34
+ // with authz decisions by callId). message.received is reactor-internal and
35
+ // is forwarded to the caller but not to the collector. Without a collector,
36
+ // the caller's onEvent is used directly.
37
+ const composedOnEvent = auditCollector !== undefined
38
+ ? (event) => {
39
+ if (event.type !== "message.received") {
40
+ auditCollector.onEvent(event);
41
+ }
42
+ onEvent(event);
43
+ }
44
+ : onEvent;
45
+ // Authz is composed in front of any caller-supplied before-tool extensions
46
+ // so policy enforcement runs first. Without authz, the caller's list (if
47
+ // any) is passed through unchanged.
48
+ const authzExtension = authorize !== undefined
49
+ ? createAuthzExtension({
50
+ authorize,
51
+ ...(auditCollector !== undefined
52
+ ? { onDecision: (d) => auditCollector.onDecision(d) }
53
+ : {}),
54
+ ...(toolDefinitions !== undefined ? { toolDefinitions } : {}),
55
+ })
56
+ : undefined;
57
+ const composedBeforeToolExtensions = authzExtension !== undefined
58
+ ? [authzExtension, ...(callerBeforeToolExtensions ?? [])]
59
+ : callerBeforeToolExtensions;
60
+ // The size-cap transform is always prepended so oversized payloads spill
61
+ // before any caller transform sees them. Caller transforms run after and
62
+ // can rely on the inline content already being bounded.
63
+ const sizeCapTransform = createSizeCapTransform({
64
+ maxChars: sizeCapMaxChars ?? DEFAULT_SIZE_CAP_MAX_CHARS,
65
+ contextStore,
66
+ });
67
+ const composedToolResultTransforms = [
68
+ sizeCapTransform,
69
+ ...(callerToolResultTransforms ?? []),
70
+ ];
71
+ // Audit flush wraps the caller's lifecycle hooks: the helper's flush runs
72
+ // first so the records produced by the just-completed cycle are persisted
73
+ // before the caller's hook observes the checkpoint or shutdown boundary.
74
+ async function flushAudit() {
75
+ if (auditCollector === undefined || auditStore === undefined)
76
+ return;
77
+ const records = auditCollector.flush();
78
+ if (records.length > 0) {
79
+ await auditStore.commitAudit(records);
80
+ }
81
+ }
82
+ const composedAfterCheckpoint = auditCollector !== undefined
83
+ ? async () => {
84
+ await flushAudit();
85
+ if (callerAfterCheckpoint !== undefined) {
86
+ await callerAfterCheckpoint();
87
+ }
88
+ }
89
+ : callerAfterCheckpoint;
90
+ const composedOnShutdown = auditCollector !== undefined
91
+ ? async () => {
92
+ const inflight = auditCollector.pending();
93
+ if (inflight > 0) {
94
+ logger.warn `${inflight} audit records in flight at shutdown, these tool calls will not be recorded`;
95
+ }
96
+ await flushAudit();
97
+ if (callerOnShutdown !== undefined) {
98
+ await callerOnShutdown();
99
+ }
100
+ }
101
+ : callerOnShutdown;
102
+ // exactOptionalPropertyTypes is on: only set optional keys when defined.
103
+ const reactorConfig = {
104
+ sessionId,
105
+ director,
106
+ source,
107
+ ...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}),
108
+ ...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}),
109
+ toolRunner,
110
+ contextStore,
111
+ onEvent: composedOnEvent,
112
+ deps,
113
+ toolResultTransforms: composedToolResultTransforms,
114
+ ...(composedBeforeToolExtensions !== undefined
115
+ ? { beforeToolExtensions: composedBeforeToolExtensions }
116
+ : {}),
117
+ ...(contextTransforms !== undefined ? { contextTransforms } : {}),
118
+ ...(compactors !== undefined ? { compactors } : {}),
119
+ ...(composedAfterCheckpoint !== undefined
120
+ ? { afterCheckpoint: composedAfterCheckpoint }
121
+ : {}),
122
+ ...(composedOnShutdown !== undefined
123
+ ? { onShutdown: composedOnShutdown }
124
+ : {}),
125
+ ...(correlationValidator !== undefined ? { correlationValidator } : {}),
126
+ ...(inferenceRunner !== undefined ? { inferenceRunner } : {}),
127
+ ...(gateTimeout !== undefined ? { gateTimeout } : {}),
128
+ ...(shutdownTimeoutMs !== undefined ? { shutdownTimeoutMs } : {}),
129
+ };
130
+ const reactor = createReactor(reactorConfig);
131
+ const blobReader = createBlobReader(contextStore);
132
+ return { reactor, blobReader, auditCollector };
133
+ }
@@ -0,0 +1,10 @@
1
+ import type { AuditRecord } from "@intx/types/audit";
2
+ import type { InferenceEvent } from "@intx/types/runtime";
3
+ import type { AuthzDecision } from "./authz-extension.js";
4
+ export type AuditCollector = {
5
+ onEvent(event: InferenceEvent): void;
6
+ onDecision(decision: AuthzDecision): void;
7
+ flush(): AuditRecord[];
8
+ pending(): number;
9
+ };
10
+ export declare function createAuditCollector(sessionId: string): AuditCollector;
@@ -0,0 +1,139 @@
1
+ // Audit collector: accumulates tool invocation records for persistence.
2
+ //
3
+ // The collector correlates three data sources into complete AuditRecord
4
+ // objects:
5
+ // 1. tool.start events — tool name and arguments (allowed calls only)
6
+ // 2. AuthzDecision via onDecision — governance decision
7
+ // 3. tool.done events — result and completion metadata
8
+ //
9
+ // Correlation is by callId. For blocked calls, no tool.start is emitted;
10
+ // the collector creates the record from the buffered decision and the
11
+ // tool.done event alone.
12
+ //
13
+ // Wiring: the caller must connect onDecision to the authz extension's
14
+ // onDecision callback, and onEvent to the reactor's event stream. The
15
+ // types alone do not enforce this — it is a composition-layer concern.
16
+ import { getLogger } from "@intx/log";
17
+ const logger = getLogger(["interchange", "audit-collector"]);
18
+ function coerceContent(content) {
19
+ if (typeof content === "string")
20
+ return content;
21
+ if (typeof content === "object" && content !== null) {
22
+ // content is a non-null object — compatible with Record<string, unknown>
23
+ // but TypeScript can't verify the index signature without a cast.
24
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-null object is structurally compatible with Record<string, unknown> but TS won't widen
25
+ return content;
26
+ }
27
+ throw new Error(`Unexpected tool result content type: ${typeof content}`);
28
+ }
29
+ function mapGrant(g) {
30
+ return {
31
+ id: g.id,
32
+ resource: g.resource,
33
+ action: g.action,
34
+ effect: g.effect,
35
+ origin: g.origin,
36
+ specificity: g.specificity,
37
+ };
38
+ }
39
+ function decisionToAuthz(d) {
40
+ return {
41
+ effect: d.effect,
42
+ resolvedBy: d.resolvedBy ? mapGrant(d.resolvedBy) : null,
43
+ matchingGrants: d.matchingGrants.map(mapGrant),
44
+ blocked: d.blocked,
45
+ ...(d.blockReason !== undefined ? { blockReason: d.blockReason } : {}),
46
+ };
47
+ }
48
+ export function createAuditCollector(sessionId) {
49
+ const decisions = new Map();
50
+ const pendingRecords = new Map();
51
+ const completed = [];
52
+ function onDecision(decision) {
53
+ decisions.set(decision.callId, decision);
54
+ }
55
+ function onEvent(event) {
56
+ if (event.type === "tool.start") {
57
+ const call = event.data.call;
58
+ const decision = decisions.get(call.id);
59
+ decisions.delete(call.id);
60
+ pendingRecords.set(call.id, {
61
+ callId: call.id,
62
+ tool: call.name,
63
+ arguments: call.arguments,
64
+ authz: decision ? decisionToAuthz(decision) : null,
65
+ });
66
+ return;
67
+ }
68
+ if (event.type === "tool.done") {
69
+ const result = event.data.result;
70
+ const pending = pendingRecords.get(result.callId);
71
+ if (pending) {
72
+ pendingRecords.delete(result.callId);
73
+ completed.push({
74
+ callId: pending.callId,
75
+ tool: pending.tool,
76
+ arguments: pending.arguments,
77
+ authz: pending.authz,
78
+ result: {
79
+ content: coerceContent(result.content),
80
+ isError: result.isError === true,
81
+ },
82
+ timestamp: new Date().toISOString(),
83
+ sessionId,
84
+ seq: event.seq,
85
+ });
86
+ return;
87
+ }
88
+ // Blocked call: no tool.start was emitted. Build the record from
89
+ // the buffered decision and the tool.done event.
90
+ const decision = decisions.get(result.callId);
91
+ if (decision === undefined) {
92
+ // Orphaned tool.done: no tool.start or authz decision was recorded.
93
+ // Emit a degraded record rather than crashing the session — the audit
94
+ // system is observational infrastructure and must not veto execution.
95
+ logger.warn `Orphaned tool.done for callId "${result.callId}": no tool.start or authz decision was recorded`;
96
+ completed.push({
97
+ callId: result.callId,
98
+ tool: "$orphaned",
99
+ arguments: {},
100
+ authz: null,
101
+ result: {
102
+ content: coerceContent(result.content),
103
+ isError: result.isError === true,
104
+ },
105
+ timestamp: new Date().toISOString(),
106
+ sessionId,
107
+ seq: event.seq,
108
+ });
109
+ return;
110
+ }
111
+ decisions.delete(result.callId);
112
+ completed.push({
113
+ callId: result.callId,
114
+ tool: decision.tool,
115
+ arguments: {},
116
+ authz: decisionToAuthz(decision),
117
+ result: {
118
+ content: coerceContent(result.content),
119
+ isError: result.isError === true,
120
+ },
121
+ timestamp: new Date().toISOString(),
122
+ sessionId,
123
+ seq: event.seq,
124
+ });
125
+ }
126
+ }
127
+ function flush() {
128
+ return completed.splice(0);
129
+ }
130
+ function pendingCount() {
131
+ return pendingRecords.size + decisions.size;
132
+ }
133
+ return {
134
+ onEvent,
135
+ onDecision,
136
+ flush,
137
+ pending: pendingCount,
138
+ };
139
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { InferenceSource } from "@intx/types/runtime";
2
+ /**
3
+ * Sentinel for headers that carry the API key verbatim (no prefix).
4
+ * Used by providers like Anthropic (`x-api-key`) and Google
5
+ * (`x-goog-api-key`) that accept the raw credential.
6
+ */
7
+ export declare const CREDENTIAL_SENTINEL = "<inject:credential>";
8
+ /**
9
+ * Sentinel for headers that carry a Bearer-prefixed API key. Used by
10
+ * providers that follow the `Authorization: Bearer <token>` convention
11
+ * (OpenAI, OpenAI-compatible).
12
+ */
13
+ export declare const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
14
+ /**
15
+ * Replace credential sentinels in a header map with material derived
16
+ * from the inference source. Returns a new object; the input is not
17
+ * mutated. Non-sentinel header values pass through unchanged.
18
+ *
19
+ * A header value that contains a sentinel as a substring but is not
20
+ * exactly equal to it is left alone -- partial replacement would be
21
+ * surprising, and no legitimate adapter constructs sentinel-bearing
22
+ * composite values.
23
+ */
24
+ export declare function injectCredentials(headers: Record<string, string>, source: InferenceSource): Record<string, string>;
@@ -1,5 +1,3 @@
1
- import type { InferenceSource } from "@intx/types/runtime";
2
-
3
1
  // Sentinel placeholder strings adapters use in their built request
4
2
  // headers to declare which credential the harness should fill at send
5
3
  // time. The harness scans every header value and replaces exact-match
@@ -18,21 +16,18 @@ import type { InferenceSource } from "@intx/types/runtime";
18
16
  // keyword prefix that would never appear in a legitimate header value:
19
17
  // matching is exact, but defense-in-depth ensures a literal echo from
20
18
  // an upstream system can't accidentally trigger replacement.
21
-
22
19
  /**
23
20
  * Sentinel for headers that carry the API key verbatim (no prefix).
24
21
  * Used by providers like Anthropic (`x-api-key`) and Google
25
22
  * (`x-goog-api-key`) that accept the raw credential.
26
23
  */
27
24
  export const CREDENTIAL_SENTINEL = "<inject:credential>";
28
-
29
25
  /**
30
26
  * Sentinel for headers that carry a Bearer-prefixed API key. Used by
31
27
  * providers that follow the `Authorization: Bearer <token>` convention
32
28
  * (OpenAI, OpenAI-compatible).
33
29
  */
34
30
  export const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
35
-
36
31
  /**
37
32
  * Replace credential sentinels in a header map with material derived
38
33
  * from the inference source. Returns a new object; the input is not
@@ -43,19 +38,18 @@ export const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
43
38
  * surprising, and no legitimate adapter constructs sentinel-bearing
44
39
  * composite values.
45
40
  */
46
- export function injectCredentials(
47
- headers: Record<string, string>,
48
- source: InferenceSource,
49
- ): Record<string, string> {
50
- const result: Record<string, string> = {};
51
- for (const [name, value] of Object.entries(headers)) {
52
- if (value === CREDENTIAL_SENTINEL) {
53
- result[name] = source.apiKey;
54
- } else if (value === BEARER_CREDENTIAL_SENTINEL) {
55
- result[name] = `Bearer ${source.apiKey}`;
56
- } else {
57
- result[name] = value;
41
+ export function injectCredentials(headers, source) {
42
+ const result = {};
43
+ for (const [name, value] of Object.entries(headers)) {
44
+ if (value === CREDENTIAL_SENTINEL) {
45
+ result[name] = source.apiKey;
46
+ }
47
+ else if (value === BEARER_CREDENTIAL_SENTINEL) {
48
+ result[name] = `Bearer ${source.apiKey}`;
49
+ }
50
+ else {
51
+ result[name] = value;
52
+ }
58
53
  }
59
- }
60
- return result;
54
+ return result;
61
55
  }
@@ -0,0 +1,46 @@
1
+ import type { BeforeToolExtension, ToolDefinition } from "@intx/types/runtime";
2
+ import type { Effect } from "@intx/types/authz";
3
+ export type AuthzMatchedGrant = {
4
+ id: string;
5
+ resource: string;
6
+ action: string;
7
+ effect: Effect;
8
+ origin: "system" | "role" | "creator" | "invoker";
9
+ specificity: number;
10
+ };
11
+ export type AuthzCallResult = {
12
+ effect: Effect | null;
13
+ matchingGrants: AuthzMatchedGrant[];
14
+ resolvedBy: AuthzMatchedGrant | null;
15
+ };
16
+ export type AuthzDecision = {
17
+ callId: string;
18
+ tool: string;
19
+ resource: string;
20
+ action: string;
21
+ effect: Effect | null;
22
+ resolvedBy: AuthzMatchedGrant | null;
23
+ matchingGrants: AuthzMatchedGrant[];
24
+ blocked: boolean;
25
+ blockReason: string | undefined;
26
+ error: string | undefined;
27
+ };
28
+ export type AuthzExtensionOptions<Ctx = unknown> = {
29
+ authorize: (resource: string, action: string, context: Ctx) => Promise<AuthzCallResult>;
30
+ onDecision?: (decision: AuthzDecision) => void;
31
+ /**
32
+ * Deadline applied to an approval suspension, in milliseconds from the
33
+ * moment the `ask` effect is hit. Defaults to `DEFAULT_APPROVAL_TIMEOUT_MS`.
34
+ */
35
+ approvalTimeoutMs?: number;
36
+ /**
37
+ * Tool definitions the extension can be asked to authorize, used to build the
38
+ * approver-facing snapshot at the `ask` branch. Presence is a contract: when
39
+ * supplied, every tool this extension authorizes must appear here, and an
40
+ * `ask` for a tool that does not is a wiring defect that throws. Omitted
41
+ * entirely, the extension produces no snapshot — a mode for callers that
42
+ * never register a suspension with the hub.
43
+ */
44
+ toolDefinitions?: readonly ToolDefinition[];
45
+ };
46
+ export declare function createAuthzExtension<Ctx = unknown>(opts: AuthzExtensionOptions<Ctx>): BeforeToolExtension;
@@ -0,0 +1,184 @@
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 suspended (parked awaiting an external approval decision)
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
+ // Default deadline for an approval suspension when the caller does not supply
24
+ // one. Matches the reactor's DEFAULT_GATE_TIMEOUT_MS (one hour); the value is
25
+ // duplicated rather than imported to avoid a dependency from the pure-policy
26
+ // extension onto the reactor module.
27
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 3_600_000;
28
+ function formatBlockReason(effect, resource, action) {
29
+ switch (effect) {
30
+ case "deny":
31
+ return `Denied by policy: ${resource}/${action}`;
32
+ case null:
33
+ return `No matching grants for ${resource}/${action}`;
34
+ }
35
+ }
36
+ function safeOnDecision(callback, decision) {
37
+ if (callback === undefined)
38
+ return;
39
+ try {
40
+ callback(decision);
41
+ }
42
+ catch {
43
+ // onDecision must not throw. If it does, swallow the exception so
44
+ // it cannot interfere with the authorization decision or mask the
45
+ // original error from authorize().
46
+ }
47
+ }
48
+ export function createAuthzExtension(opts) {
49
+ // The reactor does not know workflow concepts; per-call context is the
50
+ // caller's domain. The third arg is plumbing here -- if the caller
51
+ // needs to attach context (workflow step, tenant id, request id), they
52
+ // do so by closure on the authorize function. The empty object is the
53
+ // safe default at this layer.
54
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the inference layer has no domain knowledge to construct a Ctx; callers that need a populated context use closure capture on the authorize function (see @intx/workflow's AuthorizeContext)
55
+ const emptyContext = Object.freeze({});
56
+ // One-shot bypass tokens, keyed on ToolCall.id. A token authorizes a single
57
+ // re-dispatch of an already-approved call to skip the `ask` gate it would
58
+ // otherwise re-hit. Held in memory only, within the resumed reactor cycle
59
+ // that grants and consumes it: a durable allow would outlive the cycle and
60
+ // defeat the one-shot intent, and a crash between grant and consume simply
61
+ // re-drives from the durable log and re-grants.
62
+ const approvedOnce = new Set();
63
+ // Name → definition lookup for building the approval snapshot at the `ask`
64
+ // branch. `undefined` (not merely empty) means the caller wired no tool
65
+ // definitions and wants no snapshot; a defined map means every authorizable
66
+ // tool must be present, so a lookup miss is a wiring defect that throws. The
67
+ // sentinel keeps those two contracts distinguishable at the lookup site.
68
+ const toolDefinitionsByName = opts.toolDefinitions !== undefined
69
+ ? new Map(opts.toolDefinitions.map((def) => [def.name, def]))
70
+ : undefined;
71
+ return {
72
+ grantOneShot(id) {
73
+ approvedOnce.add(id);
74
+ },
75
+ async beforeTool(call) {
76
+ const resource = `tool:${call.name}`;
77
+ const action = "invoke";
78
+ let result;
79
+ try {
80
+ result = await opts.authorize(resource, action, emptyContext);
81
+ }
82
+ catch (cause) {
83
+ const msg = cause instanceof Error ? cause.message : String(cause);
84
+ const decision = {
85
+ callId: call.id,
86
+ tool: call.name,
87
+ resource,
88
+ action,
89
+ effect: null,
90
+ resolvedBy: null,
91
+ matchingGrants: [],
92
+ blocked: true,
93
+ blockReason: `Authorization failed: ${msg}`,
94
+ error: msg,
95
+ };
96
+ safeOnDecision(opts.onDecision, decision);
97
+ throw cause;
98
+ }
99
+ // An `ask` effect suspends the call rather than blocking it, so it is
100
+ // neither cleanly blocked nor allowed: the decision records
101
+ // `blocked: false` with no block reason. Only `deny`/null (fail-closed)
102
+ // are blocks.
103
+ const blockReason = result.effect === "deny" || result.effect === null
104
+ ? formatBlockReason(result.effect, resource, action)
105
+ : undefined;
106
+ const decision = {
107
+ callId: call.id,
108
+ tool: call.name,
109
+ resource,
110
+ action,
111
+ effect: result.effect,
112
+ resolvedBy: result.resolvedBy,
113
+ matchingGrants: result.matchingGrants,
114
+ blocked: blockReason !== undefined,
115
+ blockReason,
116
+ error: undefined,
117
+ };
118
+ safeOnDecision(opts.onDecision, decision);
119
+ // A one-shot token only authorizes bypassing an `ask` gate. If the
120
+ // resolved effect is anything else, the grant changed underneath the
121
+ // token: drop it and let the normal path decide, rather than silently
122
+ // allowing a call the policy no longer parks.
123
+ if (approvedOnce.has(call.id) && result.effect !== "ask") {
124
+ approvedOnce.delete(call.id);
125
+ }
126
+ if (blockReason !== undefined) {
127
+ return { type: "block", reason: blockReason };
128
+ }
129
+ if (result.effect === "ask") {
130
+ // A prior approval authorized this exact call to run once. Consume the
131
+ // token (delete-on-read) and allow it through instead of suspending,
132
+ // so a re-dispatched approved call does not re-park on its own gate.
133
+ if (approvedOnce.has(call.id)) {
134
+ approvedOnce.delete(call.id);
135
+ return { type: "allow" };
136
+ }
137
+ // Mint the correlationId once here so it is the single source of
138
+ // identity for both the gate and the persisted operation. The
139
+ // reactor persists the operation, so this id survives a restart.
140
+ const correlationId = crypto.randomUUID();
141
+ const timeoutAt = Date.now() + (opts.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS);
142
+ const gateId = `pending-${correlationId}`;
143
+ // Build the approver-facing snapshot when tool definitions are wired.
144
+ // A wired extension must have a definition for every tool it can
145
+ // authorize, so a miss is a wiring defect rather than a fallback. An
146
+ // unwired extension produces no snapshot: such callers never register
147
+ // the suspension with the hub, so the downstream required-snapshot
148
+ // validator never sees them.
149
+ let approvalSnapshot;
150
+ if (toolDefinitionsByName !== undefined) {
151
+ const def = toolDefinitionsByName.get(call.name);
152
+ if (def === undefined) {
153
+ throw new Error(`Tool "${call.name}" was authorized with effect "ask" but has ` +
154
+ `no definition in the resolved tool set; the approval ` +
155
+ `snapshot cannot be built. This is a wiring defect: every ` +
156
+ `tool the authz extension can authorize must be present in ` +
157
+ `toolDefinitions.`);
158
+ }
159
+ approvalSnapshot = {
160
+ name: call.name,
161
+ description: def.description,
162
+ inputSchema: def.inputSchema,
163
+ arguments: call.arguments,
164
+ };
165
+ }
166
+ const pendingOp = {
167
+ correlationId,
168
+ kind: "approval",
169
+ registeredAt: Date.now(),
170
+ gateId,
171
+ timeoutAt,
172
+ suspendedCall: call,
173
+ ...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}),
174
+ };
175
+ return {
176
+ type: "suspend",
177
+ gate: { type: "approval", gateId, correlationId, timeoutAt },
178
+ pendingOp,
179
+ };
180
+ }
181
+ return { type: "allow" };
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,26 @@
1
+ import type { InboundMessage, PendingOperation } from "@intx/types/runtime";
2
+ /**
3
+ * Validates whether an inbound message is an authentic response to a
4
+ * registered pending operation. Consumers provide this at reactor construction
5
+ * time to enforce sender identity and signature checks.
6
+ */
7
+ export interface CorrelationValidator {
8
+ /**
9
+ * Return true if `message` is a valid resolution for `pending`.
10
+ * False causes the message to be delivered as a regular uncorrelated event.
11
+ */
12
+ validate(pending: PendingOperation, message: InboundMessage): Promise<boolean>;
13
+ }
14
+ /**
15
+ * Tracks pending async operations. Each entry maps a correlation ID to the
16
+ * operation metadata and the gate that is waiting for it.
17
+ */
18
+ export declare function createCorrelationRegistry(): {
19
+ register: (op: PendingOperation) => void;
20
+ lookup: (correlationId: string) => PendingOperation | undefined;
21
+ findByGateId: (gateId: string) => PendingOperation | undefined;
22
+ remove: (correlationId: string) => boolean;
23
+ all: () => PendingOperation[];
24
+ hasAny: () => boolean;
25
+ };
26
+ export type CorrelationRegistry = ReturnType<typeof createCorrelationRegistry>;
@@ -0,0 +1,39 @@
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
+ * Tracks pending async operations. Each entry maps a correlation ID to the
9
+ * operation metadata and the gate that is waiting for it.
10
+ */
11
+ export function createCorrelationRegistry() {
12
+ const operations = new Map();
13
+ function register(op) {
14
+ if (operations.has(op.correlationId)) {
15
+ throw new Error(`Correlation ID "${op.correlationId}" is already registered`);
16
+ }
17
+ operations.set(op.correlationId, op);
18
+ }
19
+ function lookup(correlationId) {
20
+ return operations.get(correlationId);
21
+ }
22
+ function findByGateId(gateId) {
23
+ for (const op of operations.values()) {
24
+ if (op.gateId === gateId)
25
+ return op;
26
+ }
27
+ return undefined;
28
+ }
29
+ function remove(correlationId) {
30
+ return operations.delete(correlationId);
31
+ }
32
+ function all() {
33
+ return Array.from(operations.values());
34
+ }
35
+ function hasAny() {
36
+ return operations.size > 0;
37
+ }
38
+ return { register, lookup, findByGateId, remove, all, hasAny };
39
+ }