@intx/inference 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +37 -0
  31. package/dist/providers/anthropic.js +917 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +5 -0
  35. package/dist/providers/google-genai.js +1205 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +132 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +22 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,31 @@
1
+ import type { RetryPolicy } from "@intx/types/runtime";
2
+ /**
3
+ * The default retry policy bundled with `@intx/inference`. Behaviour by
4
+ * `InferenceError.category`:
5
+ *
6
+ * - `credential_failure`, `context_overflow`, `fatal`, `aborted`,
7
+ * `protocol_mismatch` — never retry. These categories describe a
8
+ * deterministic per-call failure that re-issuing the identical
9
+ * request cannot resolve: bad credentials stay bad, a too-large
10
+ * context stays too large, a caller-driven abort is intentional,
11
+ * and a wire-shape mismatch will repeat on the next response.
12
+ * - `retryable`, `timeout` — up to 3 attempts total. 500ms before
13
+ * attempt 2, then 1000ms before attempt 3. Exponential rather than
14
+ * constant so a server taking longer than usual to recover gets a
15
+ * slightly larger window each time without compounding into a long
16
+ * tail.
17
+ * - `quota_exhausted` — up to 3 attempts total. The delay is taken
18
+ * from `error.retryAfterMs` when the provider returned one (the
19
+ * server told us when it would be ready); otherwise a flat
20
+ * `1000`ms baseline. The baseline does NOT grow across attempts —
21
+ * if 1s isn't long enough for a rate limit to clear, exponential
22
+ * backoff on top of the provider's own pacing instructions is more
23
+ * likely to mask a config problem than help. Operators who need
24
+ * exponential pacing for rate limits should supply a custom policy.
25
+ *
26
+ * The 3-attempt cap is the same across every retryable category: a
27
+ * single transient flake is plausible, two is rare, and a third
28
+ * failure across the backoff schedule is a real signal that the call
29
+ * is not going to succeed on its own.
30
+ */
31
+ export declare function createDefaultRetryPolicy(): RetryPolicy;
@@ -7,21 +7,13 @@
7
7
  // transient-flake surface (TCP resets, 5xx, rate-limit jitter, half-
8
8
  // streamed connection drops) without masking a genuinely persistent
9
9
  // failure under a retry loop a human would never notice.
10
-
11
- import type {
12
- RetryPolicy,
13
- RetrySituation,
14
- RetryDecision,
15
- } from "@intx/types/runtime";
16
-
17
10
  const MAX_ATTEMPTS = 3;
18
11
  // Indexed by the failed attempt number (1-indexed): the delay BEFORE
19
12
  // the attempt-after-this-one starts. Length must be `MAX_ATTEMPTS - 1`
20
13
  // because after the final attempt fails the policy aborts. Drives the
21
14
  // `retryable` and `timeout` schedules.
22
- const RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS: readonly number[] = [500, 1000];
15
+ const RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS = [500, 1000];
23
16
  const QUOTA_DEFAULT_DELAY_MS = 1000;
24
-
25
17
  /**
26
18
  * The default retry policy bundled with `@intx/inference`. Behaviour by
27
19
  * `InferenceError.category`:
@@ -51,49 +43,45 @@ const QUOTA_DEFAULT_DELAY_MS = 1000;
51
43
  * failure across the backoff schedule is a real signal that the call
52
44
  * is not going to succeed on its own.
53
45
  */
54
- export function createDefaultRetryPolicy(): RetryPolicy {
55
- return (situation: RetrySituation): RetryDecision => {
56
- const { error, attempt } = situation;
57
-
58
- switch (error.category) {
59
- case "credential_failure":
60
- case "context_overflow":
61
- case "fatal":
62
- case "aborted":
63
- case "protocol_mismatch":
64
- return { kind: "abort" };
65
-
66
- case "retryable":
67
- case "timeout": {
68
- if (attempt >= MAX_ATTEMPTS) return { kind: "abort" };
69
- const delayMs = RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS[attempt - 1];
70
- if (delayMs === undefined) {
71
- // Unreachable in practice given the `attempt >= MAX_ATTEMPTS`
72
- // guard above, but the explicit narrowing keeps the schedule
73
- // table and the cap from drifting silently if anyone bumps
74
- // `MAX_ATTEMPTS` without extending the table.
75
- return { kind: "abort" };
46
+ export function createDefaultRetryPolicy() {
47
+ return (situation) => {
48
+ const { error, attempt } = situation;
49
+ switch (error.category) {
50
+ case "credential_failure":
51
+ case "context_overflow":
52
+ case "fatal":
53
+ case "aborted":
54
+ case "protocol_mismatch":
55
+ return { kind: "abort" };
56
+ case "retryable":
57
+ case "timeout": {
58
+ if (attempt >= MAX_ATTEMPTS)
59
+ return { kind: "abort" };
60
+ const delayMs = RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS[attempt - 1];
61
+ if (delayMs === undefined) {
62
+ // Unreachable in practice given the `attempt >= MAX_ATTEMPTS`
63
+ // guard above, but the explicit narrowing keeps the schedule
64
+ // table and the cap from drifting silently if anyone bumps
65
+ // `MAX_ATTEMPTS` without extending the table.
66
+ return { kind: "abort" };
67
+ }
68
+ return { kind: "retry", delayMs };
69
+ }
70
+ case "quota_exhausted":
71
+ if (attempt >= MAX_ATTEMPTS)
72
+ return { kind: "abort" };
73
+ return {
74
+ kind: "retry",
75
+ delayMs: error.retryAfterMs ?? QUOTA_DEFAULT_DELAY_MS,
76
+ };
77
+ default: {
78
+ // Exhaustiveness: if a new InferenceError.category lands
79
+ // without a clause here, the never-assignment fails at
80
+ // compile time rather than silently returning undefined
81
+ // from the policy callback.
82
+ const exhaustive = error.category;
83
+ throw new Error(`createDefaultRetryPolicy: unhandled error category ${String(exhaustive)}`);
84
+ }
76
85
  }
77
- return { kind: "retry", delayMs };
78
- }
79
-
80
- case "quota_exhausted":
81
- if (attempt >= MAX_ATTEMPTS) return { kind: "abort" };
82
- return {
83
- kind: "retry",
84
- delayMs: error.retryAfterMs ?? QUOTA_DEFAULT_DELAY_MS,
85
- };
86
-
87
- default: {
88
- // Exhaustiveness: if a new InferenceError.category lands
89
- // without a clause here, the never-assignment fails at
90
- // compile time rather than silently returning undefined
91
- // from the policy callback.
92
- const exhaustive: never = error.category;
93
- throw new Error(
94
- `createDefaultRetryPolicy: unhandled error category ${String(exhaustive)}`,
95
- );
96
- }
97
- }
98
- };
86
+ };
99
87
  }
package/dist/sse.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function parseSSE(stream: ReadableStream<Uint8Array>): AsyncIterable<string>;
package/dist/sse.js ADDED
@@ -0,0 +1,63 @@
1
+ // Server-Sent Events byte stream parser.
2
+ //
3
+ // Converts a ReadableStream<Uint8Array> (the raw HTTP response body) into an
4
+ // AsyncIterable<string> of SSE data payloads. Each yielded string is the
5
+ // value of one `data:` field. Comments (`:`) and blank-line separators are
6
+ // consumed internally. The `[DONE]` sentinel (OpenAI convention) terminates
7
+ // the iteration.
8
+ //
9
+ // The parser buffers incomplete lines across chunk boundaries so split chunks
10
+ // are handled correctly regardless of where chunk boundaries fall.
11
+ const decoder = new TextDecoder();
12
+ export async function* parseSSE(stream) {
13
+ const reader = stream.getReader();
14
+ let buffer = "";
15
+ try {
16
+ while (true) {
17
+ const { done, value } = await reader.read();
18
+ if (done) {
19
+ // Flush any remaining content in the buffer as a final line.
20
+ if (buffer.length > 0) {
21
+ const payload = extractDataPayload(buffer);
22
+ if (payload !== null) {
23
+ yield payload;
24
+ }
25
+ }
26
+ break;
27
+ }
28
+ buffer += decoder.decode(value, { stream: true });
29
+ // Process all complete lines (lines terminated by \n).
30
+ // A line ending in \r\n counts as terminated at the \n.
31
+ let newlineIndex;
32
+ while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
33
+ const rawLine = buffer.slice(0, newlineIndex);
34
+ buffer = buffer.slice(newlineIndex + 1);
35
+ // Strip trailing \r for CRLF line endings.
36
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
37
+ // Blank lines and comment lines are ignored.
38
+ if (line === "" || line.startsWith(":")) {
39
+ continue;
40
+ }
41
+ const payload = extractDataPayload(line);
42
+ if (payload === null) {
43
+ continue;
44
+ }
45
+ if (payload === "[DONE]") {
46
+ return;
47
+ }
48
+ yield payload;
49
+ }
50
+ }
51
+ }
52
+ finally {
53
+ reader.releaseLock();
54
+ }
55
+ }
56
+ function extractDataPayload(line) {
57
+ if (line.startsWith("data:")) {
58
+ // The spec allows an optional space after the colon.
59
+ const raw = line.slice(5);
60
+ return raw.startsWith(" ") ? raw.slice(1) : raw;
61
+ }
62
+ return null;
63
+ }
@@ -0,0 +1,23 @@
1
+ import type { ConversationTurn, LastCycleSource, PendingOperation, TokenUsage, ReactorState } from "@intx/types/runtime";
2
+ import type { GateSnapshot } from "./gates.js";
3
+ export type ReactorStateManager = ReturnType<typeof createStateManager>;
4
+ /**
5
+ * Creates a mutable state container. All mutations go through explicit methods;
6
+ * the `snapshot()` method produces an immutable view for the director.
7
+ */
8
+ export declare function createStateManager(sessionId: string, initialTurns: ConversationTurn[], initialOps: PendingOperation[], initialUsage: TokenUsage): {
9
+ appendTurn: (msg: ConversationTurn) => void;
10
+ replaceTurns: (next: ConversationTurn[]) => void;
11
+ addPendingOperation: (op: PendingOperation) => void;
12
+ removePendingOperation: (correlationId: string) => void;
13
+ accumUsage: (usage: TokenUsage) => void;
14
+ setLastCycleUsage: (usage: TokenUsage) => void;
15
+ setLastCycleSource: (source: LastCycleSource) => void;
16
+ setGatesSnapshot: (gates: GateSnapshot[]) => void;
17
+ addFork: (forkId: string, mode: "independent" | "child") => void;
18
+ removeFork: (forkId: string) => void;
19
+ getTurns: () => ConversationTurn[];
20
+ getPendingOperations: () => PendingOperation[];
21
+ getTokenUsage: () => TokenUsage;
22
+ snapshot: () => ReactorState;
23
+ };
package/dist/state.js ADDED
@@ -0,0 +1,100 @@
1
+ // Reactor state management: turn history, async operations, usage tracking.
2
+ //
3
+ // The state object is the authoritative view the director receives on every
4
+ // decision. It is mutable by the reactor only — the director receives a
5
+ // snapshot so it cannot corrupt the reactor's internal state.
6
+ //
7
+ // (INFERENCE.md § Agent Reactor › Director Decision Function)
8
+ /**
9
+ * Creates a mutable state container. All mutations go through explicit methods;
10
+ * the `snapshot()` method produces an immutable view for the director.
11
+ */
12
+ export function createStateManager(sessionId, initialTurns, initialOps, initialUsage) {
13
+ let turns = [...initialTurns];
14
+ const pendingOperations = new Map(initialOps.map((op) => [op.correlationId, op]));
15
+ const tokenUsage = { ...initialUsage };
16
+ let lastCycleUsage = null;
17
+ let lastCycleSource = null;
18
+ let activeGatesSnapshot = [];
19
+ const activeForks = [];
20
+ function appendTurn(msg) {
21
+ turns.push(msg);
22
+ }
23
+ function replaceTurns(next) {
24
+ turns = [...next];
25
+ }
26
+ function addPendingOperation(op) {
27
+ pendingOperations.set(op.correlationId, op);
28
+ }
29
+ function removePendingOperation(correlationId) {
30
+ pendingOperations.delete(correlationId);
31
+ }
32
+ function accumUsage(usage) {
33
+ tokenUsage.input += usage.input;
34
+ tokenUsage.output += usage.output;
35
+ tokenUsage.cacheRead += usage.cacheRead;
36
+ tokenUsage.cacheWrite += usage.cacheWrite;
37
+ tokenUsage.thinking += usage.thinking;
38
+ }
39
+ function setLastCycleUsage(usage) {
40
+ lastCycleUsage = { ...usage };
41
+ }
42
+ function setLastCycleSource(source) {
43
+ lastCycleSource = { ...source };
44
+ }
45
+ function setGatesSnapshot(gates) {
46
+ activeGatesSnapshot = gates;
47
+ }
48
+ function addFork(forkId, mode) {
49
+ activeForks.push({ forkId, mode });
50
+ }
51
+ function removeFork(forkId) {
52
+ const idx = activeForks.findIndex((f) => f.forkId === forkId);
53
+ if (idx !== -1)
54
+ activeForks.splice(idx, 1);
55
+ }
56
+ function getTurns() {
57
+ return turns;
58
+ }
59
+ function getPendingOperations() {
60
+ return Array.from(pendingOperations.values());
61
+ }
62
+ function getTokenUsage() {
63
+ return { ...tokenUsage };
64
+ }
65
+ function snapshot() {
66
+ return {
67
+ sessionId,
68
+ turns: turns.map((m) => ({
69
+ ...m,
70
+ content: m.content.map((b) => structuredClone(b)),
71
+ })),
72
+ pendingOperations: Array.from(pendingOperations.values()).map((op) => structuredClone(op)),
73
+ activeGates: activeGatesSnapshot.map((g) => ({
74
+ gateId: g.gateId,
75
+ type: g.type,
76
+ timeoutAt: g.timeoutAt,
77
+ })),
78
+ activeForks: activeForks.map((f) => ({ ...f })),
79
+ tokenUsage: { ...tokenUsage },
80
+ lastCycleUsage: lastCycleUsage !== null ? { ...lastCycleUsage } : null,
81
+ lastCycleSource: lastCycleSource !== null ? { ...lastCycleSource } : null,
82
+ };
83
+ }
84
+ return {
85
+ appendTurn,
86
+ replaceTurns,
87
+ addPendingOperation,
88
+ removePendingOperation,
89
+ accumUsage,
90
+ setLastCycleUsage,
91
+ setLastCycleSource,
92
+ setGatesSnapshot,
93
+ addFork,
94
+ removeFork,
95
+ getTurns,
96
+ getPendingOperations,
97
+ getTokenUsage,
98
+ snapshot,
99
+ };
100
+ }
@@ -0,0 +1,6 @@
1
+ export type ToolNameLimit = {
2
+ readonly provider: string;
3
+ readonly maxLength: number;
4
+ };
5
+ export declare function encodeToolName(name: string, limit: ToolNameLimit): string;
6
+ export declare function decodeToolName(wire: string): string;
@@ -0,0 +1,110 @@
1
+ // Invertible codec for tool names on the provider wire.
2
+ //
3
+ // Internal tool names are package-qualified ids like
4
+ // `@intx/tools-posix/sidecar-bundle:run_shell`. Provider function-name
5
+ // charsets are narrow (OpenAI `^[a-zA-Z0-9_-]{1,64}$`, Anthropic 128, Gemini
6
+ // with a leading-letter rule), and reject the `@`, `/`, `:`, and `.`
7
+ // characters these ids carry. This codec maps such a name to a
8
+ // wire-charset-safe form and back.
9
+ //
10
+ // Invariant: `decodeToolName(encodeToolName(x, ...)) === x`. It is
11
+ // load-bearing. Tool-call dispatch keys on the exact prefixed name in two
12
+ // places (the agent's `byName` map and the tool-package loader's per-bundle
13
+ // `nameMap`), so a name that does not round-trip lands as an `unknown tool`
14
+ // with no error at the point of the fault. The `encode`/`decode` naming
15
+ // advertises the invertibility on purpose: a `sanitize`-style name invites a
16
+ // future lossy "cleanup" that would break dispatch.
17
+ //
18
+ // Names that are already valid on the wire pass through untouched — the codec
19
+ // only rewrites names that genuinely need it. Rewritten names carry a
20
+ // distinctive `MARKER` prefix, and `decode` transforms only marker-prefixed
21
+ // names, so an ordinary wire-valid name a provider echoes (a tool the model
22
+ // named that never needed encoding, or a hallucination) is returned verbatim.
23
+ // The marker is what makes the round-trip unambiguous: a name that is already
24
+ // valid but happens to begin with the marker is force-encoded too, so a
25
+ // marker prefix on the wire always denotes an encoding.
26
+ //
27
+ // Rewriting escapes each out-of-charset character (and, so the sentinel stays
28
+ // unambiguous, each literal `-`) as `-XX`, its uppercase two-digit hex byte:
29
+ // `@`->`-40`, `/`->`-2F`, `:`->`-3A`, `.`->`-2E`, `-`->`-2D`. A `base64url`
30
+ // encoding (reusing `@intx/types/base64url`) was considered and rejected: it
31
+ // renders every name fully opaque, hurting both model tool-selection and
32
+ // debugging, and it would encode even the already-legible names this scheme
33
+ // leaves alone.
34
+ // A distinctive, letter-leading prefix that ordinary tool names do not start
35
+ // with. Letter-leading satisfies providers (Gemini) that require a
36
+ // letter/underscore leading character on every function name.
37
+ const MARKER = "IX_";
38
+ // Characters that survive a rewrite verbatim. `-` is deliberately excluded so
39
+ // it can serve as the escape sentinel inside a rewritten name.
40
+ const ESCAPE_PASSTHROUGH = /^[A-Za-z0-9_]$/;
41
+ // The provider wire charset. A name already matching this, that starts with a
42
+ // letter or underscore and does not collide with the marker, needs no rewrite.
43
+ const WIRE_SAFE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
44
+ const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
45
+ function needsRewrite(name) {
46
+ return !WIRE_SAFE.test(name) || name.startsWith(MARKER);
47
+ }
48
+ function rewrite(name) {
49
+ let out = MARKER;
50
+ for (const ch of name) {
51
+ if (ESCAPE_PASSTHROUGH.test(ch)) {
52
+ out += ch;
53
+ continue;
54
+ }
55
+ const code = ch.charCodeAt(0);
56
+ if (code > 0xff) {
57
+ throw new Error(`Cannot encode tool name "${name}": character "${ch}" is outside the ` +
58
+ `single-byte range the wire codec supports.`);
59
+ }
60
+ out += "-" + code.toString(16).toUpperCase().padStart(2, "0");
61
+ }
62
+ return out;
63
+ }
64
+ // Encode a tool name into a form valid for the provider's function-name
65
+ // charset. Names already valid on the wire pass through unchanged. Throws if
66
+ // the resulting wire name exceeds the provider's length limit — on the
67
+ // passthrough path too, since an already-valid name can still be too long — so
68
+ // a name too long for a provider surfaces as a fixable diagnostic rather than
69
+ // truncation, a silent collision, or an opaque upstream 400.
70
+ export function encodeToolName(name, limit) {
71
+ const wire = needsRewrite(name) ? rewrite(name) : name;
72
+ if (wire.length > limit.maxLength) {
73
+ throw new Error(`Tool name "${name}" is ${wire.length} chars on the wire, which ` +
74
+ `exceeds the ${limit.maxLength}-char limit for provider ` +
75
+ `"${limit.provider}". Shorten the tool bundle id or tool name.`);
76
+ }
77
+ return wire;
78
+ }
79
+ // Invert `encodeToolName`. Total: a wire name without the marker prefix, or a
80
+ // marker-prefixed name whose body is not a valid escaping, is returned
81
+ // unchanged. That covers both names that never needed encoding and
82
+ // hallucinated or provider-mangled names, which then fall through to the
83
+ // existing `unknown tool` handling — giving the model feedback to retry —
84
+ // rather than throwing and tearing down the stream over a bad tool name.
85
+ export function decodeToolName(wire) {
86
+ if (!wire.startsWith(MARKER)) {
87
+ return wire;
88
+ }
89
+ const body = wire.slice(MARKER.length);
90
+ let out = "";
91
+ let i = 0;
92
+ while (i < body.length) {
93
+ const ch = body.charAt(i);
94
+ if (ch === "-") {
95
+ const hex = body.slice(i + 1, i + 3);
96
+ if (!HEX_PAIR.test(hex)) {
97
+ return wire;
98
+ }
99
+ out += String.fromCharCode(parseInt(hex, 16));
100
+ i += 3;
101
+ continue;
102
+ }
103
+ if (!ESCAPE_PASSTHROUGH.test(ch)) {
104
+ return wire;
105
+ }
106
+ out += ch;
107
+ i += 1;
108
+ }
109
+ return out;
110
+ }
@@ -0,0 +1,11 @@
1
+ import { type ConversationTurn } from "@intx/types/runtime";
2
+ export type TransformOptions = {
3
+ targetModel: string;
4
+ keepThinkingForSameModel?: boolean;
5
+ };
6
+ export declare function transformMessages(messages: ConversationTurn[], options: TransformOptions): ConversationTurn[];
7
+ export type IDNormalizer = {
8
+ normalize(providerId: string): string;
9
+ resolve(portableId: string): string | undefined;
10
+ };
11
+ export declare function createIDNormalizer(): IDNormalizer;
@@ -0,0 +1,132 @@
1
+ // Cross-provider message transformation.
2
+ //
3
+ // When conversations cross provider boundaries the message history must be
4
+ // adapted: thinking blocks are stripped for foreign models, orphaned tool
5
+ // calls receive synthetic error results, and tool call IDs are normalized to
6
+ // a portable format.
7
+ //
8
+ // Callers invoke transformMessages when switching models. Adapter
9
+ // buildRequest paths also apply provider-specific history fixes. The
10
+ // originating model is tracked per-message, not per-conversation.
11
+ import { formatSafetyRatingText, } from "@intx/types/runtime";
12
+ export function transformMessages(messages, options) {
13
+ const { targetModel, keepThinkingForSameModel = true } = options;
14
+ // First pass: strip thinking blocks and filter aborted assistant messages.
15
+ const filtered = messages
16
+ .map((msg) => {
17
+ if (msg.role === "assistant") {
18
+ const isSameModel = msg.model === targetModel;
19
+ const keepThinking = keepThinkingForSameModel && isSameModel;
20
+ const filteredContent = msg.content
21
+ .filter((block) => {
22
+ if (block.type === "thinking") {
23
+ return keepThinking;
24
+ }
25
+ return true;
26
+ })
27
+ // safety_rating is output-only metadata. Convert it to text
28
+ // so cross-provider history keeps role alternation and a
29
+ // human-readable block reason without requiring every
30
+ // adapter to special-case the block.
31
+ .map((block) => {
32
+ if (block.type === "safety_rating") {
33
+ return {
34
+ type: "text",
35
+ text: formatSafetyRatingText(block),
36
+ };
37
+ }
38
+ return block;
39
+ });
40
+ // Filter out assistant messages that have no text or tool calls
41
+ // (aborted/error messages with only thinking blocks removed).
42
+ const hasUsableContent = filteredContent.some((b) => b.type === "text" || b.type === "tool_call");
43
+ if (!hasUsableContent && filteredContent.length === 0) {
44
+ return null;
45
+ }
46
+ return { ...msg, content: filteredContent };
47
+ }
48
+ return msg;
49
+ })
50
+ .filter((msg) => msg !== null);
51
+ // Second pass: inject synthetic tool results for orphaned tool calls.
52
+ return injectOrphanedToolResults(filtered);
53
+ }
54
+ function injectOrphanedToolResults(messages) {
55
+ const result = [];
56
+ for (let i = 0; i < messages.length; i++) {
57
+ const msg = messages[i];
58
+ if (msg === undefined)
59
+ continue;
60
+ result.push(msg);
61
+ if (msg.role !== "assistant")
62
+ continue;
63
+ const toolCalls = msg.content.filter((b) => b.type === "tool_call");
64
+ if (toolCalls.length === 0)
65
+ continue;
66
+ // Collect tool call IDs from this assistant message.
67
+ const calledIds = new Set(toolCalls.map((tc) => tc.id));
68
+ // Check the following messages for results that cover these calls.
69
+ const coveredIds = new Set();
70
+ for (let j = i + 1; j < messages.length; j++) {
71
+ const next = messages[j];
72
+ if (next === undefined)
73
+ break;
74
+ if (next.role !== "user")
75
+ break;
76
+ for (const block of next.content) {
77
+ if (block.type === "tool_result") {
78
+ coveredIds.add(block.callId);
79
+ }
80
+ }
81
+ }
82
+ // Find which tool calls have no corresponding result.
83
+ const orphanedIds = [...calledIds].filter((id) => !coveredIds.has(id));
84
+ if (orphanedIds.length === 0)
85
+ continue;
86
+ // Inject a synthetic user message with error tool results for each orphan.
87
+ const syntheticBlocks = orphanedIds.map((id) => ({
88
+ type: "tool_result",
89
+ callId: id,
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: "Tool execution was interrupted before completion.",
94
+ },
95
+ ],
96
+ isError: true,
97
+ }));
98
+ result.push({
99
+ role: "user",
100
+ content: syntheticBlocks,
101
+ timestamp: Date.now(),
102
+ });
103
+ }
104
+ return result;
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Tool call ID normalization
108
+ //
109
+ // OpenAI Responses API generates 450+ character IDs with pipes. Anthropic
110
+ // has strict format requirements. IDs are normalized to a short portable
111
+ // format with a bidirectional map for round-trip fidelity.
112
+ // ---------------------------------------------------------------------------
113
+ const PORTABLE_ID_PREFIX = "tc_";
114
+ export function createIDNormalizer() {
115
+ const portableToProvider = new Map();
116
+ const providerToPortable = new Map();
117
+ let counter = 0;
118
+ return {
119
+ normalize(providerId) {
120
+ const existing = providerToPortable.get(providerId);
121
+ if (existing !== undefined)
122
+ return existing;
123
+ const portable = `${PORTABLE_ID_PREFIX}${(++counter).toString(36)}`;
124
+ providerToPortable.set(providerId, portable);
125
+ portableToProvider.set(portable, providerId);
126
+ return portable;
127
+ },
128
+ resolve(portableId) {
129
+ return portableToProvider.get(portableId);
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,2 @@
1
+ export { createSizeCapTransform } from "./size-cap.js";
2
+ export type { SizeCapTransformOptions } from "./size-cap.js";
@@ -0,0 +1 @@
1
+ export { createSizeCapTransform } from "./size-cap.js";
@@ -0,0 +1,12 @@
1
+ import type { ContextStore, ToolResultTransform } from "@intx/types/runtime";
2
+ export type SizeCapTransformOptions = {
3
+ maxChars: number;
4
+ contextStore: Pick<ContextStore, "writeBlob">;
5
+ };
6
+ /**
7
+ * Create a `ToolResultTransform` that caps inline tool result content at
8
+ * `maxChars` characters. Oversized results are spilled to the context store
9
+ * via `writeBlob` and the inline content becomes a truncated marker
10
+ * referencing the spill by `tool-output:///{callId}` URI.
11
+ */
12
+ export declare function createSizeCapTransform(options: SizeCapTransformOptions): ToolResultTransform;