@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.
- package/LICENSE +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +68 -0
- package/dist/assembly.js +132 -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 +32 -0
- package/dist/authz-extension.js +100 -0
- package/dist/correlation.d.ts +25 -0
- package/dist/correlation.js +32 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +199 -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 +27 -0
- package/dist/gates.js +80 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1319 -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 +33 -0
- package/dist/providers/anthropic.js +670 -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 +3 -0
- package/dist/providers/google-genai.js +1196 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +3 -0
- package/dist/providers/openai.js +609 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +920 -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 +117 -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 +21 -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/dist/assembly.js
ADDED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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;
|