@intx/inference 0.1.2 → 0.2.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 (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 +38 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +68 -0
  7. package/dist/assembly.js +132 -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 +32 -0
  13. package/dist/authz-extension.js +100 -0
  14. package/dist/correlation.d.ts +25 -0
  15. package/dist/correlation.js +32 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +199 -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 +27 -0
  23. package/dist/gates.js +80 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1319 -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 +33 -0
  31. package/dist/providers/anthropic.js +670 -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 +3 -0
  35. package/dist/providers/google-genai.js +1196 -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 +3 -0
  39. package/dist/providers/openai.js +609 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +920 -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 +117 -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 +21 -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,132 @@
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, 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
+ })
55
+ : undefined;
56
+ const composedBeforeToolExtensions = authzExtension !== undefined
57
+ ? [authzExtension, ...(callerBeforeToolExtensions ?? [])]
58
+ : callerBeforeToolExtensions;
59
+ // The size-cap transform is always prepended so oversized payloads spill
60
+ // before any caller transform sees them. Caller transforms run after and
61
+ // can rely on the inline content already being bounded.
62
+ const sizeCapTransform = createSizeCapTransform({
63
+ maxChars: sizeCapMaxChars ?? DEFAULT_SIZE_CAP_MAX_CHARS,
64
+ contextStore,
65
+ });
66
+ const composedToolResultTransforms = [
67
+ sizeCapTransform,
68
+ ...(callerToolResultTransforms ?? []),
69
+ ];
70
+ // Audit flush wraps the caller's lifecycle hooks: the helper's flush runs
71
+ // first so the records produced by the just-completed cycle are persisted
72
+ // before the caller's hook observes the checkpoint or shutdown boundary.
73
+ async function flushAudit() {
74
+ if (auditCollector === undefined || auditStore === undefined)
75
+ return;
76
+ const records = auditCollector.flush();
77
+ if (records.length > 0) {
78
+ await auditStore.commitAudit(records);
79
+ }
80
+ }
81
+ const composedAfterCheckpoint = auditCollector !== undefined
82
+ ? async () => {
83
+ await flushAudit();
84
+ if (callerAfterCheckpoint !== undefined) {
85
+ await callerAfterCheckpoint();
86
+ }
87
+ }
88
+ : callerAfterCheckpoint;
89
+ const composedOnShutdown = auditCollector !== undefined
90
+ ? async () => {
91
+ const inflight = auditCollector.pending();
92
+ if (inflight > 0) {
93
+ logger.warn `${inflight} audit records in flight at shutdown, these tool calls will not be recorded`;
94
+ }
95
+ await flushAudit();
96
+ if (callerOnShutdown !== undefined) {
97
+ await callerOnShutdown();
98
+ }
99
+ }
100
+ : callerOnShutdown;
101
+ // exactOptionalPropertyTypes is on: only set optional keys when defined.
102
+ const reactorConfig = {
103
+ sessionId,
104
+ director,
105
+ source,
106
+ ...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}),
107
+ ...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}),
108
+ toolRunner,
109
+ contextStore,
110
+ onEvent: composedOnEvent,
111
+ deps,
112
+ toolResultTransforms: composedToolResultTransforms,
113
+ ...(composedBeforeToolExtensions !== undefined
114
+ ? { beforeToolExtensions: composedBeforeToolExtensions }
115
+ : {}),
116
+ ...(contextTransforms !== undefined ? { contextTransforms } : {}),
117
+ ...(compactors !== undefined ? { compactors } : {}),
118
+ ...(composedAfterCheckpoint !== undefined
119
+ ? { afterCheckpoint: composedAfterCheckpoint }
120
+ : {}),
121
+ ...(composedOnShutdown !== undefined
122
+ ? { onShutdown: composedOnShutdown }
123
+ : {}),
124
+ ...(correlationValidator !== undefined ? { correlationValidator } : {}),
125
+ ...(inferenceRunner !== undefined ? { inferenceRunner } : {}),
126
+ ...(gateTimeout !== undefined ? { gateTimeout } : {}),
127
+ ...(shutdownTimeoutMs !== undefined ? { shutdownTimeoutMs } : {}),
128
+ };
129
+ const reactor = createReactor(reactorConfig);
130
+ const blobReader = createBlobReader(contextStore);
131
+ return { reactor, blobReader, auditCollector };
132
+ }
@@ -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,32 @@
1
+ import type { BeforeToolExtension } 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
+ export declare function createAuthzExtension<Ctx = unknown>(opts: AuthzExtensionOptions<Ctx>): BeforeToolExtension;
@@ -0,0 +1,100 @@
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
+ function formatBlockReason(effect, resource, action) {
24
+ switch (effect) {
25
+ case "deny":
26
+ return `Denied by policy: ${resource}/${action}`;
27
+ case "ask":
28
+ return `Requires approval: ${resource}/${action}`;
29
+ case null:
30
+ return `No matching grants for ${resource}/${action}`;
31
+ }
32
+ }
33
+ function safeOnDecision(callback, decision) {
34
+ if (callback === undefined)
35
+ return;
36
+ try {
37
+ callback(decision);
38
+ }
39
+ catch {
40
+ // onDecision must not throw. If it does, swallow the exception so
41
+ // it cannot interfere with the authorization decision or mask the
42
+ // original error from authorize().
43
+ }
44
+ }
45
+ export function createAuthzExtension(opts) {
46
+ // The reactor does not know workflow concepts; per-call context is the
47
+ // caller's domain. The third arg is plumbing here -- if the caller
48
+ // needs to attach context (workflow step, tenant id, request id), they
49
+ // do so by closure on the authorize function. The empty object is the
50
+ // safe default at this layer.
51
+ // 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)
52
+ const emptyContext = Object.freeze({});
53
+ return {
54
+ async beforeTool(call) {
55
+ const resource = `tool:${call.name}`;
56
+ const action = "invoke";
57
+ let result;
58
+ try {
59
+ result = await opts.authorize(resource, action, emptyContext);
60
+ }
61
+ catch (cause) {
62
+ const msg = cause instanceof Error ? cause.message : String(cause);
63
+ const decision = {
64
+ callId: call.id,
65
+ tool: call.name,
66
+ resource,
67
+ action,
68
+ effect: null,
69
+ resolvedBy: null,
70
+ matchingGrants: [],
71
+ blocked: true,
72
+ blockReason: `Authorization failed: ${msg}`,
73
+ error: msg,
74
+ };
75
+ safeOnDecision(opts.onDecision, decision);
76
+ throw cause;
77
+ }
78
+ const blocked = result.effect !== "allow";
79
+ const blockReason = result.effect === "deny" ||
80
+ result.effect === "ask" ||
81
+ result.effect === null
82
+ ? formatBlockReason(result.effect, resource, action)
83
+ : undefined;
84
+ const decision = {
85
+ callId: call.id,
86
+ tool: call.name,
87
+ resource,
88
+ action,
89
+ effect: result.effect,
90
+ resolvedBy: result.resolvedBy,
91
+ matchingGrants: result.matchingGrants,
92
+ blocked,
93
+ blockReason,
94
+ error: undefined,
95
+ };
96
+ safeOnDecision(opts.onDecision, decision);
97
+ return blockReason;
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,25 @@
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
+ remove: (correlationId: string) => boolean;
22
+ all: () => PendingOperation[];
23
+ hasAny: () => boolean;
24
+ };
25
+ export type CorrelationRegistry = ReturnType<typeof createCorrelationRegistry>;
@@ -0,0 +1,32 @@
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 remove(correlationId) {
23
+ return operations.delete(correlationId);
24
+ }
25
+ function all() {
26
+ return Array.from(operations.values());
27
+ }
28
+ function hasAny() {
29
+ return operations.size > 0;
30
+ }
31
+ return { register, lookup, remove, all, hasAny };
32
+ }
@@ -0,0 +1,111 @@
1
+ import type { ReactorDirector, ReactorInboundEvent, ReactorState, ReactorCapabilities, ReactorAction, AssistantTurn, ToolDefinition } from "@intx/types/runtime";
2
+ /**
3
+ * Decision returned by an `afterInferenceDone` policy hook.
4
+ *
5
+ * continue — proceed with the director's normal post-inference logic
6
+ * (tool extraction, reply, or wait per the existing flow).
7
+ * abort — terminate the agent. Routes to `[checkpoint, done]` and
8
+ * the reactor shuts down. Stronger than the
9
+ * `inference.error` branch, which only replies and stays
10
+ * alive — `abort` is for "session is over, do not accept
11
+ * further inputs."
12
+ * halt — pause the current cycle without terminating. Routes to
13
+ * `[checkpoint, reply]`; the reply returns the reactor to
14
+ * waiting for the next inbound event, so it stays alive.
15
+ * There is no auto-resume; an external event (mail, gate
16
+ * clearance, etc.) must reach the reactor for the agent to
17
+ * make progress again.
18
+ *
19
+ * `reason` on a `halt` becomes the connector reply text verbatim, so
20
+ * policy authors choose what is safe to surface to the user. On an
21
+ * `abort` the reason is not surfaced: a terminal action cannot carry a
22
+ * reply, since a reply invites continuation. Delivering an abort reason
23
+ * to the user needs a dedicated terminal-notice path, which does not
24
+ * exist today.
25
+ */
26
+ export type AfterInferenceDecision = {
27
+ type: "continue";
28
+ } | {
29
+ type: "abort";
30
+ reason: string;
31
+ } | {
32
+ type: "halt";
33
+ reason: string;
34
+ };
35
+ /**
36
+ * Function shape for an after-inference-done policy hook.
37
+ *
38
+ * The hook fires only on `inference.done` (a successful cycle). Errored
39
+ * cycles do not invoke it. `mode: "reactive"` does not change firing —
40
+ * the hook gates the entire `inference.done` branch, including the
41
+ * reactive-wait shortcut, so a budget check applies to reactive agents
42
+ * the same way it does to conversational ones.
43
+ *
44
+ * The hook receives the post-cycle `ReactorState` (with `lastCycleSource`
45
+ * and `lastCycleUsage` populated for the just-completed call) and the
46
+ * assistant turn. Returns a decision (sync or async) that controls
47
+ * whether the director continues, terminates the agent, or pauses the
48
+ * cycle.
49
+ *
50
+ * Canonical use case: cost-aware gating. Read `state.lastCycleSource`
51
+ * + `state.lastCycleUsage`, price the call against user-supplied rate
52
+ * data, decide whether the budget is exhausted. Token caps, time caps,
53
+ * wallet checks, and governance triggers fit the same shape; the
54
+ * type stays policy-agnostic.
55
+ *
56
+ * "Downgrade to cheaper model" policies do NOT use this hook to return
57
+ * a new source. Compose them via an external observer of
58
+ * `lastCycleSource` / `lastCycleUsage` that calls `setSource` from
59
+ * outside the director.
60
+ *
61
+ * The hook blocks the reactor's inference.done branch: keep its
62
+ * latency low. The return type admits a Promise, but every await
63
+ * inside the hook is wall-clock time the agent isn't making progress.
64
+ * Small lookups (in-memory caches, fast DB reads) are fine; arbitrary
65
+ * waits are not.
66
+ *
67
+ * Tool calls and `halt`: if the model emitted tool calls and the hook
68
+ * returns `halt` (or `abort`), those tool calls are dropped — the
69
+ * director never executes them. On resume, the model's next inference
70
+ * sees an assistant turn with unanswered tool calls; depending on the
71
+ * provider this is either a validation error or a confused model.
72
+ * Policy authors that combine `halt` with tool-heavy agents need to
73
+ * understand this.
74
+ */
75
+ export type AfterInferenceHook = (state: ReactorState, turn: AssistantTurn) => AfterInferenceDecision | Promise<AfterInferenceDecision>;
76
+ export type DefaultDirectorPolicy = {
77
+ /**
78
+ * Controls the agent's behavior after inference completes.
79
+ *
80
+ * "conversational" (default) — The standard agentic loop. After tools
81
+ * complete, re-infer so the model can reason about results, issue more
82
+ * tool calls, or compose a reply. When inference produces text without
83
+ * tool calls, send it as a connector reply.
84
+ *
85
+ * "reactive" — The agent acts on each message by executing tools, then
86
+ * returns to the event loop to wait for the next inbound event. It does
87
+ * not re-infer after tools complete and does not send connector replies.
88
+ * Use this for agents that perform a single action per message.
89
+ */
90
+ mode?: "conversational" | "reactive";
91
+ /**
92
+ * Optional policy hook fired after every successful `inference.done`.
93
+ * See `AfterInferenceHook` for the contract: firing boundary, return
94
+ * shape, composition patterns, and policy-author caveats.
95
+ *
96
+ * If the hook throws or rejects, the director catches the error,
97
+ * routes to `{ type: "abort", reason: "afterInferenceDone policy
98
+ * threw: <message>" }`, and logs at error level. The director's
99
+ * never-throws contract is preserved.
100
+ */
101
+ afterInferenceDone?: AfterInferenceHook;
102
+ };
103
+ export declare class DefaultDirector implements ReactorDirector {
104
+ private readonly systemPrompt;
105
+ private readonly toolDefinitions;
106
+ private readonly policy;
107
+ private pendingToolResults;
108
+ constructor(systemPrompt: string, toolDefinitions?: ToolDefinition[], policy?: DefaultDirectorPolicy);
109
+ decide(event: ReactorInboundEvent, state: ReactorState, capabilities: ReactorCapabilities): Promise<ReactorAction | ReactorAction[]>;
110
+ }
111
+ export declare function createDefaultDirector(systemPrompt: string, toolDefinitions?: ToolDefinition[], policy?: DefaultDirectorPolicy): ReactorDirector;