Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,12 @@ Codex. Native custom calls and converted function calls use the same completion
patch previews are held while their executable form is unresolved. JavaScript that merely
contains patch text and unrelated native custom payloads stay unchanged.

When a routed model double-wraps an executable freeform body, opencodex removes one extra
JSON object containing only an input string. This repair applies to local exec and apply_patch
bodies, leaves other tool grammars and ambiguous objects unchanged, and does not decode recursively.
A body holds its streamed preview only while it can still turn out to be such a wrapper;
JavaScript that merely starts with an object or block keeps streaming as it arrives.

A routed model can also mistakenly send a shell-argument object such as
`{"cmd":"git status --short"}` to code-mode `exec`. For a verified code-mode catalog,
opencodex converts an unambiguous shell object into `tools.exec_command(...)` JavaScript
Expand Down
8 changes: 5 additions & 3 deletions src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
mayBecomePatchEnvelope,
repairFreeformToolInput,
} from "../responses/apply-patch-envelope";
import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
import { ProgressiveFreeformInputReader } from "../responses/progressive-freeform-input";
import { encodeCompactionSummary, releaseCompactionCiphertextLease } from "../responses/compaction";
import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input";
Expand Down Expand Up @@ -480,7 +480,7 @@ export function bridgeToResponsesSSE(
// Full assistant text of a compaction turn (across message boundaries) — becomes the
// synthetic compaction item's payload on done.
let compaction = emptyChunks();
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; codeModeHelperName?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; freeformReader?: ProgressiveFreeformInputReader; codeModeHelperName?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
// Open native web-search cell (between begin and end). Holds the output index allocated on
// begin so the matching done reuses it; closed as `failed` if the stream terminates early.
let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
Expand Down Expand Up @@ -1157,7 +1157,9 @@ export function bridgeToResponsesSSE(
// is the same disagreement in the other direction.
const ownsFreeformGrammar = currentToolCall.namespace === undefined
|| currentToolCall.namespace === "functions";
const full = progressiveFreeformInput(
// One reader per call resumes where the previous delta stopped.
currentToolCall.freeformReader ??= new ProgressiveFreeformInputReader();
const full = currentToolCall.freeformReader.update(
currentToolCall.args,
ownsFreeformGrammar ? currentToolCall.name : "",
);
Expand Down
20 changes: 18 additions & 2 deletions src/responses/apply-patch-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ function stripMarkdownCodeFence(text: string, toolName: string): string {
return match ? match[1] : text;
}

/** Repair one extra canonical wrapper inside an already unwrapped executable body. */
function repairUnwrappedBody(text: string, toolName: string): string {
if (toolName !== "exec" && toolName !== "apply_patch") return text;
try {
const nested: unknown = JSON.parse(text);
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
const record = nested as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length === 1 && keys[0] === "input" && typeof record.input === "string") text = record.input;
}
} catch {
// Ordinary JavaScript and patches are not JSON wrappers.
}
return stripMarkdownCodeFence(text, toolName);
}

/** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */
export function unwrapFreeformToolInput(argumentsText: unknown, toolName = ""): string {
if (typeof argumentsText !== "string") return "";
Expand All @@ -48,13 +64,13 @@ export function unwrapFreeformToolInput(argumentsText: unknown, toolName = ""):
const record = parsed as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(record, "input")) {
return typeof record.input === "string"
? stripMarkdownCodeFence(record.input, toolName)
? repairUnwrappedBody(record.input, toolName)
: argumentsText;
}
const fallbackKeys = freeformFallbackKeys(toolName);
const candidates = fallbackKeys.filter(key => typeof record[key] === "string");
if (candidates.length === 1) {
return stripMarkdownCodeFence(record[candidates[0]] as string, toolName);
return repairUnwrappedBody(record[candidates[0]] as string, toolName);
}
}
} catch {
Expand Down
8 changes: 5 additions & 3 deletions src/responses/freeform-wrapper-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ const NEVER = -2;
export type FreeformWrapperScan =
| { kind: "hold" }
| { kind: "raw" }
| { kind: "input"; valueStart: number };
// `canonical` is false for a fallback field, which a closed object resolved and trailing text
// can still withdraw; an own `input` is decided by its prefix and stays decided.
| { kind: "input"; valueStart: number; canonical: boolean };

/** Index of the first non-whitespace character at or after `from`, or HOLD past the end. */
function skipWhitespace(text: string, from: number): number {
Expand Down Expand Up @@ -249,7 +251,7 @@ export function scanFreeformWrapper(text: string, fallbackKeys: readonly string[
// non-string value is decidable too, in the other direction: completion hands back the
// argument text unchanged rather than unwrapping a value it cannot use.
return bounded[valueAt] === '"'
? { kind: "input", valueStart: valueAt + 1 }
? { kind: "input", valueStart: valueAt + 1, canonical: true }
: { kind: "raw" };
}

Expand Down Expand Up @@ -300,7 +302,7 @@ function afterTopLevelClose(
if (!wholeText) return { kind: "hold" };
const candidates = fallbackKeys.filter(key => members.get(key)?.stringValue === true);
if (candidates.length === 1) {
return { kind: "input", valueStart: members.get(candidates[0]!)!.valueStart };
return { kind: "input", valueStart: members.get(candidates[0]!)!.valueStart, canonical: false };
}
return { kind: "raw" };
}
Loading
Loading