@intx/inference 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.d.ts CHANGED
@@ -6,18 +6,20 @@ export type BuiltRequest = {
6
6
  };
7
7
  export type RequestBuilder = (messages: ConversationTurn[], model: string, options: InferenceOptions) => BuiltRequest;
8
8
  export type ResponseParser = (sseData: string) => InferenceEvent[];
9
+ export type JSONResponseParser = (body: string) => InferenceEvent[];
9
10
  export type RetryAfterExtractor = (headers: Headers) => number | undefined;
10
11
  export type PacingExtractor = (headers: Headers) => number | undefined;
11
12
  export type ProviderAdapter = {
12
13
  buildRequest: RequestBuilder;
13
14
  parseResponse: ResponseParser;
15
+ parseJSONResponse: JSONResponseParser;
14
16
  extractRetryAfterMs?: RetryAfterExtractor;
15
17
  extractPacingDelayMs?: PacingExtractor;
16
18
  };
17
- export type AdapterFactory = (source: LastCycleSource) => ProviderAdapter;
19
+ export type AdapterFactory = (source: LastCycleSource, quirks?: unknown) => ProviderAdapter;
18
20
  export type AdapterRegistry = {
19
21
  has(provider: string): boolean;
20
- resolve(source: LastCycleSource): ProviderAdapter;
22
+ resolve(source: LastCycleSource, quirks?: unknown): ProviderAdapter;
21
23
  };
22
24
  /**
23
25
  * Builds an adapter registry from a map of provider identifier to adapter
package/dist/adapter.js CHANGED
@@ -20,12 +20,12 @@ export function createAdapterRegistry(factories) {
20
20
  has(provider) {
21
21
  return byProvider.has(provider);
22
22
  },
23
- resolve(source) {
23
+ resolve(source, quirks) {
24
24
  const factory = byProvider.get(source.provider);
25
25
  if (factory === undefined) {
26
26
  throw new Error(`Unknown inference provider: ${source.provider}`);
27
27
  }
28
- return factory(source);
28
+ return factory(source, quirks);
29
29
  },
30
30
  };
31
31
  }
@@ -1,4 +1,4 @@
1
- import { type BlobReader, type AuditStore, type BeforeToolExtension, type Compactor, type ContextStore, type ContextTransform, type InferenceSource, type ReactorDirector, type ToolResultTransform, type ToolRunner } from "@intx/types/runtime";
1
+ import { type BlobReader, type AuditStore, type BeforeToolExtension, type Compactor, type ContextStore, type ContextTransform, type InferenceSource, type ReactorDirector, type ToolDefinition, type ToolResultTransform, type ToolRunner } from "@intx/types/runtime";
2
2
  import { type AuditCollector } from "./audit-collector.js";
3
3
  import { type AuthzExtensionOptions } from "./authz-extension.js";
4
4
  import type { CorrelationValidator } from "./correlation.js";
@@ -31,6 +31,13 @@ export type ReactorAssemblyConfig = {
31
31
  contextStore: ContextStore;
32
32
  onEvent: (event: ReactorEmittedEvent) => void;
33
33
  authorize?: AuthzExtensionOptions["authorize"];
34
+ /**
35
+ * Tool definitions forwarded to the authz extension so it can build the
36
+ * approver-facing snapshot at an `ask` suspension. Only consumed when
37
+ * `authorize` is also supplied. Omitting it puts the authz extension in its
38
+ * no-snapshot mode; the production edge always supplies the resolved set.
39
+ */
40
+ toolDefinitions?: readonly ToolDefinition[];
34
41
  auditStore?: AuditStore;
35
42
  beforeToolExtensions?: BeforeToolExtension[];
36
43
  toolResultTransforms?: ToolResultTransform[];
package/dist/assembly.js CHANGED
@@ -24,7 +24,7 @@ const DEFAULT_SIZE_CAP_MAX_CHARS = 10_000;
24
24
  * different composition.
25
25
  */
26
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;
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
28
  // Audit collector is created up-front so the authz extension can route its
29
29
  // decisions through `onDecision`. When no auditStore is supplied, no
30
30
  // collector is created and authz runs without decision recording.
@@ -51,6 +51,7 @@ export function createReactorAssembly(config) {
51
51
  ...(auditCollector !== undefined
52
52
  ? { onDecision: (d) => auditCollector.onDecision(d) }
53
53
  : {}),
54
+ ...(toolDefinitions !== undefined ? { toolDefinitions } : {}),
54
55
  })
55
56
  : undefined;
56
57
  const composedBeforeToolExtensions = authzExtension !== undefined
@@ -1,4 +1,4 @@
1
- import type { BeforeToolExtension } from "@intx/types/runtime";
1
+ import type { BeforeToolExtension, ToolDefinition } from "@intx/types/runtime";
2
2
  import type { Effect } from "@intx/types/authz";
3
3
  export type AuthzMatchedGrant = {
4
4
  id: string;
@@ -28,5 +28,19 @@ export type AuthzDecision = {
28
28
  export type AuthzExtensionOptions<Ctx = unknown> = {
29
29
  authorize: (resource: string, action: string, context: Ctx) => Promise<AuthzCallResult>;
30
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[];
31
45
  };
32
46
  export declare function createAuthzExtension<Ctx = unknown>(opts: AuthzExtensionOptions<Ctx>): BeforeToolExtension;
@@ -7,7 +7,7 @@
7
7
  // Effects:
8
8
  // allow → tool proceeds
9
9
  // deny → tool blocked
10
- // ask → tool blocked (gate-based approval deferred to a future commit)
10
+ // ask → tool suspended (parked awaiting an external approval decision)
11
11
  // null → tool blocked (fail-closed: no grants matched)
12
12
  //
13
13
  // The action is always "invoke" — all tool calls are invocations. If
@@ -20,12 +20,15 @@
20
20
  // The onDecision callback must not throw. If it does, the exception is
21
21
  // logged but swallowed so it cannot interfere with the authorization
22
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;
23
28
  function formatBlockReason(effect, resource, action) {
24
29
  switch (effect) {
25
30
  case "deny":
26
31
  return `Denied by policy: ${resource}/${action}`;
27
- case "ask":
28
- return `Requires approval: ${resource}/${action}`;
29
32
  case null:
30
33
  return `No matching grants for ${resource}/${action}`;
31
34
  }
@@ -50,7 +53,25 @@ export function createAuthzExtension(opts) {
50
53
  // safe default at this layer.
51
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)
52
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;
53
71
  return {
72
+ grantOneShot(id) {
73
+ approvedOnce.add(id);
74
+ },
54
75
  async beforeTool(call) {
55
76
  const resource = `tool:${call.name}`;
56
77
  const action = "invoke";
@@ -75,10 +96,11 @@ export function createAuthzExtension(opts) {
75
96
  safeOnDecision(opts.onDecision, decision);
76
97
  throw cause;
77
98
  }
78
- const blocked = result.effect !== "allow";
79
- const blockReason = result.effect === "deny" ||
80
- result.effect === "ask" ||
81
- result.effect === null
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
82
104
  ? formatBlockReason(result.effect, resource, action)
83
105
  : undefined;
84
106
  const decision = {
@@ -89,12 +111,74 @@ export function createAuthzExtension(opts) {
89
111
  effect: result.effect,
90
112
  resolvedBy: result.resolvedBy,
91
113
  matchingGrants: result.matchingGrants,
92
- blocked,
114
+ blocked: blockReason !== undefined,
93
115
  blockReason,
94
116
  error: undefined,
95
117
  };
96
118
  safeOnDecision(opts.onDecision, decision);
97
- return blockReason;
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" };
98
182
  },
99
183
  };
100
184
  }
@@ -18,6 +18,7 @@ export interface CorrelationValidator {
18
18
  export declare function createCorrelationRegistry(): {
19
19
  register: (op: PendingOperation) => void;
20
20
  lookup: (correlationId: string) => PendingOperation | undefined;
21
+ findByGateId: (gateId: string) => PendingOperation | undefined;
21
22
  remove: (correlationId: string) => boolean;
22
23
  all: () => PendingOperation[];
23
24
  hasAny: () => boolean;
@@ -19,6 +19,13 @@ export function createCorrelationRegistry() {
19
19
  function lookup(correlationId) {
20
20
  return operations.get(correlationId);
21
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
+ }
22
29
  function remove(correlationId) {
23
30
  return operations.delete(correlationId);
24
31
  }
@@ -28,5 +35,5 @@ export function createCorrelationRegistry() {
28
35
  function hasAny() {
29
36
  return operations.size > 0;
30
37
  }
31
- return { register, lookup, remove, all, hasAny };
38
+ return { register, lookup, findByGateId, remove, all, hasAny };
32
39
  }
@@ -1,4 +1,4 @@
1
- import type { ReactorDirector, ReactorInboundEvent, ReactorState, ReactorCapabilities, ReactorAction, AssistantTurn, ToolDefinition } from "@intx/types/runtime";
1
+ import { type ReactorDirector, type ReactorInboundEvent, type ReactorState, type ReactorCapabilities, type ReactorAction, type AssistantTurn, type ToolDefinition } from "@intx/types/runtime";
2
2
  /**
3
3
  * Decision returned by an `afterInferenceDone` policy hook.
4
4
  *
@@ -10,6 +10,8 @@
10
10
  // inference.error → checkpoint + reply (error message to user)
11
11
  // abort → done
12
12
  // reactor.gate.cleared → checkpoint + infer (resume after gate)
13
+ // resume.execute_tools → execute_tools (re-run a parked approved call)
14
+ // resume.tool_result → checkpoint + infer (parked call denied/timed out)
13
15
  //
14
16
  // The inference.done branch additionally runs the optional afterInferenceDone
15
17
  // policy hook, whose continue/abort/halt decisions route independently of the
@@ -18,6 +20,7 @@
18
20
  // The director never throws. Inference errors are surfaced to the user as a
19
21
  // reply so the problem is visible, and the agent remains alive for retries.
20
22
  import { getLogger } from "@intx/log";
23
+ import { formatSafetyRatingText, } from "@intx/types/runtime";
21
24
  const logger = getLogger(["interchange", "inference", "default-director"]);
22
25
  function extractToolCalls(turn) {
23
26
  const calls = [];
@@ -33,14 +36,13 @@ function extractToolCalls(turn) {
33
36
  return calls;
34
37
  }
35
38
  function extractTextContent(turn) {
36
- // Both regular text and refusal blocks carry human-readable model
37
- // output that the connector needs to surface a refusal-only turn
38
- // (OpenAI strict-mode policy decline) would otherwise route through
39
- // the empty-response branch below and never reach the reply path,
40
- // leaving the human waiting for an answer the model already
41
- // declined to give. The structural "this was a refusal" signal is
42
- // preserved at the persistence layer (event-collector emits a
43
- // refusal turn-part); the reply path only needs the words.
39
+ // Text, refusal, and safety_rating blocks all carry human-readable
40
+ // output the connector needs to surface. A refusal-only or
41
+ // safety-only turn would otherwise route through the empty-response
42
+ // branch below and never reach the reply path, leaving the human
43
+ // waiting for an answer the model already declined or blocked.
44
+ // Structural part kinds are preserved at the persistence layer;
45
+ // the reply path only needs the words.
44
46
  const parts = [];
45
47
  for (const block of turn.content) {
46
48
  if (block.type === "text") {
@@ -49,6 +51,9 @@ function extractTextContent(turn) {
49
51
  else if (block.type === "refusal") {
50
52
  parts.push(block.reason);
51
53
  }
54
+ else if (block.type === "safety_rating") {
55
+ parts.push(formatSafetyRatingText(block));
56
+ }
52
57
  }
53
58
  return parts.join("\n").trim();
54
59
  }
@@ -151,6 +156,30 @@ export class DefaultDirector {
151
156
  // stop (abort), never because the model produced an empty turn.
152
157
  return [capabilities.checkpoint("inference-done"), capabilities.wait()];
153
158
  }
159
+ case "resume.execute_tools": {
160
+ // A resumed approval re-runs its parked tool call. The reactor drives
161
+ // the execution; this director owns the outstanding-result count, so
162
+ // seed it to the number of calls about to run — exactly as the
163
+ // inference.done branch seeds it for a fresh tool batch. Without this
164
+ // seed the count stays zero and the re-dispatched call's tool.done
165
+ // would decrement to -1 and re-infer off a negative count by accident.
166
+ this.pendingToolResults = event.calls.length;
167
+ return capabilities.executeTools(event.calls, false, true);
168
+ }
169
+ case "resume.tool_result": {
170
+ // A parked approval ended without running its tool (rejected or timed
171
+ // out). The reactor appends the synthetic error result that answers the
172
+ // parked call, then this re-infers once so the model sees the failure
173
+ // and continues. No tool ran, so pendingToolResults is untouched — the
174
+ // counter only gates batches of real executions.
175
+ return [
176
+ capabilities.checkpoint("resume-tool-result"),
177
+ capabilities.infer({
178
+ systemPrompt: this.systemPrompt,
179
+ tools: this.toolDefinitions,
180
+ }),
181
+ ];
182
+ }
154
183
  case "tool.done": {
155
184
  this.pendingToolResults--;
156
185
  if (this.pendingToolResults > 0) {
package/dist/gates.d.ts CHANGED
@@ -19,6 +19,7 @@ export type GateSnapshot = {
19
19
  export declare function createGateManager(): {
20
20
  register: (gateId: string, type: GateType, timeoutMs: number, correlationId: string | undefined, onCleared: (gateId: string, reason: "resolved" | "timeout" | "shutdown") => void) => Promise<"resolved" | "timeout" | "shutdown">;
21
21
  clear: (gateId: string) => boolean;
22
+ clearSilently: (gateId: string) => boolean;
22
23
  shutdown: () => void;
23
24
  findByCorrelationId: (correlationId: string) => GateRecord | undefined;
24
25
  snapshot: () => GateSnapshot[];
package/dist/gates.js CHANGED
@@ -50,6 +50,21 @@ export function createGateManager() {
50
50
  gate.onCleared(gateId, "resolved");
51
51
  return true;
52
52
  }
53
+ // Clear a gate without invoking its onCleared callback. The caller has
54
+ // already decided how the reactor resumes and does not want the standard
55
+ // cleared-event enqueue that onCleared drives. Used by the approval
56
+ // re-dispatch path, which resumes by re-running the parked tool call rather
57
+ // than by re-inferring off a gate-cleared event: firing onCleared there
58
+ // would enqueue a second, spurious continuation.
59
+ function clearSilently(gateId) {
60
+ const gate = gates.get(gateId);
61
+ if (gate === undefined)
62
+ return false;
63
+ clearTimeout(gate.timer);
64
+ gates.delete(gateId);
65
+ gate.resolve("resolved");
66
+ return true;
67
+ }
53
68
  function shutdown() {
54
69
  const entries = Array.from(gates.values());
55
70
  gates.clear();
@@ -76,5 +91,13 @@ export function createGateManager() {
76
91
  function has(gateId) {
77
92
  return gates.has(gateId);
78
93
  }
79
- return { register, clear, shutdown, findByCorrelationId, snapshot, has };
94
+ return {
95
+ register,
96
+ clear,
97
+ clearSilently,
98
+ shutdown,
99
+ findByCorrelationId,
100
+ snapshot,
101
+ has,
102
+ };
80
103
  }