@bitkyc08/opencodex 2.7.39 → 2.7.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/gui/dist/assets/index-CMip1DzF.css +1 -0
- package/gui/dist/assets/index-cydcmbzC.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/cursor/arg-normalize.ts +23 -7
- package/src/adapters/cursor/live-transport.ts +26 -14
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +1 -1
- package/src/adapters/cursor/protobuf-events.ts +72 -13
- package/src/adapters/cursor/protobuf-request.ts +82 -11
- package/src/adapters/cursor/request-builder.ts +35 -11
- package/src/adapters/cursor/tool-definitions.ts +175 -30
- package/src/adapters/openai-chat.ts +28 -7
- package/src/adapters/openai-responses.ts +150 -4
- package/src/bridge.ts +20 -1
- package/src/claude/outbound.ts +91 -6
- package/src/codex/auth-api.ts +12 -25
- package/src/codex/auth-context.ts +48 -3
- package/src/codex/catalog/provider-fetch.ts +56 -24
- package/src/codex/model-cache.ts +23 -0
- package/src/codex/quota.ts +120 -0
- package/src/codex/routing.ts +178 -9
- package/src/config.ts +56 -1
- package/src/providers/openai-sidecar.ts +8 -1
- package/src/providers/openai-tiers.ts +18 -0
- package/src/server/adapter-resolve.ts +24 -10
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/claude-messages.ts +4 -0
- package/src/server/index.ts +3 -1
- package/src/server/live.ts +56 -0
- package/src/server/memory-watchdog.ts +1 -1
- package/src/server/responses/compact.ts +40 -10
- package/src/server/responses/core.ts +180 -26
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/service.ts +113 -30
- package/src/types.ts +52 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/anthropic-executor.ts +3 -1
- package/src/web-search/index.ts +7 -1
- package/src/web-search/loop.ts +17 -3
- package/README.ja.md +0 -445
- package/README.ko.md +0 -435
- package/README.ru.md +0 -486
- package/README.zh-CN.md +0 -411
- package/gui/dist/assets/index-B-cheu55.js +0 -52
- package/gui/dist/assets/index-oOZcqVmj.css +0 -1
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
import { create, fromJson, toBinary, type JsonValue } from "@bufbuild/protobuf";
|
|
2
2
|
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
3
3
|
import type { OcxRequestOptions, OcxTool } from "../../types";
|
|
4
|
-
import { namespacedToolName } from "../../types";
|
|
4
|
+
import { namespacedToolName, toolChoiceAliases } from "../../types";
|
|
5
5
|
import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from "./gen/agent_pb";
|
|
6
6
|
|
|
7
7
|
export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses";
|
|
8
8
|
export const CODEX_EXEC_COMMAND_TOOL = "exec_command";
|
|
9
|
+
export const CODEX_SHELL_COMMAND_TOOL = "shell_command";
|
|
9
10
|
export const CODEX_APPLY_PATCH_TOOL = "apply_patch";
|
|
10
11
|
export const CURSOR_EXEC_COMMAND_TOOL = CODEX_EXEC_COMMAND_TOOL;
|
|
11
|
-
export const
|
|
12
|
-
export const
|
|
12
|
+
export const CODEX_SHELL_BRIDGE_TOOL_NAMES = [CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL] as const;
|
|
13
|
+
export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE =
|
|
14
|
+
'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell; never say native shell is blocked.';
|
|
15
|
+
export const CURSOR_SHELL_ALIAS_USER_HINT =
|
|
16
|
+
"Use the Codex shell bridge tool listed this turn (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` name). Prefer that bridge over Cursor-native Shell/Read. Never say phrases like \"Native shell access is blocked\" or \"Shell access is blocked\" — silently use the bridge unless that bridge tool itself fails.";
|
|
13
17
|
const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const;
|
|
14
18
|
|
|
15
19
|
export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [
|
|
16
|
-
"For generic tool-use/count demos, satisfy the request with repeated `exec_command`
|
|
17
|
-
"`exec_command`
|
|
20
|
+
"For generic tool-use/count demos, satisfy the request with repeated Codex shell bridge calls (`shell_command` or `exec_command`) for harmless commands.",
|
|
21
|
+
"`shell_command` / `exec_command` are the Codex Responses shell bridge exposed through Cursor's tool protocol; do not describe them as an external MCP server tool.",
|
|
18
22
|
"Do not use `run_shell` unless this turn's tool catalog lists it.",
|
|
19
|
-
"A request for N tools means N separate
|
|
20
|
-
"For independent read-only or output-only commands, emit all requested
|
|
23
|
+
"A request for N tools means N separate shell-bridge invocations/results; never satisfy it with one chained shell command such as `cmd1 && cmd2`.",
|
|
24
|
+
"For independent read-only or output-only commands, emit all requested shell-bridge calls in the same response before waiting when the runtime supports parallel tool calls.",
|
|
21
25
|
"The Cursor bridge may suspend after the first returned bridge tool call, so emit sibling calls together before any result is needed.",
|
|
22
|
-
"If parallel emission is unavailable, continue with separate
|
|
26
|
+
"If parallel emission is unavailable, continue with separate shell-bridge calls until the requested count has returned.",
|
|
23
27
|
"Do not use `tool_search`, external MCP, or resource discovery just to pad the count unless explicitly asked.",
|
|
24
28
|
"Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, or `LS` unless this turn's catalog lists those exact names.",
|
|
29
|
+
"Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Do not narrate Cursor-native Shell/Read routing.",
|
|
25
30
|
].join(" ");
|
|
26
31
|
|
|
27
32
|
export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = {
|
|
@@ -38,8 +43,91 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = {
|
|
|
38
43
|
additionalProperties: false,
|
|
39
44
|
} as const;
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Responses/Codex-side schema used ONLY for arg-key normalization after Cursor returns a call.
|
|
48
|
+
* Cursor models are trained to emit `cmd`; Codex `shell_command` / `exec_command` validate
|
|
49
|
+
* `command`. Keeping `cmd` out of this schema lets `normalizeArgKeys` rewrite `cmd` → `command`.
|
|
50
|
+
*/
|
|
51
|
+
export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
command: { type: "string", description: "Shell command to execute." },
|
|
55
|
+
workdir: { type: "string", description: "Working directory for the command. Defaults to the turn cwd." },
|
|
56
|
+
shell: { type: "string", description: "Shell binary to launch. Defaults to the user's default shell." },
|
|
57
|
+
tty: { type: "boolean", description: "True allocates a PTY for the command; false or omitted uses plain pipes." },
|
|
58
|
+
yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." },
|
|
59
|
+
max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." },
|
|
60
|
+
max_output_chars: { type: "number", description: "Output character budget when the Responses tool uses chars instead of tokens." },
|
|
61
|
+
},
|
|
62
|
+
required: ["command"],
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export function isCodexShellBridgeToolName(name: string): boolean {
|
|
66
|
+
return (CODEX_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Direct key lookup, then shell_command/exec_command sibling aliases when the key is a bridge name.
|
|
71
|
+
* Used for catalog admission, schema normalize maps, and Responses name maps (#399).
|
|
72
|
+
*/
|
|
73
|
+
export function resolveShellBridgeAliasKey<T>(
|
|
74
|
+
key: string,
|
|
75
|
+
lookup: (name: string) => T | undefined,
|
|
76
|
+
): T | undefined {
|
|
77
|
+
const direct = lookup(key);
|
|
78
|
+
if (direct !== undefined) return direct;
|
|
79
|
+
if (!isCodexShellBridgeToolName(key)) return undefined;
|
|
80
|
+
for (const alias of CODEX_SHELL_BRIDGE_TOOL_NAMES) {
|
|
81
|
+
if (alias === key) continue;
|
|
82
|
+
const hit = lookup(alias);
|
|
83
|
+
if (hit !== undefined) return hit;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cursorToolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): string[] {
|
|
89
|
+
const aliases = new Set(toolChoiceAliases(tool));
|
|
90
|
+
if (isBareCodexShellBridgeTool(tool)) {
|
|
91
|
+
for (const alias of CODEX_SHELL_BRIDGE_TOOL_NAMES) aliases.add(alias);
|
|
92
|
+
}
|
|
93
|
+
return [...aliases];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function catalogHasBareCodexShellBridge(
|
|
97
|
+
catalog: readonly Pick<OcxTool, "namespace" | "name">[],
|
|
98
|
+
): boolean {
|
|
99
|
+
return catalog.some(isBareCodexShellBridgeTool);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Catalog-aware tool_choice matching for Cursor.
|
|
104
|
+
* When a bare Codex shell bridge is in the catalog, raw `shell_command` / `exec_command`
|
|
105
|
+
* choices select only that bridge (never a namespaced remote with the same raw name).
|
|
106
|
+
* When no bare bridge exists, raw bridge names may select a namespaced tool by raw name.
|
|
107
|
+
* Explicit wire names (`mcp__remote__exec_command`) always match the namespaced tool.
|
|
108
|
+
*/
|
|
109
|
+
function cursorToolChoiceMatches(
|
|
110
|
+
tool: Pick<OcxTool, "namespace" | "name">,
|
|
111
|
+
choiceName: string,
|
|
112
|
+
catalog: readonly Pick<OcxTool, "namespace" | "name">[],
|
|
113
|
+
): boolean {
|
|
114
|
+
if (isCodexShellBridgeToolName(choiceName)) {
|
|
115
|
+
if (catalogHasBareCodexShellBridge(catalog)) {
|
|
116
|
+
return isBareCodexShellBridgeTool(tool);
|
|
117
|
+
}
|
|
118
|
+
return tool.name === choiceName || cursorToolWireName(tool) === choiceName;
|
|
119
|
+
}
|
|
120
|
+
if (tool.name === choiceName || cursorToolWireName(tool) === choiceName) return true;
|
|
121
|
+
return cursorToolChoiceAliases(tool).includes(choiceName);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isBareCodexShellBridgeTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
|
|
125
|
+
return !tool.namespace && isCodexShellBridgeToolName(tool.name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @deprecated Prefer isBareCodexShellBridgeTool; kept for older call sites/tests. */
|
|
41
129
|
function isBareCodexExecCommandTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
|
|
42
|
-
return
|
|
130
|
+
return isBareCodexShellBridgeTool(tool);
|
|
43
131
|
}
|
|
44
132
|
|
|
45
133
|
export function cursorRequestHasShellAlias(tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined): boolean {
|
|
@@ -50,7 +138,8 @@ export function cursorRequestAdvertisesApplyPatch(
|
|
|
50
138
|
tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
|
|
51
139
|
toolChoice?: OcxRequestOptions["toolChoice"],
|
|
52
140
|
): boolean {
|
|
53
|
-
|
|
141
|
+
const catalog = tools ?? [];
|
|
142
|
+
return catalog.some(tool => !tool.namespace && tool.name === CODEX_APPLY_PATCH_TOOL && tool.freeform === true && cursorToolAllowedByChoice(tool, toolChoice, catalog));
|
|
54
143
|
}
|
|
55
144
|
|
|
56
145
|
export function cursorToolWireName(tool: Pick<OcxTool, "namespace" | "name">): string {
|
|
@@ -60,8 +149,9 @@ export function cursorToolWireName(tool: Pick<OcxTool, "namespace" | "name">): s
|
|
|
60
149
|
/**
|
|
61
150
|
* Cursor's harness shows MCP tools to the model as `mcp_<providerIdentifier>_<toolName>`; models
|
|
62
151
|
* sometimes call that display name verbatim instead of the advertised short name (live 20:41/21:00
|
|
63
|
-
* sessions: `mcp_opencodex-responses_exec_command`).
|
|
64
|
-
*
|
|
152
|
+
* sessions: `mcp_opencodex-responses_exec_command` / `mcp_opencodex-responses_shell_command`).
|
|
153
|
+
* Fold the display prefix back to the advertised wire name, and treat `shell_command` /
|
|
154
|
+
* `exec_command` as the same Codex shell bridge, so alias thrash does not become "tool not found".
|
|
65
155
|
*/
|
|
66
156
|
const CURSOR_MCP_DISPLAY_PREFIX = `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_`;
|
|
67
157
|
|
|
@@ -71,15 +161,60 @@ export function normalizeCursorWireName(name: string): string {
|
|
|
71
161
|
|
|
72
162
|
export function responsesToolNameFromCursorWire(name: string, cursorToolNameMap?: ReadonlyMap<string, string>): string {
|
|
73
163
|
const normalized = normalizeCursorWireName(name);
|
|
74
|
-
|
|
164
|
+
if (!cursorToolNameMap) return normalized;
|
|
165
|
+
return resolveShellBridgeAliasKey(normalized, alias => cursorToolNameMap.get(alias)) ?? normalized;
|
|
75
166
|
}
|
|
76
167
|
|
|
168
|
+
/** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */
|
|
77
169
|
export function cursorToolInputSchema(tool: OcxTool): unknown {
|
|
78
170
|
return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {});
|
|
79
171
|
}
|
|
80
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Schema used to normalize completed Cursor tool args back to Responses/Codex field names.
|
|
175
|
+
* Must NOT reuse `cursorToolInputSchema` for the shell bridge: advertising `cmd` while also
|
|
176
|
+
* treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399).
|
|
177
|
+
*/
|
|
178
|
+
export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown {
|
|
179
|
+
if (isBareCodexShellBridgeTool(tool)) {
|
|
180
|
+
return shellBridgeArgNormalizeSchema(tool);
|
|
181
|
+
}
|
|
182
|
+
return tool.parameters ?? {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function shellBridgeArgNormalizeSchema(tool: OcxTool): unknown {
|
|
186
|
+
const parameters = tool.parameters;
|
|
187
|
+
if (!parameters || typeof parameters !== "object") return CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA;
|
|
188
|
+
const base = parameters as Record<string, unknown>;
|
|
189
|
+
const rawProps = base.properties && typeof base.properties === "object"
|
|
190
|
+
? { ...(base.properties as Record<string, unknown>) }
|
|
191
|
+
: {};
|
|
192
|
+
const required = Array.isArray(base.required) ? [...base.required as unknown[]] : [];
|
|
193
|
+
const requiresCommand = required.includes("command") || "command" in rawProps;
|
|
194
|
+
const requiresCmd = required.includes("cmd") || "cmd" in rawProps;
|
|
195
|
+
const shouldRewriteCmdToCommand = tool.name === CODEX_SHELL_COMMAND_TOOL || requiresCommand;
|
|
196
|
+
|
|
197
|
+
if (!shouldRewriteCmdToCommand && requiresCmd) {
|
|
198
|
+
return parameters;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Drop Cursor-preferred aliases so normalizeArgKeys can rewrite them to Responses keys.
|
|
202
|
+
delete rawProps.cmd;
|
|
203
|
+
const properties = {
|
|
204
|
+
...CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties,
|
|
205
|
+
...rawProps,
|
|
206
|
+
command: rawProps.command ?? CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties.command,
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
...base,
|
|
210
|
+
type: "object",
|
|
211
|
+
properties,
|
|
212
|
+
required: requiresCommand ? required : ["command"],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
81
216
|
function activeTextMentionsExecCommand(text: string): boolean {
|
|
82
|
-
return /\
|
|
217
|
+
return /\b(?:exec_command|shell_command)\b/i.test(text);
|
|
83
218
|
}
|
|
84
219
|
|
|
85
220
|
function looksLikeShellCommandRequest(text: string): boolean {
|
|
@@ -127,9 +262,9 @@ function cursorGenericToolUseHint(text: string): string {
|
|
|
127
262
|
const count = requestedCursorToolUseCount(text);
|
|
128
263
|
if (!count) return CURSOR_GENERIC_TOOL_USE_USER_HINT;
|
|
129
264
|
return [
|
|
130
|
-
`This turn requests ${count} tool uses: emit exactly ${count} separate
|
|
131
|
-
`One
|
|
132
|
-
`Prefer one parallel tool-call batch containing all ${count} independent
|
|
265
|
+
`This turn requests ${count} tool uses: emit exactly ${count} separate Codex shell bridge function calls/results (\`shell_command\` or \`exec_command\`).`,
|
|
266
|
+
`One shell-bridge call containing chained commands counts as 1 tool call, not ${count}.`,
|
|
267
|
+
`Prefer one parallel tool-call batch containing all ${count} independent shell-bridge calls before waiting for results.`,
|
|
133
268
|
CURSOR_GENERIC_TOOL_USE_USER_HINT,
|
|
134
269
|
].join(" ");
|
|
135
270
|
}
|
|
@@ -176,7 +311,8 @@ export function cursorToolsForActivePrompt<T extends Pick<OcxTool, "namespace" |
|
|
|
176
311
|
): readonly T[] | undefined {
|
|
177
312
|
if (!shouldUseNativeExecOnlyForGenericToolUse(tools, activeText)) return tools;
|
|
178
313
|
const execTools = tools?.filter(isBareCodexExecCommandTool);
|
|
179
|
-
|
|
314
|
+
const catalog = tools ?? [];
|
|
315
|
+
if (execTools?.length && !execTools.some(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog))) return tools;
|
|
180
316
|
return execTools && execTools.length > 0 ? execTools : tools;
|
|
181
317
|
}
|
|
182
318
|
|
|
@@ -199,13 +335,17 @@ export function appendCursorShellAliasHint(
|
|
|
199
335
|
return `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${CURSOR_SHELL_ALIAS_USER_HINT}`;
|
|
200
336
|
}
|
|
201
337
|
|
|
202
|
-
export function cursorToolAllowedByChoice(
|
|
338
|
+
export function cursorToolAllowedByChoice(
|
|
339
|
+
tool: Pick<OcxTool, "namespace" | "name">,
|
|
340
|
+
toolChoice: OcxRequestOptions["toolChoice"] | undefined,
|
|
341
|
+
catalog: readonly Pick<OcxTool, "namespace" | "name">[] = [tool],
|
|
342
|
+
): boolean {
|
|
203
343
|
if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true;
|
|
204
344
|
if (toolChoice === "none") return false;
|
|
205
345
|
if ("allowedTools" in toolChoice) {
|
|
206
|
-
return toolChoice.allowedTools.
|
|
346
|
+
return toolChoice.allowedTools.some(choiceName => cursorToolChoiceMatches(tool, choiceName, catalog));
|
|
207
347
|
}
|
|
208
|
-
return tool
|
|
348
|
+
return cursorToolChoiceMatches(tool, toolChoice.name, catalog);
|
|
209
349
|
}
|
|
210
350
|
|
|
211
351
|
function quotedNames(names: readonly string[]): string {
|
|
@@ -232,13 +372,15 @@ export function buildCursorToolGuidanceSystemNote(
|
|
|
232
372
|
if (!tools?.length) return undefined;
|
|
233
373
|
const wireNames = [...new Set(
|
|
234
374
|
tools
|
|
235
|
-
.filter(tool => cursorToolAllowedByChoice(tool, toolChoice))
|
|
375
|
+
.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools))
|
|
236
376
|
.map(tool => cursorToolWireName(tool)),
|
|
237
377
|
)];
|
|
238
378
|
if (wireNames.length === 0) return undefined;
|
|
239
379
|
|
|
240
380
|
const listedNames = quotedNames(wireNames);
|
|
241
|
-
const
|
|
381
|
+
const shellBridgeNames = wireNames.filter(isCodexShellBridgeToolName);
|
|
382
|
+
const hasBareExec = shellBridgeNames.length > 0;
|
|
383
|
+
const shellBridgeLabel = quotedNames(shellBridgeNames.length > 0 ? shellBridgeNames : [...CODEX_SHELL_BRIDGE_TOOL_NAMES]);
|
|
242
384
|
const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice);
|
|
243
385
|
const discoveryTools = discoveryToolLabel(wireNames);
|
|
244
386
|
const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames);
|
|
@@ -249,31 +391,34 @@ export function buildCursorToolGuidanceSystemNote(
|
|
|
249
391
|
? `This turn does not expose neighboring-agent tool names ${quotedNames(unavailableNeighborNames)}; do not call or suggest them unless the catalog lists them.`
|
|
250
392
|
: undefined,
|
|
251
393
|
hasBareExec
|
|
252
|
-
?
|
|
394
|
+
? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.`
|
|
395
|
+
: undefined,
|
|
396
|
+
hasBareExec
|
|
397
|
+
? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user."
|
|
253
398
|
: undefined,
|
|
254
399
|
hasBareExec
|
|
255
|
-
? "
|
|
400
|
+
? "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Prefer the bridge over Cursor-native Shell/Read; do not narrate phrases like \"Native shell access is blocked\" — silently call `shell_command` / `exec_command`."
|
|
256
401
|
: undefined,
|
|
257
402
|
"Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise.",
|
|
258
403
|
hasBareExec
|
|
259
|
-
?
|
|
404
|
+
? `For file read/search/listing, use ${shellBridgeLabel} when no more specific listed tool is available.`
|
|
260
405
|
: undefined,
|
|
261
406
|
hasApplyPatch
|
|
262
407
|
? "For file edits, use the `apply_patch` tool, not built-in file write/delete tools."
|
|
263
408
|
: undefined,
|
|
264
409
|
hasBareExec
|
|
265
|
-
? "For tool-count demos, each counted tool must be a separate
|
|
410
|
+
? "For tool-count demos, each counted tool must be a separate Codex shell-bridge invocation/result; do not collapse several requested tools into one chained shell command."
|
|
266
411
|
: undefined,
|
|
267
412
|
"For independent read-only tool-count or batch requests, prefer one response containing multiple tool calls before waiting for results when the runtime supports parallel tool calls.",
|
|
268
413
|
hasBareExec
|
|
269
|
-
? "For bridge tool-count batches, emit sibling
|
|
414
|
+
? "For bridge tool-count batches, emit sibling shell-bridge calls together before any result is needed because the bridge may suspend after a returned tool call."
|
|
270
415
|
: undefined,
|
|
271
416
|
discoveryTools
|
|
272
417
|
? `Use ${discoveryTools} only for explicit discovery/resource tasks, not generic tool-count demos.`
|
|
273
418
|
: undefined,
|
|
274
419
|
"Do not count or report a tool call unless a tool result was actually returned.",
|
|
275
420
|
hasBareExec
|
|
276
|
-
?
|
|
421
|
+
? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
|
|
277
422
|
: undefined,
|
|
278
423
|
].filter((note): note is string => typeof note === "string");
|
|
279
424
|
return notes.join(" ");
|
|
@@ -291,7 +436,7 @@ export function buildCursorToolDefinitions(
|
|
|
291
436
|
toolChoice?: OcxRequestOptions["toolChoice"],
|
|
292
437
|
): McpToolDefinition[] {
|
|
293
438
|
if (!tools?.length) return [];
|
|
294
|
-
return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice)).map(tool => {
|
|
439
|
+
return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)).map(tool => {
|
|
295
440
|
const wireName = cursorToolWireName(tool);
|
|
296
441
|
return create(McpToolDefinitionSchema, {
|
|
297
442
|
name: wireName,
|
|
@@ -61,6 +61,13 @@ function extractErrorDetail(parsed: unknown): string | undefined {
|
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function developerSystemText(message: OcxMessage): string | undefined {
|
|
65
|
+
if (message.role !== "developer") return undefined;
|
|
66
|
+
if (typeof message.content === "string") return message.content;
|
|
67
|
+
if (message.content.some(part => part.type === "image")) return undefined;
|
|
68
|
+
return message.content.map(part => (part as OcxTextContent).text).join("");
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
65
72
|
const out: unknown[] = [];
|
|
66
73
|
const { context, options } = parsed;
|
|
@@ -112,7 +119,20 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
112
119
|
const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
|
|
113
120
|
? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
|
|
114
121
|
: undefined;
|
|
115
|
-
|
|
122
|
+
// Chat templates used by LM Studio, llama.cpp, and other strict OpenAI-compatible
|
|
123
|
+
// backends require every system instruction to precede conversation history. Codex can
|
|
124
|
+
// append developer reminders after user turns, so fold text-only developer messages into
|
|
125
|
+
// the single leading system message instead of emitting role:"system" in place. Developer
|
|
126
|
+
// messages with images cannot be represented as system content and remain user-compatible
|
|
127
|
+
// vision messages at their original position below.
|
|
128
|
+
const developerSystemParts = context.messages
|
|
129
|
+
.map(developerSystemText)
|
|
130
|
+
.filter((part): part is string => part !== undefined && part.length > 0);
|
|
131
|
+
const systemParts = [
|
|
132
|
+
...(context.systemPrompt ?? []),
|
|
133
|
+
...developerSystemParts,
|
|
134
|
+
...(toolCatalogNudge ? [toolCatalogNudge] : []),
|
|
135
|
+
];
|
|
116
136
|
if (systemParts.length > 0) {
|
|
117
137
|
// Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
|
|
118
138
|
// base_instructions is ignored at request time). Neutralize that one identity line
|
|
@@ -126,18 +146,19 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
126
146
|
switch (msg.role) {
|
|
127
147
|
case "user":
|
|
128
148
|
case "developer": {
|
|
129
|
-
const
|
|
149
|
+
const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
|
|
150
|
+
const hasImages = parts?.some(p => p.type === "image") ?? false;
|
|
151
|
+
if (msg.role === "developer" && !hasImages) break;
|
|
130
152
|
let chatMsg: Record<string, unknown>;
|
|
131
153
|
if (typeof msg.content === "string") {
|
|
132
|
-
chatMsg = { role, content: msg.content };
|
|
154
|
+
chatMsg = { role: "user", content: msg.content };
|
|
133
155
|
} else {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
chatMsg = { role, content: parts.map(p => (p as OcxTextContent).text).join("") };
|
|
156
|
+
if (!hasImages) {
|
|
157
|
+
chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
|
|
137
158
|
} else {
|
|
138
159
|
// Vision: chat-completions content-parts array. Images are only valid on the user role,
|
|
139
160
|
// and the data URL goes straight into image_url.url (never the token-exploding text path).
|
|
140
|
-
const chatParts = parts
|
|
161
|
+
const chatParts = parts!.map(p => p.type === "image"
|
|
141
162
|
? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
|
|
142
163
|
: { type: "text", text: (p as OcxTextContent).text });
|
|
143
164
|
chatMsg = { role: "user", content: chatParts };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { IncomingMeta, ProviderAdapter } from "./base";
|
|
3
|
-
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
3
|
+
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
|
|
4
4
|
import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
|
|
5
|
-
import { decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
|
|
5
|
+
import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
|
|
6
|
+
import { decodeServerSentEvents } from "../lib/sse-decoder";
|
|
7
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
6
8
|
import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
|
|
7
9
|
import { modelRecordValue } from "../reasoning-effort";
|
|
8
10
|
|
|
@@ -518,6 +520,81 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
|
|
|
518
520
|
return tools.length === body.tools.length ? body : { ...body, tools };
|
|
519
521
|
}
|
|
520
522
|
|
|
523
|
+
/** Replace every `input_image` part under a routed-compaction body with a short marker. */
|
|
524
|
+
function stripInputImagesDeep(value: unknown): unknown {
|
|
525
|
+
if (Array.isArray(value)) return value.map(stripInputImagesDeep);
|
|
526
|
+
if (!isPlainObject(value)) return value;
|
|
527
|
+
if (value.type === "input_image") {
|
|
528
|
+
return { type: "input_text", text: "[image omitted for compaction]" };
|
|
529
|
+
}
|
|
530
|
+
const out: Record<string, unknown> = {};
|
|
531
|
+
for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry);
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Rewrite a compaction turn for an upstream that does not speak Codex's private
|
|
537
|
+
* `compaction_trigger` item: drop the trigger and the whole tool surface, and ask
|
|
538
|
+
* for the handoff summary in plain terms instead (#422).
|
|
539
|
+
*
|
|
540
|
+
* The adapter builds from `parsed._rawBody`, so the summarizer prompt that
|
|
541
|
+
* handleResponses() pushed onto `parsed.context` never reaches the wire — it has to
|
|
542
|
+
* be applied here. Images go too: a summary needs no pixels, and a text-only
|
|
543
|
+
* gateway would reject them.
|
|
544
|
+
*/
|
|
545
|
+
function buildRoutedCompactionBody(body: unknown): unknown {
|
|
546
|
+
if (!isPlainObject(body)) return body;
|
|
547
|
+
const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body;
|
|
548
|
+
const input = Array.isArray(body.input) ? body.input : [];
|
|
549
|
+
const kept = input.filter(item => !isPlainObject(item)
|
|
550
|
+
// `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
|
|
551
|
+
// leaving it in would break the no-tools invariant even with `tools` removed.
|
|
552
|
+
|| (item.type !== "compaction_trigger" && item.type !== "additional_tools"));
|
|
553
|
+
return {
|
|
554
|
+
...rest,
|
|
555
|
+
input: [
|
|
556
|
+
...(stripInputImagesDeep(kept) as unknown[]),
|
|
557
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] },
|
|
558
|
+
],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Read the Responses `usage` block, if the gateway sent one. */
|
|
563
|
+
function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
|
|
564
|
+
if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
|
|
565
|
+
const usage = payload.usage;
|
|
566
|
+
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
567
|
+
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
568
|
+
if (inputTokens === 0 && outputTokens === 0) return undefined;
|
|
569
|
+
return {
|
|
570
|
+
inputTokens,
|
|
571
|
+
outputTokens,
|
|
572
|
+
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function responsesPayloadText(response: unknown): string {
|
|
577
|
+
if (!isPlainObject(response) || !Array.isArray(response.output)) return "";
|
|
578
|
+
return response.output
|
|
579
|
+
.filter(item => isPlainObject(item) && item.type === "message")
|
|
580
|
+
.flatMap(item => (Array.isArray((item as Record<string, unknown>).content)
|
|
581
|
+
? (item as { content: unknown[] }).content
|
|
582
|
+
: []))
|
|
583
|
+
.filter(part => isPlainObject(part) && part.type === "output_text")
|
|
584
|
+
.map(part => String((part as { text?: unknown }).text ?? ""))
|
|
585
|
+
.join("");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function responsesErrorMessage(payload: unknown): string {
|
|
589
|
+
if (!isPlainObject(payload)) return "upstream compaction failed";
|
|
590
|
+
const err = payload.error;
|
|
591
|
+
if (typeof err === "string") return err;
|
|
592
|
+
if (isPlainObject(err) && typeof err.message === "string") return err.message;
|
|
593
|
+
const incomplete = payload.incomplete_details;
|
|
594
|
+
if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason;
|
|
595
|
+
return "upstream compaction failed";
|
|
596
|
+
}
|
|
597
|
+
|
|
521
598
|
export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
|
|
522
599
|
return {
|
|
523
600
|
name: "openai-responses",
|
|
@@ -573,6 +650,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
573
650
|
outBody = repairOversizedReplayCallIds(outBody);
|
|
574
651
|
}
|
|
575
652
|
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
|
|
653
|
+
// Same predicate as the routedCompaction gate in handleResponses(): an
|
|
654
|
+
// authMode check would let a noncanonical custom forward provider skip this
|
|
655
|
+
// rewrite while the server still routes it as a summarizer turn (#422).
|
|
656
|
+
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
|
|
657
|
+
outBody = buildRoutedCompactionBody(outBody);
|
|
658
|
+
}
|
|
576
659
|
return {
|
|
577
660
|
url,
|
|
578
661
|
method: "POST",
|
|
@@ -585,8 +668,71 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
585
668
|
};
|
|
586
669
|
},
|
|
587
670
|
|
|
588
|
-
|
|
589
|
-
|
|
671
|
+
// The passthrough normally relays the upstream stream verbatim and never parses.
|
|
672
|
+
// The exception is a routed compaction turn: the server drives this adapter like
|
|
673
|
+
// an ordinary one so the bridge can build the single compaction item (#422).
|
|
674
|
+
async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
|
|
675
|
+
if (!response.body) {
|
|
676
|
+
yield { type: "error", message: "passthrough adapter received no response body" };
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
let deltas = "";
|
|
680
|
+
let doneText = "";
|
|
681
|
+
let snapshot = "";
|
|
682
|
+
let usage: OcxUsage | undefined;
|
|
683
|
+
for await (const event of decodeServerSentEvents(response.body)) {
|
|
684
|
+
let payload: unknown;
|
|
685
|
+
try { payload = JSON.parse(event.data); } catch { continue; }
|
|
686
|
+
if (!isPlainObject(payload)) continue;
|
|
687
|
+
switch (payload.type) {
|
|
688
|
+
case "response.output_text.delta":
|
|
689
|
+
if (typeof payload.delta === "string") deltas += payload.delta;
|
|
690
|
+
break;
|
|
691
|
+
case "response.output_text.done":
|
|
692
|
+
if (typeof payload.text === "string") doneText += payload.text;
|
|
693
|
+
break;
|
|
694
|
+
case "response.failed":
|
|
695
|
+
case "error":
|
|
696
|
+
yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) };
|
|
697
|
+
return;
|
|
698
|
+
case "response.incomplete":
|
|
699
|
+
yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
|
|
700
|
+
return;
|
|
701
|
+
case "response.completed":
|
|
702
|
+
snapshot = responsesPayloadText(payload.response);
|
|
703
|
+
usage = usageFromResponsesPayload(payload.response);
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Gateways differ in which of these they emit; prefer the authoritative
|
|
708
|
+
// completed snapshot so text is never double-counted.
|
|
709
|
+
const text = snapshot || doneText || deltas;
|
|
710
|
+
if (text) yield { type: "text_delta", text };
|
|
711
|
+
yield { type: "done", ...(usage ? { usage } : {}) };
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
async parseResponse(response: Response): Promise<AdapterEvent[]> {
|
|
715
|
+
let payload: unknown;
|
|
716
|
+
try { payload = await response.json(); } catch {
|
|
717
|
+
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
718
|
+
}
|
|
719
|
+
if (!isPlainObject(payload)) {
|
|
720
|
+
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
721
|
+
}
|
|
722
|
+
if (payload.error || payload.status === "failed") {
|
|
723
|
+
return [{ type: "error", message: responsesErrorMessage(payload) }];
|
|
724
|
+
}
|
|
725
|
+
if (payload.status === "incomplete") {
|
|
726
|
+
return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
|
|
727
|
+
}
|
|
728
|
+
const text = responsesPayloadText(payload);
|
|
729
|
+
if (!text) {
|
|
730
|
+
// A completed turn with no usable text cannot become a summary; saying so is
|
|
731
|
+
// better than installing an empty compaction as replacement history.
|
|
732
|
+
return [{ type: "error", message: "upstream compaction returned no summary text" }];
|
|
733
|
+
}
|
|
734
|
+
const usage = usageFromResponsesPayload(payload);
|
|
735
|
+
return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }];
|
|
590
736
|
},
|
|
591
737
|
};
|
|
592
738
|
}
|
package/src/bridge.ts
CHANGED
|
@@ -456,6 +456,17 @@ export function bridgeToResponsesSSE(
|
|
|
456
456
|
if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue;
|
|
457
457
|
}
|
|
458
458
|
switch (event.type) {
|
|
459
|
+
case "assistant_boundary": {
|
|
460
|
+
// A guarded continuation starts a fresh assistant output item while keeping the
|
|
461
|
+
// intermediate, suspicious text in the same Responses turn.
|
|
462
|
+
if (currentMsg) closeCurrentMessage();
|
|
463
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
464
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
465
|
+
flushHiddenRawReasoning();
|
|
466
|
+
if (currentToolCall) closeCurrentToolCall();
|
|
467
|
+
flushHiddenReasoningEnvelope();
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
459
470
|
case "text_delta": {
|
|
460
471
|
if (currentReasoning) closeCurrentReasoning();
|
|
461
472
|
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
@@ -971,6 +982,12 @@ export function buildResponseJSON(
|
|
|
971
982
|
|
|
972
983
|
for (const e of events) {
|
|
973
984
|
switch (e.type) {
|
|
985
|
+
case "assistant_boundary":
|
|
986
|
+
flushText();
|
|
987
|
+
flushSummaryReasoning();
|
|
988
|
+
flushRawReasoning();
|
|
989
|
+
flushToolCall();
|
|
990
|
+
break;
|
|
974
991
|
case "text_delta":
|
|
975
992
|
if (currentText && currentTextPhase !== e.phase) flushText();
|
|
976
993
|
if (currentSummaryReasoning) flushSummaryReasoning();
|
|
@@ -1062,7 +1079,9 @@ export function buildResponseJSON(
|
|
|
1062
1079
|
flushSummaryReasoning();
|
|
1063
1080
|
flushRawReasoning();
|
|
1064
1081
|
flushToolCall();
|
|
1065
|
-
|
|
1082
|
+
// A truncated turn must never be installed as replacement history: emit the
|
|
1083
|
+
// compaction item only when the turn actually completed (#422).
|
|
1084
|
+
if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") {
|
|
1066
1085
|
output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
|
|
1067
1086
|
}
|
|
1068
1087
|
|