/** * 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:"", 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: * * * * README.md * * * 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' || !/]*>([\s\S]*?)<\/function_calls>/gi; const invokeRe = /]*)>([\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 = /]*)>([\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(/ blocks in native * bridge mode. It withholds any partial block from content deltas until a * closing 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 = / 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 = ' 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 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; }