-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(bridge): hold a freeform wrapper the completed item will unwrap #5053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,12 @@ import { | |
| type OcxErrorPayload, | ||
| } from "../lib/errors"; | ||
| import { redactSecretString } from "../lib/redact"; | ||
| import { mayBecomePatchEnvelope, repairFreeformToolInput } from "../responses/apply-patch-envelope"; | ||
| import { | ||
| freeformFallbackKeys, | ||
| mayBecomePatchEnvelope, | ||
| repairFreeformToolInput, | ||
| unwrapFreeformToolInput, | ||
| } from "../responses/apply-patch-envelope"; | ||
| import { encodeCompactionSummary } from "../responses/compaction"; | ||
| import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat"; | ||
| import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason"; | ||
|
|
@@ -153,8 +158,38 @@ export function bridgeToResponsesSSE( | |
| // the completed custom_tool_call item stays authoritative). Compact `{"input":"...` | ||
| // buffers get their string value progressively unescaped; anything else streams raw. | ||
| const FREEFORM_WRAP_PREFIX = '{"input":"'; | ||
| const freeformPartialInput = (args: string): string => { | ||
| if (!args.startsWith(FREEFORM_WRAP_PREFIX)) return args; | ||
| /** `{"key":"` for every wrapper this tool name accepts. Keys are distinct, so order is free. */ | ||
| const freeformWrapPrefixes = (toolName: string): string[] => [ | ||
| FREEFORM_WRAP_PREFIX, | ||
| ...freeformFallbackKeys(toolName).map(key => `{"${key}":"`), | ||
| ]; | ||
| /** | ||
| * The value to stream so far, or `null` to HOLD because nothing can be decided yet. | ||
| * | ||
| * `input` is decidable from its prefix: `unwrapFreeformToolInput` returns it whenever the | ||
| * key is present, whatever else the object carries, so its value can be unescaped | ||
| * progressively and never retracted. | ||
| * | ||
| * A fallback key is not. It only unwraps when it is the SINGLE string field, and a second | ||
| * key can still arrive — so a value emitted early would have to be taken back. That is the | ||
| * rewind this holds instead: stream nothing until the object closes, then publish the one | ||
| * repaired body. The routed passthrough in `responses-custom-tool-repair.ts` already holds | ||
| * any object prefix for the same reason (#5047). | ||
| */ | ||
| const freeformPartialInput = (args: string, toolName: string): string | null => { | ||
| const prefixes = freeformWrapPrefixes(toolName); | ||
| // Still an ambiguous prefix of some wrapper: which wrapper, if any, is not known yet. | ||
| if (prefixes.some(prefix => prefix.startsWith(args))) return null; | ||
| if (!args.startsWith(FREEFORM_WRAP_PREFIX)) { | ||
| if (!prefixes.some(prefix => args.startsWith(prefix))) return args; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '30,90p' src/responses/apply-patch-envelope.ts
sed -n '150,205p' src/bridge/sse.ts
sed -n '1095,1140p' src/bridge/sse.ts
sed -n '1730,1830p' tests/adapters/bridge.test.ts
rg -n 'unwrapFreeformToolInput|repairFreeformToolInput|freeformPartialInput' src testsRepository: lidge-jun/opencodex Length of output: 20387 🏁 Script executed: sed -n '1,45p' src/responses/apply-patch-envelope.ts
sed -n '175,225p' src/bridge/sse.ts
sed -n '35,90p' tests/responses/apply-patch-envelope.test.ts
sed -n '1768,1810p' tests/adapters/bridge.test.tsRepository: lidge-jun/opencodex Length of output: 11144 Align streaming detection with completion fallback handling.
Make streaming use the same wrapper classification as 🤖 Prompt for AI Agents |
||
| // Committed to a fallback wrapper. Undecidable until the object is complete. | ||
| try { | ||
| JSON.parse(args); | ||
| } catch { | ||
| return null; | ||
| } | ||
| return unwrapFreeformToolInput(args, toolName); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a bare or AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } | ||
| const body = args.slice(FREEFORM_WRAP_PREFIX.length); | ||
| let out = ""; | ||
| for (let i = 0; i < body.length; i++) { | ||
|
|
@@ -1075,10 +1110,21 @@ export function bridgeToResponsesSSE( | |
| }); | ||
| } | ||
| if (currentToolCall.freeform && !currentToolCall.codeModeHelperName) { | ||
| // Hold while the buffer is still an ambiguous prefix of the JSON wrapper, | ||
| // then stream only the unwrapped input suffix (never rewind on mode flips). | ||
| if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) { | ||
| const full = freeformPartialInput(currentToolCall.args); | ||
| // `freeformPartialInput` holds while the buffer is still an ambiguous prefix | ||
| // of a JSON wrapper; otherwise stream only the unwrapped input suffix, never | ||
| // rewinding on a mode flip. | ||
| // | ||
| // The name is dropped for a namespaced tool that does not own the apply-patch | ||
| // grammar, because `repairFreeformToolInput` drops it at completion for the | ||
| // same reason. Streaming under a vocabulary the completed item does not use | ||
| // is the same disagreement in the other direction. | ||
| const ownsFreeformGrammar = currentToolCall.namespace === undefined | ||
| || currentToolCall.namespace === "functions"; | ||
| const full = freeformPartialInput( | ||
| currentToolCall.args, | ||
| ownsFreeformGrammar ? currentToolCall.name : "", | ||
| ); | ||
| if (full !== null) { | ||
| const emitted = currentToolCall.inputEmitted ?? ""; | ||
| // Also hold a buffer that could still become a complete patch envelope: | ||
| // at completion such a body is recompiled into an apply_patch helper call, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,18 @@ function stripMarkdownCodeFence(text: string, toolName: string): string { | |
| return match ? match[1] : text; | ||
| } | ||
|
|
||
| /** | ||
| * The single-field wrappers `unwrapFreeformToolInput` accepts for one tool name, besides the | ||
| * canonical `input`. | ||
| * | ||
| * Exported so the streaming side can hold a buffer that is still turning into one of these. | ||
| * A second list of key names beside this one is how the streamed bytes and the completed item | ||
| * come to disagree, which is the defect it exists to prevent (#5047). | ||
| */ | ||
| export function freeformFallbackKeys(toolName: string): readonly string[] { | ||
| return FREEFORM_FALLBACK_KEYS[toolName] ?? []; | ||
|
Comment on lines
+45
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This changes the freeform transport contract in AGENTS.md reference: src/AGENTS.md:L11-L11 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */ | ||
| export function unwrapFreeformToolInput(argumentsText: unknown, toolName = ""): string { | ||
| if (typeof argumentsText !== "string") return ""; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an accepted wrapper uses legal JSON whitespace, leading whitespace, or places another property before the fallback key—for example
{ "code" : "const x = 1" }—none of these compact prefixes matches, so this branch streams the raw JSON. Completion still passes the same text throughJSON.parseinunwrapFreeformToolInputand publishes only thecodevalue, leaving the delta/completed-input rewind that this change is intended to prevent. Hold potential JSON objects until completion or make detection tolerate all JSON formatting accepted by the completion path.AGENTS.md reference: src/AGENTS.md:L19-L19
Useful? React with 👍 / 👎.