@intx/inference 0.3.0 → 0.4.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.
@@ -1,3 +1,4 @@
1
+ import type { CredentialMaterialResolver } from "@intx/types";
1
2
  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
3
  import { type AuditCollector } from "./audit-collector.js";
3
4
  import { type AuthzExtensionOptions } from "./authz-extension.js";
@@ -27,6 +28,12 @@ export type ReactorAssemblyConfig = {
27
28
  failOverToNextSource?: () => boolean;
28
29
  /** Reset `source` to the most-preferred source, in place. */
29
30
  resetToPreferredSource?: () => void;
31
+ /**
32
+ * Resolves the active source's credential secret by `credentialId` from the
33
+ * run's credential cell at send time. Threaded verbatim to the reactor;
34
+ * optional, defaulted fail-closed by the harness when omitted.
35
+ */
36
+ readMaterial?: CredentialMaterialResolver;
30
37
  toolRunner: ToolRunner;
31
38
  contextStore: ContextStore;
32
39
  onEvent: (event: ReactorEmittedEvent) => void;
@@ -51,6 +58,7 @@ export type ReactorAssemblyConfig = {
51
58
  inferenceRunner?: ReactorConfig["inferenceRunner"];
52
59
  gateTimeout?: number;
53
60
  shutdownTimeoutMs?: number;
61
+ doomLoopThreshold?: number | false;
54
62
  };
55
63
  /**
56
64
  * Output of `createReactorAssembly`. The `reactor` is started by the caller as
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, toolDefinitions, 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, readMaterial, toolRunner, contextStore, onEvent, authorize, toolDefinitions, auditStore, beforeToolExtensions: callerBeforeToolExtensions, toolResultTransforms: callerToolResultTransforms, contextTransforms, compactors, sizeCapMaxChars, afterCheckpoint: callerAfterCheckpoint, onShutdown: callerOnShutdown, deps, correlationValidator, inferenceRunner, gateTimeout, shutdownTimeoutMs, doomLoopThreshold, } = 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.
@@ -106,6 +106,7 @@ export function createReactorAssembly(config) {
106
106
  source,
107
107
  ...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}),
108
108
  ...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}),
109
+ ...(readMaterial !== undefined ? { readMaterial } : {}),
109
110
  toolRunner,
110
111
  contextStore,
111
112
  onEvent: composedOnEvent,
@@ -126,6 +127,7 @@ export function createReactorAssembly(config) {
126
127
  ...(inferenceRunner !== undefined ? { inferenceRunner } : {}),
127
128
  ...(gateTimeout !== undefined ? { gateTimeout } : {}),
128
129
  ...(shutdownTimeoutMs !== undefined ? { shutdownTimeoutMs } : {}),
130
+ ...(doomLoopThreshold !== undefined ? { doomLoopThreshold } : {}),
129
131
  };
130
132
  const reactor = createReactor(reactorConfig);
131
133
  const blobReader = createBlobReader(contextStore);
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { InferenceSource } from "@intx/types/runtime";
2
+ import type { CredentialMaterialResolver } from "@intx/types";
2
3
  /**
3
4
  * Sentinel for headers that carry the API key verbatim (no prefix).
4
5
  * Used by providers like Anthropic (`x-api-key`) and Google
@@ -21,4 +22,4 @@ export declare const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
21
22
  * surprising, and no legitimate adapter constructs sentinel-bearing
22
23
  * composite values.
23
24
  */
24
- export declare function injectCredentials(headers: Record<string, string>, source: InferenceSource): Record<string, string>;
25
+ export declare function injectCredentials(headers: Record<string, string>, source: InferenceSource, readMaterial: CredentialMaterialResolver): Record<string, string>;
package/dist/auth.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Sentinel placeholder strings adapters use in their built request
2
2
  // headers to declare which credential the harness should fill at send
3
3
  // time. The harness scans every header value and replaces exact-match
4
- // sentinels with material derived from `InferenceSource.apiKey`. Adapters
5
- // never see the API key.
4
+ // sentinels with the secret resolved from the source's `credentialId`
5
+ // against the run's credential cell. Adapters never see the API key.
6
6
  //
7
7
  // Each new provider adds a new header name + sentinel choice in its
8
8
  // `buildRequest`; the harness needs no per-provider knowledge. The
@@ -38,14 +38,23 @@ export const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
38
38
  * surprising, and no legitimate adapter constructs sentinel-bearing
39
39
  * composite values.
40
40
  */
41
- export function injectCredentials(headers, source) {
41
+ export function injectCredentials(headers, source, readMaterial) {
42
+ // Resolve the source's secret lazily and once: only when a header actually
43
+ // carries a sentinel, so a request with no credential sentinel never touches
44
+ // the cell, and the fail-closed read (revoked/absent credential) surfaces only
45
+ // when the secret is genuinely needed.
46
+ let cachedSecret;
47
+ const secret = () => {
48
+ cachedSecret ??= readMaterial(source.credentialId).secret;
49
+ return cachedSecret;
50
+ };
42
51
  const result = {};
43
52
  for (const [name, value] of Object.entries(headers)) {
44
53
  if (value === CREDENTIAL_SENTINEL) {
45
- result[name] = source.apiKey;
54
+ result[name] = secret();
46
55
  }
47
56
  else if (value === BEARER_CREDENTIAL_SENTINEL) {
48
- result[name] = `Bearer ${source.apiKey}`;
57
+ result[name] = `Bearer ${secret()}`;
49
58
  }
50
59
  else {
51
60
  result[name] = value;
package/dist/harness.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ConversationTurn, InferenceEvent, InferenceOptions, InferenceSource } from "@intx/types/runtime";
2
+ import type { CredentialMaterialResolver } from "@intx/types";
2
3
  import type { AdapterRegistry } from "./adapter.js";
3
4
  /**
4
5
  * Default per-call inactivity timeout (ms). Two minutes is conservative
@@ -86,6 +87,7 @@ export type InferenceHarnessOptions = {
86
87
  inferenceOptions?: InferenceOptions;
87
88
  signal?: AbortSignal;
88
89
  nextSeq: () => number;
90
+ readMaterial?: CredentialMaterialResolver;
89
91
  deps: Dependencies;
90
92
  };
91
93
  /**
package/dist/harness.js CHANGED
@@ -71,6 +71,13 @@ export function createDependencies(adapters) {
71
71
  adapters,
72
72
  };
73
73
  }
74
+ // Fail-closed default resolver, installed when a caller supplies no
75
+ // `readMaterial`. It throws only if a request actually reaches a credential
76
+ // sentinel, so a sentinel-free mock harness runs without a resolver while a
77
+ // real credentialed request surfaces the missing wiring loudly.
78
+ const unconfiguredCredentialResolver = (credentialId) => {
79
+ throw new Error(`no credential resolver supplied to the inference harness, but a request needs the secret for credential ${credentialId}`);
80
+ };
74
81
  /**
75
82
  * Run one fetch lifecycle and yield its events. Ends on the first
76
83
  * `inference.error` or `inference.done`. The outer `runInference`
@@ -82,7 +89,7 @@ export function createDependencies(adapters) {
82
89
  * directly would bypass retry handling.
83
90
  */
84
91
  async function* runSingleAttempt(opts) {
85
- const { turns, source, inferenceOptions, signal, nextSeq, deps } = opts;
92
+ const { turns, source, inferenceOptions, signal, nextSeq, readMaterial, deps, } = opts;
86
93
  // Per-call options override source-bound defaults. The merge happens
87
94
  // here, once, so the adapter and timeout-resolution paths below all
88
95
  // see the effective option set without having to remember the
@@ -181,7 +188,7 @@ async function* runSingleAttempt(opts) {
181
188
  }
182
189
  // Resolve the full URL and inject credentials.
183
190
  const url = resolveURL(builtRequest.url, source.baseURL);
184
- const headers = injectCredentials(builtRequest.headers, source);
191
+ const headers = injectCredentials(builtRequest.headers, source, readMaterial ?? unconfiguredCredentialResolver);
185
192
  // Per-call timeouts. The inactivity timer fires when the harness
186
193
  // hasn't yielded an event for `inactivityTimeoutMs`; the total timer
187
194
  // is a wall-clock cap from fetch onwards. We own one AbortController,
@@ -449,12 +449,7 @@ const MessageStart = type({
449
449
  });
450
450
  const MessageStop = type({ type: "'message_stop'" });
451
451
  const Ping = type({ type: "'ping'" });
452
- const AnthropicSSEEvent = ContentBlockDelta.or(ContentBlockStart)
453
- .or(ContentBlockStop)
454
- .or(MessageDelta)
455
- .or(MessageStart)
456
- .or(MessageStop)
457
- .or(Ping);
452
+ const AnthropicSSEEvent = type.or(ContentBlockDelta, ContentBlockStart, ContentBlockStop, MessageDelta, MessageStart, MessageStop, Ping);
458
453
  // Maps Anthropic's wire usage object onto the internal TokenUsage. Anthropic
459
454
  // never reports a distinct thinking-token count, so `thinking` is always 0.
460
455
  // Shared by the streaming `message_start` path and the non-streaming
package/dist/reactor.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { InboundMessage, InferenceEvent, InferenceSource, ReactorDirector, ContextStore, ToolRunner, AbortReason, BeforeToolExtension, ToolResultTransform, ContextTransform, Compactor } from "@intx/types/runtime";
2
+ import type { CredentialMaterialResolver } from "@intx/types";
2
3
  import type { Dependencies, InferenceHarnessOptions } from "./harness.js";
3
4
  import type { CorrelationValidator } from "./correlation.js";
4
5
  export type ReactorEmittedEvent = InferenceEvent | {
@@ -20,6 +21,13 @@ export type ReactorConfig = {
20
21
  failOverToNextSource?: () => boolean;
21
22
  /** Reset `source` to the most-preferred source, in place. */
22
23
  resetToPreferredSource?: () => void;
24
+ /**
25
+ * Resolves the active source's credential secret by `credentialId` from the
26
+ * run's credential cell at send time. Read live per attempt, so a failover to
27
+ * a source with a different `credentialId` resolves that source's credential.
28
+ * Optional: the harness installs a fail-closed default when it is omitted.
29
+ */
30
+ readMaterial?: CredentialMaterialResolver;
23
31
  toolRunner: ToolRunner;
24
32
  contextStore: ContextStore;
25
33
  correlationValidator?: CorrelationValidator;
@@ -34,6 +42,16 @@ export type ReactorConfig = {
34
42
  onShutdown?: () => Promise<void>;
35
43
  gateTimeout?: number;
36
44
  shutdownTimeoutMs?: number;
45
+ /**
46
+ * Number of consecutive identical tool-call turns that trips doom-loop
47
+ * detection. A turn's identity is its batch of executed tool calls; a
48
+ * runaway model repeating the same call burns inference cost with no
49
+ * progress. On the Nth consecutive identical turn the reactor emits a fatal
50
+ * `reactor.error` and shuts the run down. Must be a positive integer.
51
+ * Pass `false` to disable doom-loop detection entirely. Defaults to
52
+ * `DEFAULT_DOOM_LOOP_THRESHOLD`.
53
+ */
54
+ doomLoopThreshold?: number | false;
37
55
  };
38
56
  export type Reactor = {
39
57
  /** Begin processing. Emits reactor.start. Must be called exactly once. */
package/dist/reactor.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // (INFERENCE.md § Agent Reactor)
15
15
  import { getLogger } from "@intx/log";
16
16
  import { ApprovalDecision, signalKindToGateType } from "@intx/types";
17
+ import { canonicalJsonStringify } from "@intx/types/wire-definition-hash";
17
18
  import { type } from "arktype";
18
19
  import { runInference } from "./harness.js";
19
20
  import { createCapabilities } from "./director.js";
@@ -33,21 +34,54 @@ const SUSPENDED = Symbol("suspended");
33
34
  function assertNever(x) {
34
35
  throw new Error(`Unhandled resume case: ${JSON.stringify(x)}`);
35
36
  }
36
- function buildHarnessOpts(turns, source, options, signal, nextSeq, deps) {
37
- if (options !== undefined) {
38
- return {
39
- turns,
40
- source,
41
- inferenceOptions: options,
42
- signal,
43
- nextSeq,
44
- deps,
45
- };
46
- }
47
- return { turns, source, signal, nextSeq, deps };
37
+ function buildHarnessOpts(turns, source, options, signal, nextSeq, readMaterial, deps) {
38
+ // exactOptionalPropertyTypes is on: only set the optional keys when defined.
39
+ return {
40
+ turns,
41
+ source,
42
+ ...(options !== undefined ? { inferenceOptions: options } : {}),
43
+ signal,
44
+ nextSeq,
45
+ ...(readMaterial !== undefined ? { readMaterial } : {}),
46
+ deps,
47
+ };
48
48
  }
49
49
  const DEFAULT_GATE_TIMEOUT_MS = 3_600_000;
50
50
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
51
+ const DEFAULT_DOOM_LOOP_THRESHOLD = 3;
52
+ /**
53
+ * Resolve the caller-facing `doomLoopThreshold` into the reactor's internal
54
+ * form: a positive integer when detection is active, or `null` when it is
55
+ * disabled. `undefined` (omitted) takes the default; `false` disables; a
56
+ * number is validated here — the construction edge is the one place that owns
57
+ * the default and rejects a malformed value loudly, so a stray `0`, negative,
58
+ * or non-integer throws rather than silently disarming the guard. Downstream
59
+ * code compares against the returned `number | null` and never sees the raw
60
+ * `false`, whose numeric coercion would otherwise trip the loop immediately.
61
+ */
62
+ function resolveDoomLoopThreshold(raw) {
63
+ if (raw === false)
64
+ return null;
65
+ if (raw === undefined)
66
+ return DEFAULT_DOOM_LOOP_THRESHOLD;
67
+ if (!Number.isInteger(raw) || raw < 1) {
68
+ throw new Error(`doomLoopThreshold must be a positive integer or false, got ${String(raw)}`);
69
+ }
70
+ return raw;
71
+ }
72
+ /**
73
+ * Order-independent identity of a batch of executed tool calls. Each call
74
+ * canonicalizes to its name and arguments (the call `id` is excluded, since it
75
+ * differs on every request); sorting makes a parallel batch match regardless
76
+ * of the order the model emitted its calls. Two turns share a signature when
77
+ * they run the same multiset of `(name, arguments)` pairs.
78
+ */
79
+ function toolBatchSignature(calls) {
80
+ return calls
81
+ .map((call) => canonicalJsonStringify({ name: call.name, arguments: call.arguments }))
82
+ .sort()
83
+ .join("\n");
84
+ }
51
85
  /**
52
86
  * Creates a reactor instance bound to the given configuration.
53
87
  * Call `start()` to begin the event loop.
@@ -61,6 +95,10 @@ export function createReactor(config) {
61
95
  failOverToNextSource = () => false, resetToPreferredSource = () => {
62
96
  /* single-source: nothing to reset */
63
97
  }, } = config;
98
+ // Resolved once at the construction edge: a positive integer while detection
99
+ // is active, or `null` when the caller disabled it with `false`. Every
100
+ // downstream comparison reads this binding, never the raw config value.
101
+ const doomLoopThreshold = resolveDoomLoopThreshold(config.doomLoopThreshold);
64
102
  // Monotonic sequence counter, scoped to this session.
65
103
  let seq = 0;
66
104
  function nextSeq() {
@@ -156,9 +194,22 @@ export function createReactor(config) {
156
194
  // produces unambiguous start/end pairs downstream.
157
195
  let currentMessageRunId = null;
158
196
  let currentMessageId = null;
197
+ // Doom-loop detection state, scoped to the current message run. Each executed
198
+ // tool-call turn is reduced to a batch signature; consecutive identical
199
+ // signatures accumulate here, and the run is broken when the count reaches
200
+ // `doomLoopThreshold`. This is run-scoped, not cycle-scoped: it resets only in
201
+ // `openMessageRun`, never in `resetCycleAccumulators`. It is also deliberately
202
+ // ephemeral (closure state, not persisted) -- a mid-run restart resets it to
203
+ // zero and the loop simply re-accumulates and trips a few turns later.
204
+ let lastToolBatchSignature = null;
205
+ let toolBatchRepeatCount = 0;
206
+ let lastToolBatchNames = [];
159
207
  function openMessageRun(messageId) {
160
208
  currentMessageRunId = crypto.randomUUID();
161
209
  currentMessageId = messageId;
210
+ lastToolBatchSignature = null;
211
+ toolBatchRepeatCount = 0;
212
+ lastToolBatchNames = [];
162
213
  emit({
163
214
  type: "message.run.started",
164
215
  seq: nextSeq(),
@@ -448,7 +499,7 @@ export function createReactor(config) {
448
499
  // prior cycle must not leave the agent permanently demoted.
449
500
  resetToPreferredSource();
450
501
  for (;;) {
451
- const harnessOpts = buildHarnessOpts(prompt, config.source, options, signal, nextSeq, deps);
502
+ const harnessOpts = buildHarnessOpts(prompt, config.source, options, signal, nextSeq, config.readMaterial, deps);
452
503
  let lastDone;
453
504
  let lastError;
454
505
  for await (const event of inferenceRunner(harnessOpts)) {
@@ -627,6 +678,25 @@ export function createReactor(config) {
627
678
  // Suspended calls are parked, not answered: they contribute no tool
628
679
  // result to history and no tool.done continuation event.
629
680
  const results = outcomes.filter((o) => o !== SUSPENDED);
681
+ // Doom-loop accounting keys off the calls that actually ran, aligned to
682
+ // their outcome by index (both the parallel and serial paths above keep
683
+ // `outcomes` in `calls` order). A parked call contributes nothing, so a
684
+ // suspend-then-redispatch cycle counts its one real execution once. The
685
+ // loop reads `toolBatchRepeatCount` after this returns and breaks the run
686
+ // when it reaches the threshold. A `null` threshold means detection is
687
+ // disabled, so the accounting is skipped entirely.
688
+ const ranCalls = calls.filter((_call, i) => outcomes[i] !== SUSPENDED);
689
+ if (doomLoopThreshold !== null && ranCalls.length > 0) {
690
+ const signature = toolBatchSignature(ranCalls);
691
+ if (signature === lastToolBatchSignature) {
692
+ toolBatchRepeatCount += 1;
693
+ }
694
+ else {
695
+ lastToolBatchSignature = signature;
696
+ toolBatchRepeatCount = 1;
697
+ }
698
+ lastToolBatchNames = ranCalls.map((call) => call.name);
699
+ }
630
700
  cycleToolCallsExecuted += results.length;
631
701
  if (addToHistory && stateManager !== null && results.length > 0) {
632
702
  stateManager.appendTurn(createToolResultTurn(results));
@@ -1065,6 +1135,17 @@ export function createReactor(config) {
1065
1135
  const parallel = toolsAction.parallel !== false;
1066
1136
  const addToHistory = toolsAction.addToHistory !== false;
1067
1137
  await executeTools(toolsAction.calls, parallel, addToHistory);
1138
+ if (doomLoopThreshold !== null &&
1139
+ toolBatchRepeatCount >= doomLoopThreshold) {
1140
+ const tools = lastToolBatchNames.join(", ");
1141
+ const message = `Doom loop detected: an identical tool batch (${tools}) executed ` +
1142
+ `${String(doomLoopThreshold)} times consecutively`;
1143
+ emitError(message, true);
1144
+ closeMessageRun("failed", { message, kind: "doom_loop" });
1145
+ done = true;
1146
+ await initiateShutdown();
1147
+ break;
1148
+ }
1068
1149
  continue;
1069
1150
  }
1070
1151
  // No infer/tools/reply/suspend/wait/compact action — if a checkpoint
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@intx/inference",
3
3
  "description": "Provider-agnostic inference runtime with Anthropic, OpenAI, and Google GenAI adapters",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "license": "LGPL-2.1-only",
6
6
  "type": "module",
7
7
  "exports": {
@@ -17,12 +17,12 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
- "@intx/log": "0.3.0",
21
- "@intx/types": "0.3.0",
20
+ "@intx/log": "0.4.0",
21
+ "@intx/types": "0.4.0",
22
22
  "arktype": "^2.1.29"
23
23
  },
24
24
  "devDependencies": {
25
- "@intx/mime": "0.3.0"
25
+ "@intx/mime": "0.4.0"
26
26
  },
27
27
  "files": [
28
28
  "dist",