@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.
- package/LICENSE +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +40 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +75 -0
- package/dist/assembly.js +133 -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 +46 -0
- package/dist/authz-extension.js +184 -0
- package/dist/correlation.d.ts +26 -0
- package/dist/correlation.js +39 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +228 -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 +28 -0
- package/dist/gates.js +103 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1407 -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 +37 -0
- package/dist/providers/anthropic.js +917 -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 +5 -0
- package/dist/providers/google-genai.js +1205 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.js +903 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +1233 -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 +132 -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 +22 -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
|
@@ -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
|
|
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()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -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,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 @@
|
|
|
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;
|