src/cascade-native-bridge.js
Rama master · réplica de lectura del repositorio original.
/**
* v2.0.65 — Cascade native tool bridge (#115 root-cause fix).
*
* Translates between OpenAI-shaped client tools (Read/Bash/Glob/Grep/...) and
* Cascade's built-in IDE step kinds (view_file/run_command/find/grep_search_v2/...).
* Runtime enablement is deliberately narrower than TOOL_MAP: by default only
* the Bash/run_command family is allowlisted. Read/Grep/Glob/WebSearch/WebFetch
* remain lab/protocol mappings until an operator explicitly allowlists them
* behind model/account/API-key gates.
*
* Why this layer exists
* ─────────────────────
* v2.0.62-v2.0.64 (#115) shipped dialect/anti-refusal infra under the
* NO_TOOL planner mode + tool-prompt emulation umbrella. Real-world GPT
* traces (Codex CLI, gpt-5.x) still surfaced markers=none on non-trivial
* turns — the gateway's baked system prompt outweighs anything we inject
* via additional_instructions_section. The tools the gateway DOES respect
* are Cascade's own — view_file, run_command, grep_search_v2, find — because
* those names appear inside the planner's training distribution as first-
* class function-calling tokens, not as proxy-injected text.
*
* The bridge never enables planner_mode=DEFAULT on its own — that path
* triggers server-side workspace mocking ("/tmp/windsurf-workspace path
* leaks", #98 / pre-v2.0.64 stall_warm bursts). Instead, the bridge:
*
* 1. Forward translates the caller's OpenAI tool inventory + tool history
* into Cascade-vocabulary names so the gateway sees a familiar
* tool list and a sequence of completed cascade-style steps.
*
* 2. Reverse translates each trajectory step (view_file, run_command,
* grep_search_v2, find, list_directory) the planner emits back into
* the caller's original OpenAI tool name (Read, Bash, Grep, Glob, ...).
*
* 3. Partition (v2.0.66): mapped tools go through trajectory-step
* injection; unmapped tools keep the emulation preamble. Both can
* coexist on one request. `all_mapped` mode still refuses mixed sets.
* The file-top "fall back entirely" rule is the old v2.0.65 gate.
*
* Activation:
* WINDSURFAPI_NATIVE_TOOL_BRIDGE=1
* Force on for any request with at least one mapped tool.
* WINDSURFAPI_NATIVE_TOOL_BRIDGE=all_mapped
* Enable only when every declared function tool maps to a Cascade-native
* kind. This avoids partial native + prompt-emulation mixing.
*
* Default OFF until field-tested. Do not enable this for local IDE agents
* unless remote Windsurf-workspace execution is the intended behavior.
*/
import {
writeStringField, writeMessageField, writeVarintField, writeBoolField, writeBytesField,
parseFields, getField, getAllFields,
} from './proto.js';
// ─── Cascade step type enums ───────────────────────────────────────
//
// CortexStepType enum values from exa.cortex_pb.proto. The `type` field is
// `CortexTrajectoryStep.type` (field 1), whose enum values are NOT the oneof
// field numbers: e.g. VIEW_FILE=8 but the `view_file` body rides field 14.
// Writing the oneof number into `type` labels every step as a different kind
// (14 is USER_INPUT, 28 is COMMAND_STATUS, 40 is TOOL_CALL_PROPOSAL). The
// body field still uses the oneof number; keep the two separate.
export const CASCADE_STEP = {
// step kind → { typeEnum, oneofField }
view_file: { typeEnum: 8, oneofField: 14 },
list_directory: { typeEnum: 9, oneofField: 15 },
write_to_file: { typeEnum: 16, oneofField: 23 },
run_command: { typeEnum: 21, oneofField: 28 },
propose_code: { typeEnum: 24, oneofField: 32 },
find: { typeEnum: 25, oneofField: 34 },
read_url_content:{ typeEnum: 31, oneofField: 40 },
grep_search: { typeEnum: 7, oneofField: 13 },
grep_search_v2: { typeEnum: 91, oneofField: 105 },
// v2.0.70 — search_web (CortexStepSearchWeb proto field 42).
search_web: { typeEnum: 33, oneofField: 42 },
};
// CortexStepStatus — used for the step.status field (CortexTrajectoryStep
// field 4). DONE=3 marks a step as complete-and-observed; UNSPECIFIED=0
// would leave the planner thinking work is still in progress.
export const CASCADE_STEP_STATUS_DONE = 3;
const DEFAULT_NATIVE_BRIDGE_TOOLS = new Set([
// Default scope is intentionally narrow: real smoke has only proven the
// command path stable enough for opt-in production canaries. Read/Grep/Glob
// translators remain in TOOL_MAP for protocol matrix work, but must be
// explicitly allowlisted with WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS before
// they are routed through the native bridge.
'Bash', 'shell_command', 'run_command',
]);
// ─── argument translators ─────────────────────────────────────────
//
// Each translator maps OpenAI-style arguments (the JSON object the caller
// puts in tool_calls[].function.arguments) to and from cascade step
// fields. They MUST be pure — identical args in produce identical cascade
// step proto bytes, and a round-trip translator(reverse(forward(args))) is
// the identity. The reverse direction is exercised for two reasons:
// (a) the planner emits trajectory steps in cascade vocabulary and we have
// to surface them as OpenAI tool_calls with arguments the caller's schema
// validator accepts; (b) tests assert lossless round-trip per tool.
function safeJsonParse(s) {
if (typeof s !== 'string' || !s) return {};
try { const v = JSON.parse(s); return v && typeof v === 'object' ? v : {}; }
catch { return {}; }
}
function csvSetEnv(name) {
return new Set(String(process.env[name] || '')
.split(',')
.map(s => s.trim())
.filter(Boolean));
}
function csvListEnv(name) {
return String(process.env[name] || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
}
export function getNativeBridgeConfigStatus() {
return {
mode: String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE || '').trim(),
off: process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF === '1',
tools: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS'),
models: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_MODELS'),
providers: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_PROVIDERS'),
routes: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ROUTES'),
callers: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_CALLERS'),
allowlistNameOverrides: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ALLOWLIST_NAMES'),
hasApiKeyGate: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_API_KEYS').length > 0,
hasAccountGate: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ACCOUNTS').length > 0,
hasRawConfig: !!String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW || '').trim(),
};
}
function patternMatches(pattern, value, caseInsensitive = false) {
if (!pattern || !value) return false;
const p = caseInsensitive ? String(pattern).toLowerCase() : String(pattern);
const v = caseInsensitive ? String(value).toLowerCase() : String(value);
if (p === '*' || p === 'all') return true;
if (p.endsWith('*')) return v.startsWith(p.slice(0, -1));
return p === v;
}
function envGateAllows(envName, values, { caseInsensitive = false } = {}) {
const patterns = csvListEnv(envName);
if (!patterns.length) return true;
const candidates = values.map(v => String(v || '').trim()).filter(Boolean);
if (!candidates.length) return false;
return patterns.some(p => candidates.some(v => patternMatches(p, v, caseInsensitive)));
}
function isNativeBridgeToolAllowed(name) {
const configured = csvSetEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS');
const n = String(name || '');
if (!n) return false;
if (!configured.size) return DEFAULT_NATIVE_BRIDGE_TOOLS.has(n);
if (configured.has('*') || configured.has('all')) return Object.prototype.hasOwnProperty.call(TOOL_MAP, n);
return configured.has(n);
}
export function nativeBridgeGrayGateStatus({
modelKey = '',
model = '',
provider = '',
route = '',
callerKey = '',
} = {}) {
const callerKeys = [callerKey];
if (String(callerKey || '').includes(':api_key_allowed')) {
callerKeys.push(String(callerKey || '').replace(':api_key_allowed', ''));
}
if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_MODELS', [modelKey, model], { caseInsensitive: true })) {
return { ok: false, errorType: 'native_bridge_model_not_allowed' };
}
if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_PROVIDERS', [provider], { caseInsensitive: true })) {
return { ok: false, errorType: 'native_bridge_provider_not_allowed' };
}
if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ROUTES', [route], { caseInsensitive: true })) {
return { ok: false, errorType: 'native_bridge_route_not_allowed' };
}
if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_CALLERS', callerKeys)) {
return { ok: false, errorType: 'native_bridge_caller_not_allowed' };
}
if (csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_API_KEYS').length && !String(callerKey || '').includes(':api_key_allowed')) {
return { ok: false, errorType: 'native_bridge_api_key_not_allowed' };
}
return { ok: true };
}
export function hasNativeBridgeAccountGate() {
return csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ACCOUNTS').length > 0;
}
export function isNativeBridgeAccountAllowed(account = {}) {
return envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ACCOUNTS', [
account.accountId,
account.id,
account.accountEmail,
account.email,
], { caseInsensitive: true });
}
function unescapeXmlText(s) {
return String(s || '')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&');
}
function buildFileUri(absolutePath) {
if (typeof absolutePath !== 'string' || !absolutePath) return '';
// Cascade's view_file uses absolute_path_uri — `file://` prefix optional
// depending on LS version. Both forms are accepted in the wild; we leave
// already-prefixed paths intact and add the prefix to bare ones.
if (/^file:\/\//.test(absolutePath)) return absolutePath;
if (/^[a-zA-Z]:[\\/]/.test(absolutePath) || absolutePath.startsWith('/')) {
return `file://${absolutePath.replace(/\\/g, '/')}`;
}
// Relative path — leave as-is. Caller's environment block tells the
// planner what cwd to resolve against.
return absolutePath;
}
function stripFileUri(uri) {
if (typeof uri !== 'string') return '';
return uri.replace(/^file:\/\//, '');
}
// ── Read / view_file ────────────────────────────────────────────
function forwardReadArgs(args) {
const file_path = args.file_path || args.path || args.absolute_path || '';
const offset = Number(args.offset) || 0;
const limit = Number(args.limit) || 0;
return {
absolute_path_uri: buildFileUri(file_path),
offset,
limit,
};
}
function reverseReadArgs(cascade) {
return {
file_path: stripFileUri(cascade.absolute_path_uri || ''),
...(cascade.offset ? { offset: cascade.offset } : {}),
...(cascade.limit ? { limit: cascade.limit } : {}),
};
}
// ── Bash / run_command ──────────────────────────────────────────
function forwardBashArgs(args) {
const command = args.command || args.shell_command || '';
return {
command_line: String(command),
cwd: typeof args.cwd === 'string' ? args.cwd : '',
blocking: true,
};
}
function reverseBashArgs(cascade) {
return {
command: cascade.command_line || cascade.proposed_command_line || '',
...(cascade.cwd ? { cwd: cascade.cwd } : {}),
};
}
// ── Glob / find ─────────────────────────────────────────────────
function forwardGlobArgs(args) {
return {
pattern: args.pattern || '',
search_directory: args.path || args.cwd || '',
};
}
function reverseGlobArgs(cascade) {
const path = cascade.search_directory || stripFileUri(cascade.directory_path_uri || '');
return {
// Cascade may satisfy a Glob request via list_directory when the
// experiment allowlist maps find -> list_dir. That step has no glob
// pattern field, so keep the caller's schema valid with a broad match.
pattern: cascade.pattern || (cascade.directory_path_uri ? '*' : ''),
...(path ? { path } : {}),
};
}
// ── Grep / grep_search_v2 ───────────────────────────────────────
function forwardGrepArgs(args) {
return {
pattern: args.pattern || '',
path: args.path || '',
glob: args.glob || '',
output_mode: args.output_mode || 'files_with_matches',
case_insensitive: !!args['-i'],
multiline: !!args.multiline,
type: args.type || '',
head_limit: Number(args.head_limit) || 0,
lines_after: Number(args['-A']) || 0,
lines_before: Number(args['-B']) || 0,
lines_both: Number(args['-C'] ?? args.context) || 0,
};
}
function reverseGrepArgs(cascade) {
const out = { pattern: cascade.pattern || '' };
if (cascade.path) out.path = cascade.path;
if (cascade.glob) out.glob = cascade.glob;
if (cascade.output_mode) out.output_mode = cascade.output_mode;
if (cascade.case_insensitive) out['-i'] = true;
if (cascade.multiline) out.multiline = true;
if (cascade.type) out.type = cascade.type;
if (cascade.head_limit) out.head_limit = cascade.head_limit;
if (cascade.lines_after) out['-A'] = cascade.lines_after;
if (cascade.lines_before) out['-B'] = cascade.lines_before;
if (cascade.lines_both) out['-C'] = cascade.lines_both;
return out;
}
// ── Write / write_to_file ──────────────────────────────────────
function forwardWriteArgs(args) {
const file_path = args.file_path || args.path || '';
const content = args.content || '';
return {
target_file_uri: buildFileUri(file_path),
code_content: typeof content === 'string' ? [content] : Array.isArray(content) ? content : [String(content)],
};
}
function reverseWriteArgs(cascade) {
const lines = Array.isArray(cascade.code_content) ? cascade.code_content : [];
return {
file_path: stripFileUri(cascade.target_file_uri || ''),
content: lines.join(''),
};
}
// ── list_dir / list_directory ──────────────────────────────────
function forwardListDirArgs(args) {
return {
directory_path_uri: buildFileUri(args.path || args.directory_path || args.cwd || ''),
};
}
function reverseListDirArgs(cascade) {
return {
path: stripFileUri(cascade.directory_path_uri || ''),
};
}
// ── identity (when caller already speaks cascade vocabulary) ───
function identityArgs(x) { return { ...x }; }
// ── Edit / MultiEdit ↔ propose_code (v2.0.70) ──────────────────
// Claude Code's Edit tool: { file_path, old_string, new_string,
// replace_all? }. MultiEdit: { file_path, edits: [{old_string,
// new_string}, ...] }. Both map onto cascade's ActionSpecCommand
// (field 1 of ActionSpec, which is field 1 of CortexStepProposeCode).
//
// ActionSpecCommand.replacement_chunks is a repeated ReplacementChunk
// { target_content=1, replacement_content=2, allow_multiple=3 }.
//
// We forward to a lightweight intermediate dict (cascade-side keys)
// so build_propose_code_body can pull them and serialise to proto.
// MultiEdit collapses into one ActionSpecCommand with multiple chunks.
function forwardClaudeEditArgs(args) {
const file_path = args.file_path || args.path || '';
const chunks = [];
if (Array.isArray(args.edits)) {
for (const e of args.edits) {
chunks.push({
target: typeof e?.old_string === 'string' ? e.old_string : '',
replacement: typeof e?.new_string === 'string' ? e.new_string : '',
allow_multiple: !!e?.replace_all,
});
}
} else {
chunks.push({
target: typeof args.old_string === 'string' ? args.old_string : '',
replacement: typeof args.new_string === 'string' ? args.new_string : '',
allow_multiple: !!args.replace_all,
});
}
return { target_file_uri: buildFileUri(file_path), replacement_chunks: chunks, instruction: '' };
}
function reverseClaudeEditArgs(cascade) {
if (cascade && typeof cascade.__raw_edit === 'string') return safeJsonParse(cascade.__raw_edit);
const file_path = stripFileUri(cascade.target_file_uri || '');
const chunks = Array.isArray(cascade.replacement_chunks) ? cascade.replacement_chunks : [];
if (chunks.length <= 1) {
const c = chunks[0] || {};
return {
file_path,
old_string: c.target || '',
new_string: c.replacement || '',
...(c.allow_multiple ? { replace_all: true } : {}),
};
}
return {
file_path,
edits: chunks.map(c => ({
old_string: c.target || '',
new_string: c.replacement || '',
...(c.allow_multiple ? { replace_all: true } : {}),
})),
};
}
// ── web_search ↔ search_web (v2.0.70) ─────────────────────────
// CortexStepSearchWeb { query=1, domain=3, web_documents=2, ... }
// Caller's `web_search` declares { query, domains? }. domains[] ≥ 1
// → we pick the first (cascade only takes a single `domain` string).
function forwardWebSearchArgs(args) {
return {
query: args.query || args.q || '',
domain: Array.isArray(args.domains) && args.domains.length ? args.domains[0] : (args.domain || ''),
};
}
function reverseWebSearchArgs(cascade) {
return {
query: cascade.query || '',
...(cascade.domain ? { domains: [cascade.domain] } : {}),
};
}
// ── WebFetch ↔ read_url_content (v2.0.93) ─────────────────────────
// CortexStepReadUrlContent { url=1, web_document=2, resolved_url=3, ... }
function forwardWebFetchArgs(args) {
return { url: args.url || args.uri || args.link || '' };
}
function reverseWebFetchArgs(cascade) {
return { url: cascade.url || '' };
}
// ── apply_patch ↔ write_to_file (single-file fan-out, v2.0.70) ──
// codex CLI's `apply_patch` ships a multi-file patch in a custom
// grammar. Full fan-out (parsing the patch and emitting one
// write_to_file per file) is outside the scope of a single-step
// translator — the cascade trajectory expects the proxy to fan out
// at message-build time. For now reverse maps `__raw_apply_patch` so
// the caller's tool_call round-trips, and forward refuses (returns
// null) so partition mode falls back to emulation for this tool. The
// caller still gets a working apply_patch via emulation toolPreamble.
function forwardApplyPatchArgs(args) {
// Sentinel that build_step_body recognises and skips (returns null
// buffer, partition treats as unmapped).
return { __apply_patch_unmappable: true, raw: typeof args === 'string' ? args : (args.input || JSON.stringify(args || {})) };
}
function reverseApplyPatchArgs(cascade) {
if (cascade && typeof cascade.raw === 'string') return { input: cascade.raw };
return cascade || {};
}
// ─── OpenAI tool name → cascade kind table ──────────────────────────
//
// Keys are the EXACT tool name the caller declares in tools[].function.name.
// Casing matters — Claude Code uses TitleCase (Read, Bash); Codex CLI uses
// snake_case (view_file, run_command). Both are honored here.
export const TOOL_MAP = {
// Claude Code
Read: { kind: 'view_file', forward: forwardReadArgs, reverse: reverseReadArgs },
Bash: { kind: 'run_command', forward: forwardBashArgs, reverse: reverseBashArgs },
Glob: { kind: 'find', forward: forwardGlobArgs, reverse: reverseGlobArgs },
Grep: { kind: 'grep_search_v2', forward: forwardGrepArgs, reverse: reverseGrepArgs },
Write: { kind: 'write_to_file', forward: forwardWriteArgs, reverse: reverseWriteArgs },
// v2.0.70: Edit/MultiEdit really map to cascade propose_code with
// ReplacementChunks now. Old pass-through (forwardEditArgs) kept as
// fallback for the rare case the cascade body builder rejects the
// chunk shape (claude code emits a tool_call with no old_string).
Edit: { kind: 'propose_code', forward: forwardClaudeEditArgs, reverse: reverseClaudeEditArgs },
MultiEdit: { kind: 'propose_code', forward: forwardClaudeEditArgs, reverse: reverseClaudeEditArgs },
WebSearch: { kind: 'search_web', forward: forwardWebSearchArgs, reverse: reverseWebSearchArgs },
// v2.0.93 — ToolSearch is Claude Code's web search tool (same as WebSearch)
ToolSearch: { kind: 'search_web', forward: forwardWebSearchArgs, reverse: reverseWebSearchArgs },
// v2.0.93 — WebFetch is Claude Code's URL fetch tool (map to read_url_content)
WebFetch: { kind: 'read_url_content', forward: forwardWebFetchArgs, reverse: reverseWebFetchArgs },
// Codex CLI (already speaks cascade-ish vocabulary)
view_file: { kind: 'view_file', forward: identityArgs, reverse: identityArgs },
run_command: { kind: 'run_command', forward: forwardRunCommandPassThrough, reverse: reverseRunCommandPassThrough },
grep_v2: { kind: 'grep_search_v2', forward: identityArgs, reverse: identityArgs },
grep_search: { kind: 'grep_search_v2', forward: identityArgs, reverse: identityArgs },
grep_search_v2: { kind: 'grep_search_v2', forward: identityArgs, reverse: identityArgs },
find: { kind: 'find', forward: identityArgs, reverse: identityArgs },
list_dir: { kind: 'list_directory', forward: forwardListDirArgs, reverse: reverseListDirArgs },
list_directory: { kind: 'list_directory', forward: forwardListDirArgs, reverse: reverseListDirArgs },
write_to_file: { kind: 'write_to_file', forward: identityArgs, reverse: identityArgs },
// Common synonyms surfaced by other clients
read_file: { kind: 'view_file', forward: forwardReadArgs, reverse: reverseReadArgs },
shell: { kind: 'run_command', forward: forwardBashArgs, reverse: reverseBashArgs },
// ── Codex CLI 0.128 toolset (#115 v2.0.66) ───────────────────────
// Captured from a real codex exec request body via
// scripts/probes/dump-codex-tools.mjs. codex CLI declares 11 tools by
// default; only `shell_command` has a clean cascade-native equivalent.
// The rest (apply_patch / update_plan / request_user_input / web_search /
// view_image / spawn_agent / send_input / resume_agent / wait_agent /
// close_agent) intentionally stay OFF this map — partition mode routes
// unmapped tools through the existing toolPreamble emulation path.
// Adding apply_patch / web_search here was tried in v2.0.66 dev but
// their forward translators have no lossless cascade target (apply_patch
// is multi-file patches, write_to_file is single-target; web_search ≠
// read_url_content), so they'd produce garbage cascade steps.
shell_command: { kind: 'run_command', forward: forwardCodexShellArgs, reverse: reverseCodexShellArgs },
// v2.0.70 — codex `web_search` maps to cascade search_web. codex
// declares it as type:'web_search' but responses.js flattens to
// function/web_search before it reaches this map.
web_search: { kind: 'search_web', forward: forwardWebSearchArgs, reverse: reverseWebSearchArgs },
};
// Edit / MultiEdit translate to propose_code. ActionSpec / ActionResult are
// nested messages with their own schemas — for v2.0.65 we degrade Edit to a
// pass-through that preserves args inside CustomToolSpec.arguments_json so
// the planner sees a structured record without us shipping the full
// ActionSpec proto. The caller's reverse translator restores its original
// payload because we keep the arguments_json verbatim.
function forwardEditArgs(args) {
return { __raw_edit: JSON.stringify(args || {}) };
}
function reverseEditArgs(cascade) {
if (cascade && typeof cascade.__raw_edit === 'string') return safeJsonParse(cascade.__raw_edit);
return cascade || {};
}
// run_command pass-through: cascade and Codex both name the param
// "command" / "command_line" — accept either, normalise on the cascade side.
function forwardRunCommandPassThrough(args) {
return {
command_line: args.command_line || args.command || '',
cwd: args.cwd || '',
blocking: true,
};
}
function reverseRunCommandPassThrough(cascade) {
return {
command_line: cascade.command_line || cascade.proposed_command_line || '',
...(cascade.cwd ? { cwd: cascade.cwd } : {}),
};
}
// ── Codex CLI 0.128 codex-specific arg shapes ───────────────────────
// codex CLI's `shell_command` declares: {command:"<cmd>", workdir?:"...",
// timeout_ms?:int}. cascade run_command takes command_line + cwd. The
// reverse direction restores codex's expected shape so when the proxy
// surfaces a cascade-side run_command step back to codex CLI, codex
// picks it up as a normal shell_command tool_call.
function forwardCodexShellArgs(args) {
return {
command_line: args.command || args.command_line || '',
cwd: args.workdir || args.cwd || '',
blocking: true,
};
}
function reverseCodexShellArgs(cascade) {
return {
command: cascade.command_line || cascade.proposed_command_line || '',
...(cascade.cwd ? { workdir: cascade.cwd } : {}),
};
}
// ─── Caller-tools introspection ─────────────────────────────────────
/**
* canMapAllTools(tools) — returns true when EVERY caller-declared tool is
* present in TOOL_MAP. Kept for backwards compatibility / strict-mode
* callers; v2.0.66 prefers partitionTools() because real-world clients
* (codex CLI 0.128 declares 11 tools, only 1 maps cleanly) almost never
* pass the all-mapped bar.
*/
export function canMapAllTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) return false;
for (const t of tools) {
if (t?.type !== 'function') return false;
const name = t.function?.name;
if (!name || !TOOL_MAP[name] || !isNativeBridgeToolAllowed(name)) return false;
}
return true;
}
/**
* partitionTools(tools) — split the caller's tools[] into:
* - mapped: tools with a TOOL_MAP entry (route through cascade native
* trajectory steps)
* - unmapped: tools without a mapping (route through the existing
* emulation toolPreamble path)
*
* Both subsets coexist in the same request: mapped tools enable native
* planner_mode=DEFAULT + tool_allowlist while unmapped tool definitions
* are still injected into additional_instructions_section so the planner
* can fall through to text-protocol emit when it needs them.
*
* Returns { mapped: Tool[], unmapped: Tool[], hasAny: bool }.
*/
export function partitionTools(tools) {
const mapped = [];
const unmapped = [];
if (Array.isArray(tools)) {
for (const t of tools) {
if (t?.type !== 'function' || !t.function?.name) continue;
if (TOOL_MAP[t.function.name] && isNativeBridgeToolAllowed(t.function.name)) mapped.push(t);
else unmapped.push(t);
}
}
return { mapped, unmapped, hasAny: mapped.length > 0 };
}
/**
* Build the inverse lookup table: cascade kind → list of caller tool names
* that map onto it. Used by translateCascadeStepToToolCall to pick the
* caller-visible name when the planner emits a step (the caller might have
* declared `Read` OR `view_file` for the same kind; we emit the one they
* actually declared).
*/
export function buildReverseLookup(callerTools) {
const out = new Map();
if (!Array.isArray(callerTools)) return out;
for (const t of callerTools) {
if (t?.type !== 'function' || !t.function?.name) continue;
const name = t.function.name;
const entry = TOOL_MAP[name];
if (!entry) continue;
addReverseLookup(out, entry.kind, name);
const allowlistKind = cascadeKindForAllowlistName(nativeAllowlistNameForTool(name));
if (allowlistKind && allowlistKind !== entry.kind) {
addReverseLookup(out, allowlistKind, name);
}
}
return out;
}
function addReverseLookup(out, kind, name) {
if (!kind || !name) return;
if (!out.has(kind)) out.set(kind, []);
const names = out.get(kind);
if (!names.includes(name)) names.push(name);
}
function cascadeKindForAllowlistName(name) {
const n = String(name || '').trim();
if (!n) return '';
if (TOOL_MAP[n]?.kind) return TOOL_MAP[n].kind;
return CASCADE_STEP[n] ? n : '';
}
function nativeAllowlistNameOverrides() {
const out = new Map();
const raw = String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_ALLOWLIST_NAMES || '').trim();
if (!raw) return out;
for (const part of raw.split(',')) {
const [left, ...rest] = part.split(':');
const key = String(left || '').trim();
const value = rest.join(':').trim();
if (!key || !value) continue;
out.set(key, value);
}
return out;
}
export function nativeAllowlistNameForTool(toolName) {
const name = String(toolName || '').trim();
const entry = TOOL_MAP[name];
if (!entry) return '';
const overrides = nativeAllowlistNameOverrides();
if (overrides.has(name)) return overrides.get(name);
if (overrides.has(entry.kind)) return overrides.get(entry.kind);
if (entry.kind === 'view_file') return 'read_file';
return entry.kind;
}
// ─── Forward: build cascade trajectory step proto ───────────────────
//
// These produce raw protobuf bytes that drop straight into
// CortexTrajectoryStep oneof — caller wraps them with the trajectory step
// envelope (type, status, etc) via buildAdditionalStep below.
function buildViewFileBody(args) {
const parts = [];
if (args.absolute_path_uri) parts.push(writeStringField(1, args.absolute_path_uri));
if (args.offset) parts.push(writeVarintField(11, args.offset));
if (args.limit) parts.push(writeVarintField(12, args.limit));
if (typeof args.content === 'string') parts.push(writeStringField(4, args.content));
return Buffer.concat(parts);
}
function buildRunCommandBody(args) {
const parts = [];
if (args.command_line) parts.push(writeStringField(23, args.command_line));
if (args.cwd) parts.push(writeStringField(2, args.cwd));
if (args.blocking) parts.push(writeBoolField(11, true));
if (typeof args.stdout === 'string') parts.push(writeStringField(4, args.stdout));
if (typeof args.stderr === 'string') parts.push(writeStringField(5, args.stderr));
if (Number.isFinite(args.exit_code)) parts.push(writeVarintField(6, args.exit_code));
// combined_output (field 21) — RunCommandOutput { full=1 }. The planner
// reads this when reasoning about command results, so we mirror stdout
// there in addition to the legacy stdout field.
if (typeof args.full_output === 'string') {
const inner = writeStringField(1, args.full_output);
parts.push(writeMessageField(21, inner));
}
return Buffer.concat(parts);
}
function buildGrepSearchV2Body(args) {
const parts = [];
if (args.pattern) parts.push(writeStringField(2, args.pattern));
if (args.path) parts.push(writeStringField(3, args.path));
if (args.glob) parts.push(writeStringField(4, args.glob));
if (args.output_mode) parts.push(writeStringField(5, args.output_mode));
if (args.case_insensitive) parts.push(writeBoolField(10, true));
if (args.multiline) parts.push(writeBoolField(13, true));
if (args.type) parts.push(writeStringField(11, args.type));
if (args.head_limit) parts.push(writeVarintField(12, args.head_limit));
if (args.lines_after) parts.push(writeVarintField(6, args.lines_after));
if (args.lines_before) parts.push(writeVarintField(7, args.lines_before));
if (args.lines_both) parts.push(writeVarintField(8, args.lines_both));
if (typeof args.raw_output === 'string') parts.push(writeStringField(15, args.raw_output));
return Buffer.concat(parts);
}
function buildFindBody(args) {
const parts = [];
if (args.search_directory) parts.push(writeStringField(10, args.search_directory));
if (args.pattern) parts.push(writeStringField(1, args.pattern));
if (typeof args.raw_output === 'string') parts.push(writeStringField(11, args.raw_output));
return Buffer.concat(parts);
}
function buildListDirectoryBody(args) {
const parts = [];
if (args.directory_path_uri) parts.push(writeStringField(1, args.directory_path_uri));
// children (field 2, repeated string) — populated when emitting a fake
// "we already listed this dir, here are the names" step on tool_result.
if (Array.isArray(args.children)) {
for (const c of args.children) parts.push(writeStringField(2, String(c)));
}
return Buffer.concat(parts);
}
function buildWriteToFileBody(args) {
const parts = [];
if (args.target_file_uri) parts.push(writeStringField(1, args.target_file_uri));
if (Array.isArray(args.code_content)) {
for (const line of args.code_content) parts.push(writeStringField(2, String(line)));
}
if (args.file_created) parts.push(writeBoolField(4, true));
return Buffer.concat(parts);
}
// v2.0.70 — propose_code (CortexStepProposeCode = field 32 oneof).
// Body schema:
// ActionSpec action_spec = 1 message
// ActionResult action_result = 2 message
// string code_instruction = 3
// string markdown_language = 4
//
// ActionSpec.command = 1 → ActionSpecCommand:
// string instruction = 1
// repeated ReplacementChunk chunks = 9
// bool is_edit = 2
// oneof target → file = 4 (PathScopeItem) using absolute_uri = 5
//
// ReplacementChunk:
// string target_content = 1
// string replacement_content = 2
// bool allow_multiple = 3
function buildProposeCodeBody(args) {
const parts = [];
// action_spec.command(1).{...}
const cmdParts = [];
if (args.instruction) cmdParts.push(writeStringField(1, args.instruction));
cmdParts.push(writeBoolField(2, true)); // is_edit
if (Array.isArray(args.replacement_chunks)) {
for (const c of args.replacement_chunks) {
const chunkParts = [];
if (typeof c.target === 'string') chunkParts.push(writeStringField(1, c.target));
if (typeof c.replacement === 'string') chunkParts.push(writeStringField(2, c.replacement));
if (c.allow_multiple) chunkParts.push(writeBoolField(3, true));
cmdParts.push(writeMessageField(9, Buffer.concat(chunkParts)));
}
}
if (args.target_file_uri) {
// PathScopeItem { absolute_uri = 5 }
const psi = writeStringField(5, args.target_file_uri);
cmdParts.push(writeMessageField(4, psi)); // ActionSpecCommand.target.file = 4
}
const command = Buffer.concat(cmdParts);
// ActionSpec.command oneof at field 1
const actionSpec = writeMessageField(1, command);
parts.push(writeMessageField(1, actionSpec)); // CortexStepProposeCode.action_spec = 1
if (args.instruction) parts.push(writeStringField(3, args.instruction));
return Buffer.concat(parts);
}
// v2.0.70 — search_web (CortexStepSearchWeb = field 42 oneof).
// query = 1 string
// domain = 3 string
// summary = 5 string (server-filled; we mirror it on observation injection)
function buildSearchWebBody(args) {
const parts = [];
if (args.query) parts.push(writeStringField(1, args.query));
if (args.domain) parts.push(writeStringField(3, args.domain));
if (typeof args.summary === 'string') parts.push(writeStringField(5, args.summary));
return Buffer.concat(parts);
}
// v2.0.93 — read_url_content (CortexStepReadUrlContent = field 40 oneof).
// url = 1 string
// web_document = 2 KnowledgeBaseItem
// resolved_url = 3 string
// latency_ms = 4 uint64
// user_rejected = 6 bool
// auto_decision = 7 enum
//
// KnowledgeBaseItem fields confirmed from the official Windsurf 2.3.15
// generated client: text=2, url=3, title=4, chunks=6, summary=7.
function buildKnowledgeBaseItem(args = {}) {
const text = typeof args.text === 'string'
? args.text
: (typeof args.content === 'string' ? args.content : '');
const summary = typeof args.summary === 'string' ? args.summary : '';
const url = typeof args.url === 'string' ? args.url : '';
const title = typeof args.title === 'string' ? args.title : '';
const parts = [];
if (text) parts.push(writeStringField(2, text));
if (url) parts.push(writeStringField(3, url));
if (title) parts.push(writeStringField(4, title));
if (summary) parts.push(writeStringField(7, summary));
return Buffer.concat(parts);
}
function buildReadUrlContentBody(args) {
const parts = [];
if (args.url) parts.push(writeStringField(1, args.url));
const doc = buildKnowledgeBaseItem({
text: args.text || args.content || args.summary || '',
summary: args.summary || '',
url: args.url || args.resolved_url || '',
title: args.title || '',
});
if (doc.length) parts.push(writeMessageField(2, doc));
if (args.resolved_url) parts.push(writeStringField(3, args.resolved_url));
if (args.latency_ms != null) parts.push(writeVarintField(4, Number(args.latency_ms) || 0));
if (args.user_rejected) parts.push(writeBoolField(6, true));
if (args.auto_run_decision != null) parts.push(writeVarintField(7, Number(args.auto_run_decision) || 0));
return Buffer.concat(parts);
}
const STEP_BODY_BUILDER = {
view_file: buildViewFileBody,
run_command: buildRunCommandBody,
grep_search_v2: buildGrepSearchV2Body,
grep_search: buildGrepSearchV2Body,
find: buildFindBody,
list_directory: buildListDirectoryBody,
write_to_file: buildWriteToFileBody,
propose_code: buildProposeCodeBody,
search_web: buildSearchWebBody,
read_url_content: buildReadUrlContentBody,
};
/**
* Build a CortexTrajectoryStep proto carrying a completed cascade-side step.
* Used to inject "the caller's tool result, expressed as if the planner
* already ran the equivalent cascade tool" into
* SendUserCascadeMessageRequest.additional_steps[9]. The planner sees a
* trajectory that already contains the answer and continues reasoning from
* there instead of asking again.
*
* `observed` is REQUIRED (V-13 / KH-034). A step may only be stamped DONE
* when the caller reported a result for it; without that gate an unobserved
* call was encoded as "ran successfully, empty output", which the planner
* then treated as real history.
*
* Returns null if `kind` has no encoder or no observation was supplied.
*/
export function buildAdditionalStep(kind, args, { observed = false } = {}) {
const meta = CASCADE_STEP[kind];
const builder = STEP_BODY_BUILDER[kind];
if (!meta || !builder || observed !== true) return null;
const body = builder(args || {});
// CortexTrajectoryStep envelope: type=1, status=4, oneof step body
return Buffer.concat([
writeVarintField(1, meta.typeEnum),
writeVarintField(4, CASCADE_STEP_STATUS_DONE),
writeMessageField(meta.oneofField, body),
]);
}
// ─── Reverse: parse cascade trajectory step → OpenAI tool_call ────────
//
// CortexTrajectoryStep parser shared with windsurf.js parseTrajectorySteps.
// This module's variant focuses on the args side — given an already-parsed
// step (with the oneof body extracted), produce a {kind, args, observation}
// triple. windsurf.js handles the integer-tag pass; here we only decode the
// per-kind field schema.
function decodeViewFileStep(buf) {
const f = parseFields(buf);
return {
absolute_path_uri: getField(f, 1, 2)?.value?.toString('utf8') || '',
start_line: Number(getField(f, 2, 0)?.value || 0),
end_line: Number(getField(f, 3, 0)?.value || 0),
offset: Number(getField(f, 11, 0)?.value || 0),
limit: Number(getField(f, 12, 0)?.value || 0),
content: getField(f, 4, 2)?.value?.toString('utf8') || '',
raw_content: getField(f, 9, 2)?.value?.toString('utf8') || '',
};
}
function decodeRunCommandStep(buf) {
const f = parseFields(buf);
const decoded = {
command_line: getField(f, 23, 2)?.value?.toString('utf8')
|| getField(f, 1, 2)?.value?.toString('utf8') || '',
proposed_command_line: getField(f, 25, 2)?.value?.toString('utf8') || '',
cwd: getField(f, 2, 2)?.value?.toString('utf8') || '',
stdout: getField(f, 4, 2)?.value?.toString('utf8') || '',
stderr: getField(f, 5, 2)?.value?.toString('utf8') || '',
exit_code: getField(f, 6, 0)?.value,
};
// combined_output (RunCommandOutput { full=1 })
const combined = getField(f, 21, 2);
if (combined) {
const c = parseFields(combined.value);
const full = getField(c, 1, 2)?.value?.toString('utf8') || '';
if (full) decoded.full_output = full;
}
return decoded;
}
function decodeGrepSearchV2Step(buf) {
const f = parseFields(buf);
return {
pattern: getField(f, 2, 2)?.value?.toString('utf8') || '',
path: getField(f, 3, 2)?.value?.toString('utf8') || '',
glob: getField(f, 4, 2)?.value?.toString('utf8') || '',
output_mode: getField(f, 5, 2)?.value?.toString('utf8') || '',
lines_after: Number(getField(f, 6, 0)?.value || 0),
lines_before: Number(getField(f, 7, 0)?.value || 0),
lines_both: Number(getField(f, 8, 0)?.value || 0),
case_insensitive: !!getField(f, 10, 0)?.value,
multiline: !!getField(f, 13, 0)?.value,
type: getField(f, 11, 2)?.value?.toString('utf8') || '',
head_limit: Number(getField(f, 12, 0)?.value || 0),
raw_output: getField(f, 15, 2)?.value?.toString('utf8') || '',
};
}
function decodeFindStep(buf) {
const f = parseFields(buf);
return {
search_directory: getField(f, 10, 2)?.value?.toString('utf8') || '',
pattern: getField(f, 1, 2)?.value?.toString('utf8') || '',
raw_output: getField(f, 11, 2)?.value?.toString('utf8') || '',
};
}
function decodeListDirectoryStep(buf) {
const f = parseFields(buf);
return {
directory_path_uri: getField(f, 1, 2)?.value?.toString('utf8') || '',
children: getAllFields(f, 2)
.filter(x => x.wireType === 2)
.map(x => x.value.toString('utf8')),
};
}
function decodeWriteToFileStep(buf) {
const f = parseFields(buf);
return {
target_file_uri: getField(f, 1, 2)?.value?.toString('utf8') || '',
code_content: getAllFields(f, 2)
.filter(x => x.wireType === 2)
.map(x => x.value.toString('utf8')),
file_created: !!getField(f, 4, 0)?.value,
};
}
// v2.0.70 — decode propose_code: pull file_uri + replacement_chunks
// out of nested ActionSpec.command for round-trip back to Edit args.
function decodeProposeCodeStep(buf) {
const f = parseFields(buf);
const actionSpec = getField(f, 1, 2);
const out = { target_file_uri: '', replacement_chunks: [], instruction: '' };
if (!actionSpec) return out;
const asFields = parseFields(actionSpec.value);
const command = getField(asFields, 1, 2);
if (!command) return out;
const cmdFields = parseFields(command.value);
const instr = getField(cmdFields, 1, 2);
if (instr) out.instruction = instr.value.toString('utf8');
const fileTarget = getField(cmdFields, 4, 2);
if (fileTarget) {
const psi = parseFields(fileTarget.value);
const uri = getField(psi, 5, 2)?.value?.toString('utf8')
|| getField(psi, 1, 2)?.value?.toString('utf8') || '';
out.target_file_uri = uri;
}
for (const chunkField of getAllFields(cmdFields, 9)) {
if (chunkField.wireType !== 2) continue;
const cp = parseFields(chunkField.value);
out.replacement_chunks.push({
target: getField(cp, 1, 2)?.value?.toString('utf8') || '',
replacement: getField(cp, 2, 2)?.value?.toString('utf8') || '',
allow_multiple: !!getField(cp, 3, 0)?.value,
});
}
return out;
}
function decodeSearchWebStep(buf) {
const f = parseFields(buf);
return {
query: getField(f, 1, 2)?.value?.toString('utf8') || '',
domain: getField(f, 3, 2)?.value?.toString('utf8') || '',
summary: getField(f, 5, 2)?.value?.toString('utf8') || '',
};
}
function decodeMarkdownChunk(buf) {
const f = parseFields(buf);
return {
text: getField(f, 2, 2)?.value?.toString('utf8') || '',
};
}
function decodeKnowledgeBaseChunk(buf) {
const f = parseFields(buf);
const markdown = getField(f, 3, 2);
return {
text: getField(f, 1, 2)?.value?.toString('utf8') || '',
markdown_text: markdown ? decodeMarkdownChunk(markdown.value).text : '',
};
}
function decodeKnowledgeBaseItem(buf) {
const f = parseFields(buf);
return {
text: getField(f, 2, 2)?.value?.toString('utf8') || '',
url: getField(f, 3, 2)?.value?.toString('utf8') || '',
title: getField(f, 4, 2)?.value?.toString('utf8') || '',
chunks: getAllFields(f, 6)
.filter(x => x.wireType === 2)
.map(x => decodeKnowledgeBaseChunk(x.value)),
summary: getField(f, 7, 2)?.value?.toString('utf8') || '',
};
}
function knowledgeBaseItemText(doc = {}) {
if (doc.text) return doc.text;
const chunkText = (doc.chunks || [])
.map(c => c.text || c.markdown_text || '')
.filter(Boolean)
.join('\n');
return chunkText || doc.summary || '';
}
function decodeReadUrlContentStep(buf) {
const f = parseFields(buf);
const webDocumentField = getField(f, 2, 2);
const web_document = webDocumentField ? decodeKnowledgeBaseItem(webDocumentField.value) : null;
const legacy_summary = getField(f, 5, 2)?.value?.toString('utf8') || '';
const content = knowledgeBaseItemText(web_document || {}) || legacy_summary;
return {
url: getField(f, 1, 2)?.value?.toString('utf8') || '',
web_document,
resolved_url: getField(f, 3, 2)?.value?.toString('utf8') || '',
latency_ms: Number(getField(f, 4, 0)?.value || 0),
legacy_summary,
content,
summary: web_document?.summary || legacy_summary,
user_rejected: !!getField(f, 6, 0)?.value,
auto_run_decision: Number(getField(f, 7, 0)?.value || 0),
};
}
const STEP_BODY_DECODER = {
view_file: decodeViewFileStep,
run_command: decodeRunCommandStep,
grep_search_v2: decodeGrepSearchV2Step,
grep_search: decodeGrepSearchV2Step,
find: decodeFindStep,
list_directory: decodeListDirectoryStep,
write_to_file: decodeWriteToFileStep,
propose_code: decodeProposeCodeStep,
search_web: decodeSearchWebStep,
read_url_content: decodeReadUrlContentStep,
};
/**
* Given a CortexTrajectoryStep envelope (already parsed) and the caller's
* declared tools[], emit an OpenAI-shaped tool_call:
*
* { id, name, argumentsJson, observation }
*
* - `id` is synthesised from cascadeId + step index by the caller of this
* function (we don't have those handles here).
* - `name` is the caller-visible OpenAI tool name (Read/Bash/Grep/...) —
* resolved via buildReverseLookup. Falls back to the cascade kind name
* when the caller didn't declare a matching tool, which lets the proxy
* STILL surface trajectory tool calls for diagnostic purposes.
* - `argumentsJson` is `JSON.stringify(reverse(decoded))`.
* - `observation` is the part of the step the planner already filled in
* (file content, command stdout, etc) — the proxy DROPS this when
* forwarding to the caller (the caller will run their own version) but
* it's exposed here so callers that want to short-circuit on cached
* results can detect them.
*/
export function decodeCascadeStepToToolCall(stepFields, kind, callerLookup) {
const decoder = STEP_BODY_DECODER[kind];
const meta = CASCADE_STEP[kind];
if (!decoder || !meta) return null;
const oneof = getField(stepFields, meta.oneofField, 2);
if (!oneof) return null;
const decoded = decoder(oneof.value);
const candidates = callerLookup?.get(kind) || [];
// Pick the caller's preferred name. If they declared multiple tools that
// map onto this kind (rare — e.g., both Read and read_file) we use the
// first one in declaration order, which matches what they'd get from
// calling the same tool twice in their own code.
const callerName = candidates[0] || kind;
const reverseFn = TOOL_MAP[callerName]?.reverse || identityArgs;
let args;
try {
args = reverseFn(decoded);
} catch {
args = decoded;
}
return {
name: callerName,
arguments: args,
cascade_kind: kind,
observation: decoded,
};
}
/**
* Claude sometimes emits its provider-native tool syntax while Cascade is in
* native bridge mode:
*
* <function_calls>
* <invoke name="read_file">
* <parameter name="path">README.md</parameter>
* </invoke>
*
* This is not prompt emulation. It is a native-format fallback for models that
* did not surface a CortexTrajectoryStep but still produced a structured tool
* proposal. Convert it back to the caller's declared tool name.
*/
export function parseNativeFunctionCallsFromText(text, callerLookup = new Map()) {
if (typeof text !== 'string' || !/<function_calls\b/i.test(text)) {
return { text: text || '', toolCalls: [] };
}
const calls = [];
const keepOutsideBlocks = [];
let lastBlockEnd = 0;
const blockRe = /<function_calls\b[^>]*>([\s\S]*?)<\/function_calls>/gi;
const invokeRe = /<invoke\b([^>]*)>([\s\S]*?)<\/invoke>/gi;
let block;
while ((block = blockRe.exec(text)) !== null) {
keepOutsideBlocks.push(text.slice(lastBlockEnd, block.index));
lastBlockEnd = block.index + block[0].length;
invokeRe.lastIndex = 0;
let match;
while ((match = invokeRe.exec(block[1])) !== null) {
const nameAttr = match[1].match(/\bname\s*=\s*"([^"]+)"/i)
|| match[1].match(/\bname\s*=\s*'([^']+)'/i);
const rawName = nameAttr ? unescapeXmlText(nameAttr[1]).trim() : '';
const entry = TOOL_MAP[rawName];
if (!rawName || !entry) continue;
const params = {};
const paramRe = /<parameter\b([^>]*)>([\s\S]*?)<\/parameter>/gi;
let pm;
while ((pm = paramRe.exec(match[2])) !== null) {
const paramNameAttr = pm[1].match(/\bname\s*=\s*"([^"]+)"/i)
|| pm[1].match(/\bname\s*=\s*'([^']+)'/i);
const paramName = paramNameAttr ? unescapeXmlText(paramNameAttr[1]).trim() : '';
if (!paramName) continue;
const rawValue = unescapeXmlText(pm[2].trim());
const parsed = safeJsonParse(rawValue);
params[paramName] = Object.keys(parsed).length || /^[\[{]/.test(rawValue.trim())
? parsed
: rawValue;
}
const cascadeKind = entry.kind;
const candidates = callerLookup?.get(cascadeKind) || [];
const callerName = candidates[0];
if (!callerName) continue;
const reverseFn = TOOL_MAP[callerName]?.reverse || identityArgs;
let openaiArgs;
try { openaiArgs = reverseFn(entry.forward(params)); }
catch { openaiArgs = params; }
calls.push({
id: `call_native_${calls.length}_${Date.now().toString(36)}`,
name: callerName,
argumentsJson: JSON.stringify(openaiArgs ?? {}),
});
}
}
const trailing = text.slice(lastBlockEnd);
const danglingOpen = trailing.search(/<function_calls\b/i);
keepOutsideBlocks.push(danglingOpen === -1 ? trailing : trailing.slice(0, danglingOpen));
return { text: keepOutsideBlocks.join('').trim(), toolCalls: calls };
}
/**
* Streaming guard for Claude provider-native <function_calls> blocks in native
* bridge mode. It withholds any partial block from content deltas until a
* closing </function_calls> arrives, then converts it to OpenAI tool_calls.
*/
const NATIVE_XML_BODY_MAX = 65_536;
export class NativeFunctionCallStreamParser {
constructor(callerLookup = new Map()) {
this.callerLookup = callerLookup;
this.buffer = '';
}
feed(delta) {
if (!delta) return { text: '', toolCalls: [] };
this.buffer += String(delta);
return this._drain(false);
}
flush() {
return this._drain(true);
}
_drain(flush) {
const out = [];
const toolCalls = [];
const openRe = /<function_calls\b/i;
while (this.buffer) {
const open = this.buffer.search(openRe);
if (open === -1) {
const hold = partialFunctionCallsPrefixLength(this.buffer);
if (!flush && hold > 0) {
out.push(this.buffer.slice(0, this.buffer.length - hold));
this.buffer = this.buffer.slice(this.buffer.length - hold);
break;
}
out.push(this.buffer);
this.buffer = '';
break;
}
out.push(this.buffer.slice(0, open));
const tail = this.buffer.slice(open);
const closeMatch = tail.match(/<\/function_calls>/i);
if (!closeMatch) {
if (flush || tail.length > NATIVE_XML_BODY_MAX) {
// Incomplete native XML is dropped (existing flush policy). The
// cap stops unbounded RSS if the close tag never arrives.
this.buffer = '';
} else {
this.buffer = tail;
}
break;
}
const end = open + closeMatch.index + closeMatch[0].length;
const block = this.buffer.slice(open, end);
const parsed = parseNativeFunctionCallsFromText(block, this.callerLookup);
out.push(parsed.text || '');
toolCalls.push(...(parsed.toolCalls || []));
this.buffer = this.buffer.slice(end);
}
return { text: out.join(''), toolCalls };
}
}
function partialFunctionCallsPrefixLength(text) {
const lit = '<function_calls';
const lower = String(text || '').toLowerCase();
const max = Math.min(lit.length - 1, lower.length);
for (let n = max; n > 0; n--) {
if (lower.endsWith(lit.slice(0, n))) return n;
}
return 0;
}
// ─── Inject caller's tool history as additional_steps ───────────────
//
// When the caller's prior turns include role:"tool" messages (responses to
// tool_calls the assistant made), we want the planner to see a trajectory
// that already contains those steps with their results. This lets the
// planner reason from the post-tool state instead of re-asking.
/**
* Translate the caller's assistant/tool turns into CortexTrajectoryStep
* buffers plus the message list the emulation path should keep.
*
* For each tool_call whose name maps onto a cascade kind we encode the call
* args AS IF the planner had emitted them, and overlay the tool result onto
* the step (e.g. view_file.content = result_string for Read). Unmapped
* calls (and calls whose forward translation is lossy) stay in
* `emulationMessages` so the existing <tool_result> emulation still carries
* them — the bridge must not swallow history it cannot encode.
*
* `reuse: true` means the caller is resuming a cascade that already holds
* every observation step from earlier turns. Only the newest pending tool
* round can be new, so earlier rounds are not re-injected (V-13 / KH-034).
*
* Returns { steps: Buffer[], deltaStart: number, emulationMessages: object[] }.
* `deltaStart` indexes the first step of the newest pending tool round, so a
* caller holding the full list can slice it for a reused cascade instead of
* encoding the history again. With `reuse: true`, `steps` is already that
* slice and `deltaStart` is 0.
*/
export function buildNativeToolHistory(messages, { reuse = false } = {}) {
if (!Array.isArray(messages) || !messages.length) {
return { steps: [], emulationMessages: [] };
}
const resultByCallId = new Map();
for (const m of messages) {
if (m?.role !== 'tool' || m.tool_call_id == null) continue;
resultByCallId.set(String(m.tool_call_id), m);
}
// A mapped call with a lossless forward translation belongs to the bridge
// for this request: it becomes a completed step when observed, and it is
// kept out of the text history either way. Everything else is emulation's.
const bridgeOwnedIds = new Set();
const candidates = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (m?.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
for (const tc of m.tool_calls) {
const entry = TOOL_MAP[tc?.function?.name];
if (!entry) continue;
const args = safeJsonParse(tc.function?.arguments);
let cascadeArgs;
try { cascadeArgs = entry.forward(args); } catch { continue; }
// v2.0.70 — apply_patch returns a sentinel from forward()
// because we can't lossless-encode multi-file patches into a
// single cascade step. Skip and let partition fallback to
// emulation toolPreamble.
if (cascadeArgs?.__apply_patch_unmappable) continue;
if (tc.id != null) bridgeOwnedIds.add(String(tc.id));
const resultMessage = tc.id != null ? resultByCallId.get(String(tc.id)) : null;
candidates.push({
messageIndex: i,
entry,
cascadeArgs,
resultMessage,
observation: resultMessage ? toolResultContent(resultMessage) : undefined,
});
}
}
const fromIndex = reuse ? trailingPendingRoundStart(messages) : 0;
// Index of the first step that belongs to the newest pending tool round.
// Callers that already hold the full list can slice it instead of encoding
// the history a second time when a cascade is reused.
let deltaStart = reuse ? 0 : -1;
const steps = [];
if (!reuse || fromIndex >= 0) {
const pendingStart = reuse ? fromIndex : trailingPendingRoundStart(messages);
for (const { messageIndex, entry, cascadeArgs, observation, resultMessage } of candidates) {
if (!reuse && deltaStart < 0 && pendingStart >= 0 && messageIndex >= pendingStart) {
deltaStart = steps.length;
}
if (messageIndex < fromIndex) continue;
// Require an observation before completion: an unanswered proposal is
// not a DONE step (V-13).
if (typeof observation !== 'string') continue;
// Overlay the result onto the cascade step's "this is what came
// back" field. Per-kind because the result field varies:
// view_file → content (4)
// run_command → combined_output (21) — the authoritative result field
// grep_search_v2 → raw_output (15)
// find → raw_output (11)
// list_directory → children (2, repeated)
// write_to_file → file_created (4) ← bool, ignore content
// search_web → summary (5)
// read_url_content → web_document.text (2.2)
// propose_code → no native result field (proxy passes back
// through emulation toolPreamble result text)
if (entry.kind === 'view_file') cascadeArgs.content = observation;
else if (entry.kind === 'run_command') {
// One authoritative output field: the planner reads combined_output;
// writing stdout as well duplicated every observation.
cascadeArgs.full_output = observation;
// Never fabricate success: a failed command keeps its exit status
// (and a signalled failure without a numeric code is non-zero).
const status = parseObservationExitStatus(observation, resultMessage);
if (status) cascadeArgs.exit_code = status.exitCode;
} else if (entry.kind === 'grep_search_v2' || entry.kind === 'grep_search') {
cascadeArgs.raw_output = observation;
} else if (entry.kind === 'find') {
cascadeArgs.raw_output = observation;
} else if (entry.kind === 'list_directory') {
cascadeArgs.children = observation
.split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean);
} else if (entry.kind === 'search_web') {
cascadeArgs.summary = observation;
} else if (entry.kind === 'read_url_content') {
cascadeArgs.content = observation;
}
const buf = buildAdditionalStep(entry.kind, cascadeArgs, { observed: true });
if (buf) steps.push(buf);
}
}
if (deltaStart < 0) deltaStart = steps.length;
return { steps, deltaStart, emulationMessages: buildEmulationHistory(messages, bridgeOwnedIds) };
}
/**
* Same step list as buildNativeToolHistory().steps, kept as the narrow API
* for callers that only need the protos.
*/
export function buildAdditionalStepsFromHistory(messages, options = {}) {
return buildNativeToolHistory(messages, options).steps;
}
function toolResultContent(m) {
if (typeof m?.content === 'string') return m.content;
if (Array.isArray(m?.content)) {
return m.content.filter(p => typeof p?.text === 'string').map(p => p.text).join('\n');
}
return JSON.stringify(m?.content ?? '');
}
// proto3 reads an absent exit_code as 0 (success). A result the caller
// flagged as failed therefore has to carry a non-zero code even when the
// text has no numeric status.
const EXIT_CODE_UNKNOWN_FAILURE = 1;
/**
* Read the exit status the caller reported for a shell result. The tool
* result is opaque client-owned text, so recognize the shapes the common
* dialects emit instead of assuming success: plain-text markers, codex-style
* JSON metadata, and Anthropic's is_error flag / `[tool error]` prefix.
*
* Returns { exitCode } or null when the observation carries no status.
*/
function parseObservationExitStatus(observation, message) {
const text = typeof observation === 'string' ? observation : '';
const signalledFailure = message?.is_error === true || /^\s*\[tool error\]/i.test(text);
const accept = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 0x7fffffff;
const json = safeJsonParse(text);
for (const value of [
json.exit_code, json.exitCode,
json.metadata?.exit_code, json.metadata?.exitCode,
]) {
if (accept(value)) return { exitCode: value };
}
const patterns = [
/\bexit\s+(?:code|status)\s*[:=]?\s*(\d{1,10})\b/i,
/\bexited\s+with\s+(?:code|status)\s*[:=]?\s*(\d{1,10})\b/i,
/\[(?:process\s+)?exit(?:ed)?\s+(?:code|status)?\s*[:=]?\s*(\d{1,10})\]/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match) continue;
const value = Number(match[1]);
if (accept(value)) return { exitCode: value };
}
return signalledFailure ? { exitCode: EXIT_CODE_UNKNOWN_FAILURE } : null;
}
/**
* Index of the last assistant tool_calls message when every message after it
* is a tool result — the only round a reused cascade can be missing. Returns
* -1 when the newest turn is not a pending tool round (or there is none).
*/
function trailingPendingRoundStart(messages) {
let start = -1;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
start = i;
break;
}
}
if (start < 0 || start === messages.length - 1) return -1;
for (let i = start + 1; i < messages.length; i++) {
if (messages[i]?.role !== 'tool') return -1;
}
return start;
}
/**
* Drop the pairs the bridge encoded (or owns but could not observe) from the
* message list the emulation path will fold into text. Unmapped calls,
* lossy translations and orphan tool results stay, so the planner keeps that
* history instead of losing it between the two paths.
*/
function buildEmulationHistory(messages, bridgeOwnedIds) {
const out = [];
for (const m of messages) {
if (m?.role === 'tool') {
if (m.tool_call_id != null && bridgeOwnedIds.has(String(m.tool_call_id))) continue;
out.push(m);
continue;
}
if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
const retained = m.tool_calls.filter(tc => !(tc?.id != null && bridgeOwnedIds.has(String(tc.id))));
if (retained.length === m.tool_calls.length) { out.push(m); continue; }
if (retained.length) { out.push({ ...m, tool_calls: retained }); continue; }
const { tool_calls: _consumed, ...rest } = m;
if (rest.content != null && rest.content !== '') out.push(rest);
continue;
}
out.push(m);
}
return out;
}
// ─── Activation gate ────────────────────────────────────────────────
/**
* Return true when the native bridge should be used for this request.
*
* v2.0.66 (#115 partition mode): the gate switched from "every tool must
* be mapped" (canMapAllTools) to "at least one tool is mapped"
* (partitionTools.hasAny). Real-world clients — codex CLI 0.128 declares
* 11 tools, only 1 (shell_command) maps cleanly — never pass the all-or-
* nothing bar, which is why v2.0.65 deployed but never fired in
* production. With partition mode, mapped tools route through
* trajectory-step injection while unmapped tools keep the emulation
* toolPreamble path; both coexist in the same request.
*
* v2.0.70 (#115 root-cause fix): GPT family REMOVED from auto-on. Real
* end-to-end probe on v2.0.69 with a single shell_command tool + GPT-5.5
* showed `markers=none / 0 tool_calls` and the model fabricated a fake
* timestamp output — cascade DEFAULT planner_mode describes
* `run_command` using cascade's internal trajectory grammar, which
* GPT's training has never seen.
*
* v2.0.75 (#124 zhqsuo critical regression). v2.0.70 ALSO turned the
* auto-on the OTHER way for Anthropic Claude — premise was "Claude
* speaks cascade-style tool_use natively so it'll execute well in the
* planner-mode path." Real-world behaviour was the opposite: when
* Claude Code (or any client whose tools[] models LOCAL filesystem ops
* — Read / Edit / Bash) hits this path, the cascade planner runs the
* tool inside Windsurf's REMOTE workspace sandbox
* (`/home/user/projects/workspace-devinxse`), not the user's machine.
* The user's files don't exist there, so `run_command` / `view_file`
* hang at lastStatus=2 (ACTIVE) until warm stall fires — every Claude
* tool call permanently stuck for 6+ minutes before erroring.
*
* The native bridge is only correct for clients whose tool inventory
* models REMOTE work (e.g. a self-contained agent that wants the
* proxy's sandbox to be the execution environment). Claude Code, Cline,
* Codex CLI, opencode all expect LOCAL execution. We can't tell from
* tools[] alone which intent the caller has, and the safe default
* therefore has to be OFF — opt in via env when the deployer knows the
* caller wants remote execution.
*
* Env knobs:
* WINDSURFAPI_NATIVE_TOOL_BRIDGE=1 → force on for any mapped subset
* WINDSURFAPI_NATIVE_TOOL_BRIDGE=all_mapped → on only if all tools map
* WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF=1 → force off (highest priority)
*/
function nativeBridgeModeFlags() {
const mode = String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE || '').trim().toLowerCase();
return {
mode,
explicitOn: mode === '1' || mode === 'true' || mode === 'force',
allMappedOnly: mode === 'all_mapped' || mode === 'all-mapped' || mode === 'mapped_only' || mode === 'mapped-only',
};
}
function toolNamesFromList(tools) {
return (Array.isArray(tools) ? tools : [])
.map(t => t?.function?.name || t?.name || '')
.filter(Boolean);
}
function nativeBridgeDecision({
enabled = false,
reason,
tools,
partition,
useCascade,
mode,
explicitOn,
allMappedOnly,
modelKey,
model,
provider,
route,
} = {}) {
const mappedTools = toolNamesFromList(partition?.mapped);
const unmappedTools = toolNamesFromList(partition?.unmapped);
return {
enabled: !!enabled,
reason: reason || (enabled ? 'native_bridge_enabled' : 'native_bridge_disabled'),
mode,
explicitOn: !!explicitOn,
allMappedOnly: !!allMappedOnly,
useCascade: !!useCascade,
modelKey: String(modelKey || ''),
requestedModel: String(model || ''),
provider: String(provider || ''),
route: String(route || ''),
hasTools: Array.isArray(tools) && tools.length > 0,
toolCount: Array.isArray(tools) ? tools.length : 0,
mappedCount: mappedTools.length,
unmappedCount: unmappedTools.length,
mappedTools,
unmappedTools,
partition,
};
}
export function getNativeBridgeDecision(tools, {
modelKey = '',
model = '',
provider = '',
route = '',
callerKey = '',
useCascade = true,
} = {}) {
const list = Array.isArray(tools) ? tools : [];
const partition = partitionTools(list);
const { mode, explicitOn, allMappedOnly } = nativeBridgeModeFlags();
const base = {
tools: list,
partition,
useCascade,
mode,
explicitOn,
allMappedOnly,
modelKey,
model,
provider,
route,
};
if (process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF === '1') {
return nativeBridgeDecision({ ...base, reason: 'native_bridge_off' });
}
if (!useCascade) {
return nativeBridgeDecision({ ...base, reason: 'native_bridge_non_cascade' });
}
if (!list.length) {
return nativeBridgeDecision({ ...base, reason: 'native_bridge_no_tools' });
}
if (!explicitOn && !allMappedOnly) {
return nativeBridgeDecision({ ...base, reason: 'native_bridge_mode_not_enabled' });
}
const grayGate = nativeBridgeGrayGateStatus({ modelKey, model, provider, route, callerKey });
if (!grayGate.ok) {
return nativeBridgeDecision({ ...base, reason: grayGate.errorType || 'native_bridge_gray_gate_denied' });
}
if (!partition.hasAny) {
return nativeBridgeDecision({ ...base, reason: 'native_bridge_no_mapped_tools' });
}
if (allMappedOnly && partition.unmapped.length) {
return nativeBridgeDecision({ ...base, reason: 'native_bridge_all_mapped_required' });
}
return nativeBridgeDecision({ ...base, enabled: true, reason: 'native_bridge_enabled' });
}
export function shouldUseNativeBridge(tools, { modelKey = '', model = '', provider = '', route = '', callerKey = '' } = {}) {
return getNativeBridgeDecision(tools, {
modelKey,
model,
provider,
route,
callerKey,
useCascade: true,
}).enabled;
}