@mono-agent/agent-runtime 0.1.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/ARCHITECTURE.md +219 -0
- package/LICENSE +674 -0
- package/README.md +430 -0
- package/package.json +46 -0
- package/src/agent/allowlists.js +49 -0
- package/src/agent/approval.js +211 -0
- package/src/agent/compaction.js +752 -0
- package/src/agent/index.js +40 -0
- package/src/agent/prompt/skill-index.js +66 -0
- package/src/agent/tool-bloat.js +164 -0
- package/src/agent/tools/bash.js +156 -0
- package/src/agent/tools/edit.js +15 -0
- package/src/agent/tools/glob.js +71 -0
- package/src/agent/tools/grep.js +84 -0
- package/src/agent/tools/index.js +17 -0
- package/src/agent/tools/pi-bridge.js +638 -0
- package/src/agent/tools/read.js +39 -0
- package/src/agent/tools/shared/constants.js +21 -0
- package/src/agent/tools/shared/dedup.js +31 -0
- package/src/agent/tools/shared/output-truncation.js +54 -0
- package/src/agent/tools/shared/path-resolver.js +156 -0
- package/src/agent/tools/shared/ripgrep.js +130 -0
- package/src/agent/tools/shared/runtime-context.js +69 -0
- package/src/agent/tools/web-fetch.js +59 -0
- package/src/agent/tools/web-search.js +21 -0
- package/src/agent/tools/write.js +14 -0
- package/src/agent/transcript.js +227 -0
- package/src/ai/backend.js +17 -0
- package/src/ai/cost.js +164 -0
- package/src/ai/failure.js +165 -0
- package/src/ai/file-change-stats.js +234 -0
- package/src/ai/index.js +16 -0
- package/src/ai/live-input-prompt.js +15 -0
- package/src/ai/observer.js +233 -0
- package/src/ai/providers/claude-cli.js +694 -0
- package/src/ai/providers/claude-sdk.js +864 -0
- package/src/ai/providers/claude-subagents.js +67 -0
- package/src/ai/providers/codex-app.js +1045 -0
- package/src/ai/providers/opencode-app.js +356 -0
- package/src/ai/providers/opencode-discovery.js +39 -0
- package/src/ai/providers/pi-events.js +62 -0
- package/src/ai/providers/pi-messages.js +68 -0
- package/src/ai/providers/pi-models.js +111 -0
- package/src/ai/providers/pi-sdk.js +1310 -0
- package/src/ai/registry.js +5 -0
- package/src/ai/runtime/capabilities-used.js +56 -0
- package/src/ai/runtime/capabilities.js +44 -0
- package/src/ai/runtime/context-windows.js +38 -0
- package/src/ai/runtime/fast-mode.js +8 -0
- package/src/ai/runtime/model-refs.js +144 -0
- package/src/ai/runtime/registry.js +57 -0
- package/src/ai/runtime/router.js +214 -0
- package/src/ai/runtime/sessions.js +126 -0
- package/src/ai/streaming/codex-events.js +139 -0
- package/src/ai/streaming/opencode-events.js +54 -0
- package/src/ai/types.js +70 -0
- package/src/index.js +23 -0
- package/src/pi-auth.js +80 -0
- package/src/runtime-brand.js +32 -0
- package/src/runtime.js +104 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Per-request capability telemetry. Each provider populates what it can
|
|
2
|
+
// attest to for *this* call; unknown values are `null` (not `false`) so
|
|
3
|
+
// hosts can tell "feature off" from "we don't know". This is the per-call
|
|
4
|
+
// counterpart to the static `runtimeCapabilities()` (which describes the
|
|
5
|
+
// backend in general).
|
|
6
|
+
//
|
|
7
|
+
// Fields:
|
|
8
|
+
// prompt_cache_active — true/false/null (null = unknown for this provider)
|
|
9
|
+
// thinking_enabled — true/false/null
|
|
10
|
+
// structured_output_enforced — true/false (request-time signal; never null)
|
|
11
|
+
// subagent_invoked — true/false/null
|
|
12
|
+
// mcp_servers_used — string[] of MCP server names that ran
|
|
13
|
+
// native_subagents_used — string[] of native subagent names that ran
|
|
14
|
+
// tool_compaction_applied — true/false (derived from runtimeWarnings)
|
|
15
|
+
// context_compaction_applied — true/false/null
|
|
16
|
+
|
|
17
|
+
export const UNKNOWN_CAPABILITY = null;
|
|
18
|
+
|
|
19
|
+
export function buildCapabilitiesUsed({
|
|
20
|
+
promptCacheActive = UNKNOWN_CAPABILITY,
|
|
21
|
+
thinkingEnabled = UNKNOWN_CAPABILITY,
|
|
22
|
+
structuredOutputEnforced = false,
|
|
23
|
+
subagentInvoked = UNKNOWN_CAPABILITY,
|
|
24
|
+
mcpServersUsed = [],
|
|
25
|
+
nativeSubagentsUsed = [],
|
|
26
|
+
toolCompactionApplied = false,
|
|
27
|
+
contextCompactionApplied = UNKNOWN_CAPABILITY,
|
|
28
|
+
} = {}) {
|
|
29
|
+
return {
|
|
30
|
+
prompt_cache_active: tristate(promptCacheActive),
|
|
31
|
+
thinking_enabled: tristate(thinkingEnabled),
|
|
32
|
+
structured_output_enforced: !!structuredOutputEnforced,
|
|
33
|
+
subagent_invoked: tristate(subagentInvoked),
|
|
34
|
+
mcp_servers_used: stringList(mcpServersUsed),
|
|
35
|
+
native_subagents_used: stringList(nativeSubagentsUsed),
|
|
36
|
+
tool_compaction_applied: !!toolCompactionApplied,
|
|
37
|
+
context_compaction_applied: tristate(contextCompactionApplied),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tristate(value) {
|
|
42
|
+
if (value === true || value === false) return value;
|
|
43
|
+
return UNKNOWN_CAPABILITY;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stringList(value) {
|
|
47
|
+
if (!Array.isArray(value)) return [];
|
|
48
|
+
return value
|
|
49
|
+
.filter((entry) => typeof entry === "string" && entry.trim())
|
|
50
|
+
.map((entry) => entry.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toolCompactionAppliedFromWarnings(runtimeWarnings = []) {
|
|
54
|
+
if (!Array.isArray(runtimeWarnings)) return false;
|
|
55
|
+
return runtimeWarnings.some((warning) => warning?.warning_kind === "tool_payload_truncated");
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const COMMON_CAPABILITIES = {
|
|
2
|
+
streaming: true,
|
|
3
|
+
structured_output: true,
|
|
4
|
+
supports_session_resume: false,
|
|
5
|
+
native_runtime_config: null,
|
|
6
|
+
supports_mcp: true,
|
|
7
|
+
supports_skills: true,
|
|
8
|
+
supports_builtin_tools: true,
|
|
9
|
+
supports_live_input: true,
|
|
10
|
+
supports_native_subagents: true,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const RUNTIME_CAPABILITIES = {
|
|
14
|
+
claude: {
|
|
15
|
+
runtime: "sdk",
|
|
16
|
+
...COMMON_CAPABILITIES,
|
|
17
|
+
// The Claude Agent SDK persists sessions on disk; the bridge resumes
|
|
18
|
+
// them via queryOptions.resume when options.sessionId is supplied.
|
|
19
|
+
supports_session_resume: true,
|
|
20
|
+
},
|
|
21
|
+
pi: {
|
|
22
|
+
runtime: "pi-agent",
|
|
23
|
+
...COMMON_CAPABILITIES,
|
|
24
|
+
// The pi bridge keeps a pi-agent-core Session transcript per provider
|
|
25
|
+
// session id and seeds Agent initialState.messages on resume.
|
|
26
|
+
supports_session_resume: true,
|
|
27
|
+
},
|
|
28
|
+
codex: {
|
|
29
|
+
runtime: "cli",
|
|
30
|
+
...COMMON_CAPABILITIES,
|
|
31
|
+
// The codex-app bridge keeps the app-server subprocess + thread alive
|
|
32
|
+
// when options.sessionKeepAlive is set; resumed turns reuse the thread.
|
|
33
|
+
supports_session_resume: true,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function runtimeCapabilities(sdkOrModel) {
|
|
38
|
+
if (!sdkOrModel) throw new Error("runtimeCapabilities requires a model reference or sdk kind");
|
|
39
|
+
const sdk = typeof sdkOrModel === "string" ? sdkOrModel : sdkOrModel?.sdk;
|
|
40
|
+
if (!sdk) throw new Error("runtimeCapabilities: unrecognized argument");
|
|
41
|
+
const caps = RUNTIME_CAPABILITIES[sdk];
|
|
42
|
+
if (!caps) throw new Error(`unknown provider sdk: ${sdk}`);
|
|
43
|
+
return { kind: sdk, ...caps };
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const DEFAULT_CONTEXT_WINDOW = "default";
|
|
2
|
+
export const ONE_MILLION_CONTEXT_WINDOW = "1m";
|
|
3
|
+
|
|
4
|
+
export const CLAUDE_ONE_MILLION_CONTEXT_MODELS = new Set([
|
|
5
|
+
"claude-opus-4-7",
|
|
6
|
+
"claude-opus-4-6",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function normalizeContextWindow(value) {
|
|
10
|
+
return value === ONE_MILLION_CONTEXT_WINDOW
|
|
11
|
+
? ONE_MILLION_CONTEXT_WINDOW
|
|
12
|
+
: DEFAULT_CONTEXT_WINDOW;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stripContextWindowSuffix(model) {
|
|
16
|
+
return String(model || "").replace(/\[1m\]$/i, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function claudeModelSupportsOneMillionContext(model) {
|
|
20
|
+
return CLAUDE_ONE_MILLION_CONTEXT_MODELS.has(stripContextWindowSuffix(model));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function claudeModelSupportsContextWindow(model, contextWindow) {
|
|
24
|
+
const normalized = normalizeContextWindow(contextWindow);
|
|
25
|
+
if (normalized === DEFAULT_CONTEXT_WINDOW) return true;
|
|
26
|
+
return claudeModelSupportsOneMillionContext(model);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function modelWithContextWindow(model, contextWindow) {
|
|
30
|
+
const base = stripContextWindowSuffix(model);
|
|
31
|
+
if (
|
|
32
|
+
normalizeContextWindow(contextWindow) === ONE_MILLION_CONTEXT_WINDOW
|
|
33
|
+
&& claudeModelSupportsOneMillionContext(base)
|
|
34
|
+
) {
|
|
35
|
+
return `${base}[1m]`;
|
|
36
|
+
}
|
|
37
|
+
return base;
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function codexModelSupportsFastMode(model) {
|
|
2
|
+
return String(model || "").trim().toLowerCase().startsWith("gpt-");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function normalizeFastMode(value, fallback = true) {
|
|
6
|
+
if (value === undefined || value === null || value === "") return !!fallback;
|
|
7
|
+
return value === true || value === 1;
|
|
8
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const RESERVED_RUNTIME_IDS = new Set(["openai", "vercel", "claude-code", "codex-cli"]);
|
|
2
|
+
const ACTIVE_RUNTIME_IDS = new Set(["claude", "pi", "codex", "opencode"]);
|
|
3
|
+
|
|
4
|
+
function requirePart(value, message) {
|
|
5
|
+
if (!value || typeof value !== "string" || value.trim() !== value) {
|
|
6
|
+
throw new Error(message);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rejectTierAlias(model) {
|
|
12
|
+
if (["haiku", "sonnet", "opus"].includes(model)) {
|
|
13
|
+
throw new Error("tier aliases are not valid model references; use an exact model id");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function canonicalizeLegacyModelReference(value) {
|
|
18
|
+
if (!value || typeof value !== "string") throw new Error("model reference required");
|
|
19
|
+
|
|
20
|
+
if (value.startsWith("openai:")) {
|
|
21
|
+
const model = requirePart(value.slice("openai:".length), "model id required");
|
|
22
|
+
return `pi:openai:${model}`;
|
|
23
|
+
}
|
|
24
|
+
if (value.startsWith("codex:")) {
|
|
25
|
+
const model = requirePart(value.slice("codex:".length), "model id required");
|
|
26
|
+
return `codex:${model}`;
|
|
27
|
+
}
|
|
28
|
+
if (value.startsWith("vercel:")) {
|
|
29
|
+
const rest = value.slice("vercel:".length);
|
|
30
|
+
const i = rest.indexOf(":");
|
|
31
|
+
if (i <= 0 || i === rest.length - 1) {
|
|
32
|
+
throw new Error("invalid vercel model reference; expected vercel:<providerId>:<modelName>");
|
|
33
|
+
}
|
|
34
|
+
const provider = requirePart(rest.slice(0, i), "provider id required");
|
|
35
|
+
const model = requirePart(rest.slice(i + 1), "model name required");
|
|
36
|
+
return `pi:${provider}:${model}`;
|
|
37
|
+
}
|
|
38
|
+
if (value.startsWith("claude-code:")) {
|
|
39
|
+
const model = requirePart(value.slice("claude-code:".length), "model id required");
|
|
40
|
+
return `claude:${model}`;
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizeRuntimeModelReference(value) {
|
|
46
|
+
return parseRuntimeModelReference(canonicalizeLegacyModelReference(value));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function sdkFromModelReference(value) {
|
|
50
|
+
const parsed = parseRuntimeModelReference(value);
|
|
51
|
+
return parsed.sdk;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseRuntimeModelReference(value) {
|
|
55
|
+
if (!value || typeof value !== "string") throw new Error("model reference required");
|
|
56
|
+
|
|
57
|
+
if (value.startsWith("pi:")) {
|
|
58
|
+
const rest = value.slice("pi:".length);
|
|
59
|
+
const i = rest.indexOf(":");
|
|
60
|
+
if (i <= 0 || i === rest.length - 1) {
|
|
61
|
+
throw new Error("invalid pi model reference; expected pi:<providerId>:<modelName>");
|
|
62
|
+
}
|
|
63
|
+
const provider = requirePart(rest.slice(0, i), "provider id required");
|
|
64
|
+
const model = requirePart(rest.slice(i + 1), "model id required");
|
|
65
|
+
return { sdk: "pi", provider, model, reference: value };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (value.startsWith("opencode:")) {
|
|
69
|
+
// opencode:<providerID>:<modelID> — providerID/modelID come from OpenCode's own
|
|
70
|
+
// provider registry (auth.json). Only the first colon separates them; modelID may
|
|
71
|
+
// contain slashes (e.g. openrouter's `anthropic/claude-3.5-sonnet`).
|
|
72
|
+
const rest = value.slice("opencode:".length);
|
|
73
|
+
const i = rest.indexOf(":");
|
|
74
|
+
if (i <= 0 || i === rest.length - 1) {
|
|
75
|
+
throw new Error("invalid opencode model reference; expected opencode:<providerId>:<modelId>");
|
|
76
|
+
}
|
|
77
|
+
const provider = requirePart(rest.slice(0, i), "provider id required");
|
|
78
|
+
const model = requirePart(rest.slice(i + 1), "model id required");
|
|
79
|
+
return { sdk: "opencode", provider, model, reference: value };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const i = value.indexOf(":");
|
|
83
|
+
if (i <= 0 || i === value.length - 1) {
|
|
84
|
+
throw new Error("invalid model reference; expected <sdk>:<modelId>");
|
|
85
|
+
}
|
|
86
|
+
const sdk = value.slice(0, i);
|
|
87
|
+
const model = requirePart(value.slice(i + 1), "model id required");
|
|
88
|
+
|
|
89
|
+
if (RESERVED_RUNTIME_IDS.has(sdk)) {
|
|
90
|
+
throw new Error(`reserved runtime id: ${sdk}; use a canonical pi:*, claude:*, or codex:* model reference`);
|
|
91
|
+
}
|
|
92
|
+
if (!ACTIVE_RUNTIME_IDS.has(sdk)) {
|
|
93
|
+
throw new Error(`unknown sdk: ${sdk}`);
|
|
94
|
+
}
|
|
95
|
+
rejectTierAlias(model);
|
|
96
|
+
return { sdk, model, reference: value };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const ACTIVE_RUNTIME_KINDS = [...ACTIVE_RUNTIME_IDS];
|
|
100
|
+
export const RESERVED_RUNTIME_KINDS = [...RESERVED_RUNTIME_IDS];
|
|
101
|
+
|
|
102
|
+
// intelligence-ramp: which model refs can run under which execution_mode.
|
|
103
|
+
// sdk='claude' → CLI (claude binary) or SDK (Anthropic)
|
|
104
|
+
// sdk='codex' → CLI only (codex app-server)
|
|
105
|
+
// sdk='opencode' → CLI only (opencode server via @opencode-ai/sdk)
|
|
106
|
+
// sdk='pi' → SDK only (pi-sdk handles openai-codex and other providers)
|
|
107
|
+
|
|
108
|
+
// Returns null when the combo is fine; otherwise a short reason string the
|
|
109
|
+
// UI / API can show.
|
|
110
|
+
export function executionModeIncompatibilityReason(modelRefOrParsed, executionMode) {
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = typeof modelRefOrParsed === "string"
|
|
114
|
+
? parseRuntimeModelReference(modelRefOrParsed)
|
|
115
|
+
: modelRefOrParsed;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (!parsed) return null;
|
|
120
|
+
if (!executionMode) return null;
|
|
121
|
+
if (executionMode === "sdk") {
|
|
122
|
+
if (parsed.sdk === "codex") {
|
|
123
|
+
return "Codex CLI requires CLI execution mode.";
|
|
124
|
+
}
|
|
125
|
+
if (parsed.sdk === "opencode") {
|
|
126
|
+
return "OpenCode CLI requires CLI execution mode.";
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (executionMode !== "cli") return null;
|
|
131
|
+
if (parsed.sdk === "claude") return null;
|
|
132
|
+
if (parsed.sdk === "codex") return null;
|
|
133
|
+
if (parsed.sdk === "opencode") return null;
|
|
134
|
+
if (parsed.sdk === "pi") {
|
|
135
|
+
const provider = parsed.provider || "unknown";
|
|
136
|
+
const suffix = provider === "openai-codex" ? "; use codex:<model> for Codex CLI" : "";
|
|
137
|
+
return `Provider \`${provider}\` only runs under SDK execution mode${suffix}.`;
|
|
138
|
+
}
|
|
139
|
+
return `sdk \`${parsed.sdk}\` is not supported under CLI execution mode.`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isModelCompatibleWithExecutionMode(modelRefOrParsed, executionMode) {
|
|
143
|
+
return executionModeIncompatibilityReason(modelRefOrParsed, executionMode) === null;
|
|
144
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { COMMON_CAPABILITIES, runtimeCapabilities } from "./capabilities.js";
|
|
2
|
+
|
|
3
|
+
// CLI bridges are checked first when execution_mode='cli'. Without that flag
|
|
4
|
+
// the resolver falls through to the SDK bridges below, preserving the
|
|
5
|
+
// pre-Phase-2 behaviour for any agent that hasn't opted in.
|
|
6
|
+
const builtinBridgeSpecs = {
|
|
7
|
+
"claude-code": {
|
|
8
|
+
id: "claude-code",
|
|
9
|
+
supports: (ref, options) => ref?.sdk === "claude" && options?.executionMode === "cli",
|
|
10
|
+
// The claude CLI resumes prior sessions via `--resume <sessionId>`.
|
|
11
|
+
capabilities: () => ({ kind: "claude-code", runtime: "cli", ...COMMON_CAPABILITIES, supports_session_resume: true }),
|
|
12
|
+
load: async () => (await import("../providers/claude-cli.js")).claudeCodeRuntimeBridge,
|
|
13
|
+
},
|
|
14
|
+
"codex-app": {
|
|
15
|
+
id: "codex-app",
|
|
16
|
+
supports: (ref, options) => ref?.sdk === "codex" && options?.executionMode === "cli",
|
|
17
|
+
// The codex-app bridge keeps the app-server subprocess + thread alive
|
|
18
|
+
// across turns when options.sessionKeepAlive is set.
|
|
19
|
+
capabilities: () => ({ kind: "codex-app", runtime: "cli", ...COMMON_CAPABILITIES, supports_session_resume: true }),
|
|
20
|
+
load: async () => (await import("../providers/codex-app.js")).codexAppRuntimeBridge,
|
|
21
|
+
},
|
|
22
|
+
"opencode-app": {
|
|
23
|
+
id: "opencode-app",
|
|
24
|
+
supports: (ref, options) => ref?.sdk === "opencode" && options?.executionMode === "cli",
|
|
25
|
+
capabilities: () => ({ kind: "opencode-app", runtime: "cli", ...COMMON_CAPABILITIES }),
|
|
26
|
+
load: async () => (await import("../providers/opencode-app.js")).opencodeAppRuntimeBridge,
|
|
27
|
+
},
|
|
28
|
+
claude: {
|
|
29
|
+
id: "claude",
|
|
30
|
+
supports: (ref) => ref?.sdk === "claude",
|
|
31
|
+
capabilities: () => runtimeCapabilities("claude"),
|
|
32
|
+
load: async () => (await import("../providers/claude-sdk.js")).claudeRuntimeBridge,
|
|
33
|
+
},
|
|
34
|
+
pi: {
|
|
35
|
+
id: "pi",
|
|
36
|
+
supports: (ref) => ref?.sdk === "pi",
|
|
37
|
+
capabilities: () => runtimeCapabilities("pi"),
|
|
38
|
+
load: async () => (await import("../providers/pi-sdk.js")).piRuntimeBridge,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function listRuntimeBridges() {
|
|
43
|
+
return Object.values(builtinBridgeSpecs).map((bridge) => ({
|
|
44
|
+
id: bridge.id,
|
|
45
|
+
supports: bridge.supports,
|
|
46
|
+
capabilities: bridge.capabilities,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveRuntimeBridge(modelRef, options = {}) {
|
|
51
|
+
for (const spec of Object.values(builtinBridgeSpecs)) {
|
|
52
|
+
if (spec.supports(modelRef, options)) return spec.load();
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`unsupported sdk: ${modelRef?.sdk || "unknown"}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { RUNTIME_CAPABILITIES, runtimeCapabilities } from "./capabilities.js";
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Provider fallback router.
|
|
2
|
+
//
|
|
3
|
+
// Wraps `createRuntime` with an ordered chain of model references. If a run
|
|
4
|
+
// fails with a retryable provider error (per `retryableProviderFailureInfo`),
|
|
5
|
+
// the router retries the same logical run with the next chain entry,
|
|
6
|
+
// prepending the transcript-tail snapshot of the previous attempt to the
|
|
7
|
+
// system prompt so the next provider can continue rather than restart.
|
|
8
|
+
//
|
|
9
|
+
// Inspired by zeroclaw's RouterProvider hint-resolution pattern, but goes
|
|
10
|
+
// further: zeroclaw's router resolves a hint to one provider and never
|
|
11
|
+
// falls back automatically. This router does, using the failure-kind
|
|
12
|
+
// taxonomy and capability matrix we already maintain.
|
|
13
|
+
//
|
|
14
|
+
// API:
|
|
15
|
+
// createRouterRuntime({ host, chain })
|
|
16
|
+
// returns { run(systemPrompt, options) } plus configureTools /
|
|
17
|
+
// disposeSession / disposeAllSessions delegated to the inner runtime,
|
|
18
|
+
// so the router is a drop-in replacement for createRuntime(host).
|
|
19
|
+
//
|
|
20
|
+
// chain entries:
|
|
21
|
+
// { model: ModelRef, executionMode?: "sdk" | "cli", requires?: Capabilities }
|
|
22
|
+
// shorthand: a bare ModelRef is also accepted (no requirements).
|
|
23
|
+
//
|
|
24
|
+
// Result:
|
|
25
|
+
// The success run's result, with `failoverHistory` appended describing every
|
|
26
|
+
// prior attempt: [{ model, failureKind, requestId, retryableSubkind }].
|
|
27
|
+
// If every entry in the chain fails, returns the last result with
|
|
28
|
+
// `failureKind: "provider_unavailable_exhausted"`.
|
|
29
|
+
|
|
30
|
+
import { createRuntime } from "../../runtime.js";
|
|
31
|
+
import { retryableProviderFailureInfo } from "../failure.js";
|
|
32
|
+
import { runtimeCapabilities } from "./capabilities.js";
|
|
33
|
+
import { buildTranscriptTailSnapshot, renderResumeSnapshot } from "../../agent/transcript.js";
|
|
34
|
+
|
|
35
|
+
export function createRouterRuntime({ host = {}, chain = [] } = {}) {
|
|
36
|
+
const entries = normaliseChain(chain);
|
|
37
|
+
if (entries.length === 0) {
|
|
38
|
+
throw new Error("createRouterRuntime requires a non-empty chain");
|
|
39
|
+
}
|
|
40
|
+
const inner = createRuntime(host);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
async run(systemPrompt, options = {}) {
|
|
44
|
+
const failoverHistory = [];
|
|
45
|
+
let lastResult = null;
|
|
46
|
+
let resumeSnapshot = null;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
49
|
+
const entry = entries[i];
|
|
50
|
+
if (!entrySatisfiesRequirements(entry, options)) {
|
|
51
|
+
failoverHistory.push({
|
|
52
|
+
model: entry.model,
|
|
53
|
+
failureKind: "skipped_capability_mismatch",
|
|
54
|
+
requirements: entry.requires,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const callOptions = {
|
|
60
|
+
...options,
|
|
61
|
+
model: entry.model,
|
|
62
|
+
executionMode: entry.executionMode || options.executionMode,
|
|
63
|
+
};
|
|
64
|
+
if (resumeSnapshot) {
|
|
65
|
+
callOptions.diagnosticsSeed = {
|
|
66
|
+
...(callOptions.diagnosticsSeed || {}),
|
|
67
|
+
resume_snapshot: resumeSnapshot,
|
|
68
|
+
};
|
|
69
|
+
// Also prepend the rendered snapshot to the system prompt so SDK
|
|
70
|
+
// backends that don't read diagnosticsSeed still continue from the
|
|
71
|
+
// previous attempt.
|
|
72
|
+
const rendered = renderResumeSnapshot(resumeSnapshot);
|
|
73
|
+
if (rendered) {
|
|
74
|
+
callOptions.systemPromptPrefix = rendered;
|
|
75
|
+
systemPrompt = `${rendered}\n\n${systemPrompt}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (failoverHistory.length > 0) {
|
|
80
|
+
emit(callOptions, {
|
|
81
|
+
type: "provider_failover_started",
|
|
82
|
+
from: failoverHistory[failoverHistory.length - 1]?.model,
|
|
83
|
+
to: entry.model,
|
|
84
|
+
attemptIndex: i,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await inner.run(systemPrompt, callOptions);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// The inner runtime usually surfaces errors as structured result
|
|
93
|
+
// fields, but a bridge can still throw synchronously (e.g. spawn
|
|
94
|
+
// failures). Convert to a result-like shape so the chain logic
|
|
95
|
+
// is uniform.
|
|
96
|
+
result = {
|
|
97
|
+
text: null,
|
|
98
|
+
error: err?.message || String(err),
|
|
99
|
+
failureKind: "provider_unavailable",
|
|
100
|
+
events: [],
|
|
101
|
+
cancelled: false,
|
|
102
|
+
usage: {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const retryability = retryableProviderFailureInfo({
|
|
107
|
+
errorText: result.error || "",
|
|
108
|
+
stderrTail: result.stderrTail || "",
|
|
109
|
+
failureKind: result.failureKind,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const successful = !result.error && !result.failureKind && !result.cancelled;
|
|
113
|
+
if (successful) {
|
|
114
|
+
if (failoverHistory.length > 0) {
|
|
115
|
+
emit(callOptions, {
|
|
116
|
+
type: "provider_failover_completed",
|
|
117
|
+
attemptIndex: i,
|
|
118
|
+
model: entry.model,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { ...result, failoverHistory };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
failoverHistory.push({
|
|
125
|
+
model: entry.model,
|
|
126
|
+
failureKind: result.failureKind || null,
|
|
127
|
+
requestId: retryability.requestId,
|
|
128
|
+
retryableSubkind: retryability.subkind,
|
|
129
|
+
});
|
|
130
|
+
lastResult = result;
|
|
131
|
+
|
|
132
|
+
// Bail early on non-retryable failures (auth, billing, cancellation,
|
|
133
|
+
// invalid_result). Only retryable provider errors trigger fallback.
|
|
134
|
+
const shouldFallback = retryability.retryable && !result.cancelled;
|
|
135
|
+
if (!shouldFallback) break;
|
|
136
|
+
|
|
137
|
+
// Build a transcript-tail snapshot from this run's events so the
|
|
138
|
+
// next provider can continue. If the run produced no usable events,
|
|
139
|
+
// skip the snapshot (the next attempt starts fresh).
|
|
140
|
+
const snapshot = buildTranscriptTailSnapshot(result.events);
|
|
141
|
+
if (snapshot) resumeSnapshot = snapshot;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const exhaustedResult = lastResult || {
|
|
145
|
+
text: null,
|
|
146
|
+
events: [],
|
|
147
|
+
error: "router chain exhausted with no executions",
|
|
148
|
+
failureKind: "provider_unavailable_exhausted",
|
|
149
|
+
cancelled: false,
|
|
150
|
+
usage: {},
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
...exhaustedResult,
|
|
154
|
+
failureKind: "provider_unavailable_exhausted",
|
|
155
|
+
failoverHistory,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
chain: () => entries.slice(),
|
|
159
|
+
configureTools(next) {
|
|
160
|
+
inner.configureTools?.(next);
|
|
161
|
+
},
|
|
162
|
+
async disposeSession(providerSessionId) {
|
|
163
|
+
return inner.disposeSession?.(providerSessionId);
|
|
164
|
+
},
|
|
165
|
+
async disposeAllSessions() {
|
|
166
|
+
await inner.disposeAllSessions?.();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normaliseChain(chain) {
|
|
172
|
+
if (!Array.isArray(chain)) return [];
|
|
173
|
+
return chain
|
|
174
|
+
.map((entry) => {
|
|
175
|
+
if (!entry) return null;
|
|
176
|
+
if (entry.sdk && entry.model) {
|
|
177
|
+
// ModelRef shorthand: { sdk, model, ... }
|
|
178
|
+
return { model: entry, executionMode: null, requires: null };
|
|
179
|
+
}
|
|
180
|
+
if (entry.model) {
|
|
181
|
+
return {
|
|
182
|
+
model: entry.model,
|
|
183
|
+
executionMode: typeof entry.executionMode === "string" ? entry.executionMode : null,
|
|
184
|
+
requires: entry.requires && typeof entry.requires === "object" ? entry.requires : null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
})
|
|
189
|
+
.filter(Boolean);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function entrySatisfiesRequirements(entry, options) {
|
|
193
|
+
const requires = entry.requires;
|
|
194
|
+
if (!requires) return true;
|
|
195
|
+
let caps;
|
|
196
|
+
try {
|
|
197
|
+
caps = runtimeCapabilities(entry.model);
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
for (const [key, expected] of Object.entries(requires)) {
|
|
202
|
+
if (caps[key] !== expected) return false;
|
|
203
|
+
}
|
|
204
|
+
// Honour request-time outputSchema → require structured_output unless
|
|
205
|
+
// already specified.
|
|
206
|
+
if (options.outputSchema && requires.structured_output !== false && caps.structured_output === false) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function emit(callOptions, event) {
|
|
213
|
+
try { callOptions.onEvent?.(event); } catch { /* swallow */ }
|
|
214
|
+
}
|