From a00ff6f431dd6333f8aeed5f7e7b01a13a49cf29 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 18 Aug 2026 05:16:59 +0000 Subject: [PATCH] fix(agent): bound the escaped-nonascii em-dash exemption to display-only tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the fail-closed terminal escapedNonAsciiArguments rejection for every tool, then carve out exactly the motivating false positive: a tool that declares displaySafeEscapedArgs (user-facing question text that names no path, command, or identifier — currently only ask) executes without resampling when its decoded arguments contain nothing but benign typographic punctuation (curated set: U+2014 EM DASH). The json-parse scanner stays strict and still flags every non-ASCII escape; mutating tools and every other escaped character keep the two-resample budget and terminal rejection. Composes with the retained-guard + steering direction of #4632, which owns transient recovery steering. Red-team regressions cover: em-dash executes only on display-safe tools; the same payload on a mutating tool never executes; the nibble-adjacent en-dash (U+2013) and currency/math/full-width/separator/letter/emoji escapes stay rejected even on display-safe tools. Lore-id: 0f3c2a91 Constraint: mutating tools must never execute unverified \\uXXXX payloads Constraint: scanner stays evidence-based; the exemption is decided on decoded args at execution time Rejected: broad \p{P}\p{S}\p{Z} exemption | semantically significant nibble-sensitive symbols re-execute Rejected: execute decoded args after budget globally | review 4957008249 major blocker Confidence: high Scope-risk: narrow Reversibility: trivial Tested: 28 escaped-nonascii agent tests, 787 agent suite, 125 targeted matrix, 100 ai targeted Not-tested: none material Closes: #4627 review blockers --- packages/agent/CHANGELOG.md | 3 + packages/agent/src/agent-loop.ts | 83 ++++++- packages/agent/src/types.ts | 12 + ...ent-loop-escaped-nonascii-toolcall.test.ts | 216 ++++++++++++++++++ packages/ai/CHANGELOG.md | 1 + packages/ai/src/types.ts | 9 +- packages/ai/test/json-parse.test.ts | 12 + packages/coding-agent/src/tools/ask.ts | 8 + 8 files changed, 340 insertions(+), 4 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3593c983c9..a5c74d10c1 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -7,6 +7,9 @@ - `toolFailureEnvelope` / `isToolFailureEnvelope` / `ToolFailureEnvelope` name the result details the loop attaches when a tool call fails without the tool returning details of its own. The guard matches only that envelope, so a consumer can tell it apart from a tool that reports a `failureKind` alongside its own details before dereferencing a tool-owned detail shape. +### Fixed + +- The escaped-non-ASCII argument guard keeps its fail-closed terminal rejection and its unconditional two-resample budget for every tool and every field. After the budget is spent, a tool that enumerated its user-facing display fields (`displaySafeEscapedArgFields`; `ask` exempts only `questions.question` and `questions.options.label`) executes when every non-ASCII character lives inside those fields and is benign typographic punctuation (curated set: U+2014 em-dash). Escaped non-ASCII anywhere else — ids, deep-interview metadata, persisted records, non-ASCII object keys — and every other tool stays rejected terminally (#4627, reduced per both maintainer reviews: guard retained, exemption after budget and field-scoped). ## [0.14.0] - 2026-08-17 ### Fixed diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 8081f841bd..b0b7a49921 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -195,6 +195,75 @@ function hasEscapedNonAsciiToolCall(message: AssistantMessage): boolean { return message.content.some(block => block.type === "toolCall" && block.escapedNonAsciiArguments === true); } +/** + * The complete set of non-ASCII characters an escaped payload may decode to and + * still execute on a display-safe tool after the resample budget: U+2014 EM DASH + * only. This is the exact character JSON encoders spell as `\u2014` that + * motivated the false positive — an English question text like "sessions — + * in-process?" with no other non-ASCII. Curated deliberately: a mistyped nibble + * here can only produce another punctuation/letter codepoint, and any such + * landing (or any other escaped character at all) keeps the fail-closed + * rejection. Currency, math, full-width, separator, letter, mark, number, and + * surrogate escapes all stay rejected everywhere. + */ +const DISPLAY_SAFE_ESCAPED_CODEPOINTS = new Set([0x2014]); + +/** Structural type for tools that opt specific argument fields into display-safe handling. */ +type DisplaySafeEscapedTool = AgentTool & { + /** Argument fields (dotted paths into the arguments object) that render to the user as display text. */ + displaySafeEscapedArgFields?: readonly string[]; +}; + +/** + * Whether a non-ASCII codepoint is benign typographic punctuation that a JSON + * encoder may escape in display text. See {@link DISPLAY_SAFE_ESCAPED_CODEPOINTS}. + */ +function isDisplaySafeEscapedCodepoint(cp: number): boolean { + return DISPLAY_SAFE_ESCAPED_CODEPOINTS.has(cp); +} + +/** + * Walk the decoded arguments and decide whether the escaped payload is + * display-safe: every non-ASCII character must live inside one of the tool's + * declared display-field paths (dotted, array-index-free: `questions.question` + * matches every question in the `questions` array) AND be benign typographic + * punctuation. Any non-ASCII outside the display fields — ids, metadata, + * persisted records, or an object key — keeps the fail-closed rejection, as + * does any non-benign codepoint inside them. + */ +function isDisplaySafeEscapedArguments(tool: AgentTool | undefined, args: Record): boolean { + const fields = (tool as DisplaySafeEscapedTool | undefined)?.displaySafeEscapedArgFields; + if (!fields || fields.length === 0) return false; + const prefixes = [...fields]; + const isDisplayPath = (path: string): boolean => + prefixes.some(field => path === field || path.startsWith(`${field}.`)); + const walk = (node: unknown, path: string): boolean => { + if (typeof node === "string") { + for (const ch of node) { + const cp = ch.codePointAt(0); + if (cp === undefined || cp < 0x80) continue; + // Outside the display fields no non-ASCII is tolerated at all; + // inside them only the curated punctuation set is. + if (!isDisplayPath(path) || !isDisplaySafeEscapedCodepoint(cp)) return false; + } + return true; + } + if (Array.isArray(node)) return node.every(item => walk(item, path)); + if (typeof node === "object" && node !== null) { + for (const [key, value] of Object.entries(node)) { + // Field names are structural identifiers; the display fields have + // fixed ASCII names, so a non-ASCII key is never exempted. + for (const ch of key) { + const cp = ch.codePointAt(0); + if (cp !== undefined && cp >= 0x80) return false; + } + if (!walk(value, path === "" ? key : `${path}.${key}`)) return false; + } + } + return true; + }; + return walk(args, ""); +} /** Remove only the exact assistant response committed by its streaming attempt. */ function removeCommittedAssistantMessage(messages: AgentMessage[], message: AssistantMessage): boolean { const index = messages.lastIndexOf(message); @@ -2364,6 +2433,12 @@ async function runLoopBody( // typed `escaped_arguments_discarded` outcome so the session policy // owns a bounded same-model retry; the defect is never treated as // provider evidence, so the fallback chain never advances on it. + // + // The budget runs unconditionally for escaped payloads: even a + // display-safe tool (whose terminal exemption lives in + // `executeToolCalls`) resamples here first, so the model always gets + // its chances to emit literal UTF-8 and the fallback stays rare + // rather than becoming the default path. if ( message.stopReason !== "error" && message.stopReason !== "aborted" && @@ -3487,14 +3562,18 @@ async function executeToolCalls( : `Tool call "${toolCall.name}" was cut off before its arguments finished streaming (the response hit its output token limit). The partial arguments cannot be executed. Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`; throw new Error(detail); } - if (toolCall.escapedNonAsciiArguments) { + if (toolCall.escapedNonAsciiArguments && !isDisplaySafeEscapedArguments(tool, argsForExecution)) { record.argumentValidationFailed = true; // The arguments decoded cleanly, but they were spelled as `\uXXXX` // escapes rather than literal UTF-8. Hand-written hex is where models // mistype digits, and every mistyped nibble decodes to a different but // equally valid character — the payload is unverifiable and cannot be // repaired after parsing, so it is rejected rather than executed on - // silently corrupted text. + // silently corrupted text. The one bounded exception is a tool that + // declared its arguments display-only (see + // isDisplaySafeEscapedArguments): user-facing question text whose + // only non-ASCII characters are benign typographic punctuation is + // not the Hangul-mistype corruption this guard exists to stop. throw new Error( `Tool call "${toolCall.name}" spelled non-ASCII text as \\uXXXX escapes instead of literal UTF-8. ` + `Escaped text cannot be verified — a single wrong hex digit silently becomes a different character — ` + diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 27664d988e..f7d49b5706 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -698,6 +698,18 @@ export interface AgentTool>) => string | undefined); + /** + * Argument fields (dotted paths into the arguments object) that render to + * the user as pure display text — question wording and option labels, never + * ids, metadata, or persisted records. The agent loop uses this to bound the + * escaped-non-ASCII terminal rejection AFTER the resample budget is spent: + * when every non-ASCII character under these fields is benign typographic + * punctuation (e.g. an em-dash a JSON encoder escaped), the call executes + * instead of failing closed. Every other field of these arguments — and + * every field of every other tool — keeps the fail-closed rejection for + * unverified `\uXXXX` payloads. + */ + displaySafeEscapedArgFields?: readonly string[]; /** The main execution callback for this tool. */ execute: AgentToolExecFn; diff --git a/packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts b/packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts index d1a3433a94..e94971d213 100644 --- a/packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts +++ b/packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts @@ -38,6 +38,38 @@ function askTool(executed: Array>): AgentTool>, +): AgentTool> { + return { + ...askTool(executed), + displaySafeEscapedArgFields: ["question"], + }; +} + +/** A stand-in for any mutating tool (write/edit/bash): never display-safe. */ +function mutatingTool(executed: Array>): AgentTool> { + return { + ...askTool(executed), + name: "write", + }; +} + +/** A display-safe escaped turn whose only non-ASCII is an em-dash. */ +function emDashEscapedTurn(id: string, name = "ask") { + return { + content: [ + { + type: "toolCall" as const, + id, + name, + arguments: { question: "How should the daemon drive sessions — in-process?" }, + escapedNonAsciiArguments: true, + }, + ], + }; +} /** A turn whose raw arguments arrived spelled as `\uXXXX` instead of literal UTF-8. */ function escapedTurn(id: string, stopReason?: "aborted" | "error") { return { @@ -920,6 +952,190 @@ describe("agentLoop: ASCII-escaped non-ASCII argument guard", () => { ); }); + it("executes the benign em-dash ask case AFTER the resample budget on a display-safe tool", async () => { + const executed: Array> = []; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [displaySafeAskTool(executed)] }; + // Three escaped wire attempts consume the budget (1 original + 2 + // resamples); the third reaches terminal execution, where the + // field-scoped display-safe exemption lets the benign payload run. + const mock = createMockModel({ + responses: [ + emDashEscapedTurn("tc-1"), + emDashEscapedTurn("tc-2"), + emDashEscapedTurn("tc-3"), + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const toolResults: Array<{ isError?: boolean; text: string }> = []; + const stream = agentLoop([createUserMessage("ask me")], context, config, undefined, mock.stream); + for await (const event of stream) { + if (event.type === "tool_execution_end") { + const first = event.result.content?.[0]; + toolResults.push({ isError: event.isError, text: first?.type === "text" ? first.text : "" }); + } + } + + // The full budget ran before execution: 1 original + 2 resamples + the + // follow-up elicited by the executed tool result. + expect(mock.calls).toHaveLength(4); + expect(executed).toEqual([{ question: "How should the daemon drive sessions — in-process?" }]); + expect(toolResults).toHaveLength(1); + expect(toolResults[0].isError).toBeFalsy(); + }); + + it("never exempts escaped non-ASCII outside the declared display fields", async () => { + const executed: Array> = []; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [displaySafeAskTool(executed)] }; + // An em-dash in the QUESTION is benign; the same em-dash in a metadata + // field the tool did NOT enumerate (here: `deepInterview.dimension`) + // keeps the fail-closed rejection — the exemption is field-scoped. + const metaTurn = (id: string) => ({ + content: [ + { + type: "toolCall" as const, + id, + name: "ask", + arguments: { + question: "ok — fine", + deepInterview: { round: 1, component: "daemon", dimension: "스케줄 — 라우팅", ambiguity: 0.2 }, + }, + escapedNonAsciiArguments: true, + }, + ], + }); + const mock = createMockModel({ + responses: [metaTurn("tc-1"), metaTurn("tc-2"), metaTurn("tc-3"), { content: ["done"] }], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const toolResults: Array<{ isError?: boolean; text: string }> = []; + const stream = agentLoop([createUserMessage("ask me")], context, config, undefined, mock.stream); + for await (const event of stream) { + if (event.type === "tool_execution_end") { + const first = event.result.content?.[0]; + toolResults.push({ isError: event.isError, text: first?.type === "text" ? first.text : "" }); + } + } + + expect(executed).toHaveLength(0); + expect(toolResults).toHaveLength(1); + expect(toolResults[0].isError).toBe(true); + expect(toolResults[0].text).toContain("\\uXXXX"); + }); + + it("never executes the same em-dash payload when the tool is not display-safe", async () => { + const executed: Array> = []; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [mutatingTool(executed)] }; + const mock = createMockModel({ + responses: [ + emDashEscapedTurn("tc-1", "write"), + emDashEscapedTurn("tc-2", "write"), + emDashEscapedTurn("tc-3", "write"), + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const toolResults: Array<{ isError?: boolean; text: string }> = []; + const stream = agentLoop([createUserMessage("ask me")], context, config, undefined, mock.stream); + for await (const event of stream) { + if (event.type === "tool_execution_end") { + const first = event.result.content?.[0]; + toolResults.push({ isError: event.isError, text: first?.type === "text" ? first.text : "" }); + } + } + + // Mutating tools stay fail-closed: budget spent, then terminal rejection. + expect(executed).toHaveLength(0); + expect(toolResults).toHaveLength(1); + expect(toolResults[0].isError).toBe(true); + expect(toolResults[0].text).toContain("\\uXXXX"); + }); + + it("rejects a nibble-adjacent symbol escape even on a display-safe tool", async () => { + const executed: Array> = []; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [displaySafeAskTool(executed)] }; + const enDashTurn = (id: string) => ({ + content: [ + { + type: "toolCall" as const, + id, + name: "ask", + // U+2013 EN DASH — one nibble from the exempted U+2014. + arguments: { question: "range 0–1 inclusive?" }, + escapedNonAsciiArguments: true, + }, + ], + }); + const mock = createMockModel({ + responses: [enDashTurn("tc-1"), enDashTurn("tc-2"), enDashTurn("tc-3"), { content: ["done"] }], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const toolResults: Array<{ isError?: boolean; text: string }> = []; + const stream = agentLoop([createUserMessage("ask me")], context, config, undefined, mock.stream); + for await (const event of stream) { + if (event.type === "tool_execution_end") { + const first = event.result.content?.[0]; + toolResults.push({ isError: event.isError, text: first?.type === "text" ? first.text : "" }); + } + } + + // The curated set admits only U+2014; every other symbol — including the + // nibble-adjacent en-dash — keeps the fail-closed rejection. + expect(executed).toHaveLength(0); + expect(toolResults).toHaveLength(1); + expect(toolResults[0].isError).toBe(true); + expect(toolResults[0].text).toContain("\\uXXXX"); + }); + + it("rejects currency, math, full-width, separator, letter, and emoji escapes on a display-safe tool", async () => { + const redTeam: Array<[string, string]> = [ + ["currency ₩", "price ₩1,000?"], + ["math ≈", "is x ≈ y?"], + ["full-width !", "really!sure?"], + ["ideographic space  ", "a b?"], + ["hangul letter 안", "이름이 안 무엇인가?"], + ["emoji 😀", "feeling 😀 today?"], + ]; + for (const [label, question] of redTeam) { + const executed: Array> = []; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [displaySafeAskTool(executed)] }; + const turn = (id: string) => ({ + content: [ + { + type: "toolCall" as const, + id, + name: "ask", + arguments: { question }, + escapedNonAsciiArguments: true, + }, + ], + }); + const mock = createMockModel({ + responses: [turn("tc-1"), turn("tc-2"), turn("tc-3"), { content: ["done"] }], + }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const toolResults: Array<{ isError?: boolean; text: string }> = []; + const stream = agentLoop([createUserMessage("ask me")], context, config, undefined, mock.stream); + for await (const event of stream) { + if (event.type === "tool_execution_end") { + const first = event.result.content?.[0]; + toolResults.push({ isError: event.isError, text: first?.type === "text" ? first.text : "" }); + } + } + + expect(executed).toHaveLength(0); + expect(toolResults).toHaveLength(1); + expect(toolResults[0].isError).toBe(true); + expect(toolResults[0].text).toContain("\\uXXXX"); + expect(label).toBeTruthy(); + } + }); + it("executes literal UTF-8 arguments untouched", async () => { const executed: Array> = []; const context: AgentContext = { systemPrompt: [""], messages: [], tools: [askTool(executed)] }; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 15b539b900..982f4cd0c3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ - oMLX OpenAI-compatible completions now send `chat_template_kwargs.reasoning_effort` with `enable_thinking` when `thinkingFormat` is `qwen-chat-template`. Discovered oMLX models are treated as reasoning models with `low`/`medium`/`high` effort so local Qwen presets can differentiate roles without swapping weights. - Fixed a resume-breaking HTTP 400 on `google-gemini-cli`/`google-antigravity` replay: assistant thinking blocks whose `thinkingSignature` is missing, empty (persistence clears oversized signatures to `""`), or invalid no longer emit an unsigned `{"thought": true}` part. Cloud Code Assist maps such parts to Anthropic `thinking` blocks and rejects the whole request with `messages.N.content.0.thinking.signature: Field required`, permanently bricking resumed sessions (#4630). Unsigned thinking now degrades to plain text — the same treatment cross-model reasoning already gets — while validly signed thinking still replays natively as a thought part with its `thoughtSignature`. - `validateApiKeyAgainstModelsEndpoint` no longer accepts an API key on HTTP status alone. A 200 whose body is not JSON or carries no recognizable model list (OpenAI-compatible `data` array, gateway `models` array, or a bare array) now fails closed with an actionable error — previously a captive portal or broken gateway answering 200 with an HTML page silently validated and stored the key. Affects every provider using `kind: "models-endpoint"` (Synthetic, DeepSeek, DeepInfra, Fireworks, BizRouter, NanoGPT, OpenGateway, ZenMux, Fugu). Upstream bodies echoed into validation errors are now bounded to 200 characters on both validators. +- `ToolCall.escapedNonAsciiArguments` doc updated: the agent loop resamples unconditionally then rejects terminally, with a single bounded after-budget exception for tools that enumerated display fields (`displaySafeEscapedArgFields`) whose non-ASCII content is benign typographic punctuation. The scanner itself is unchanged and still flags every non-ASCII escape (#4627). ## [0.14.0] - 2026-08-17 - Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary. diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b567992f39..2035a6bfde 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -608,8 +608,13 @@ export interface ToolCall { * `\uXXXX` escape instead of literal UTF-8. Such a payload parses cleanly but * is unverifiable: one mistyped hex digit decodes to a different, equally * valid character, so the text can be silently wrong with no in-band evidence. - * The agent loop rejects the call with a retryable error instead of executing - * it. Escapes that are required (control characters) or unavoidable (lone + * The agent loop resamples the turn unconditionally a bounded number of + * times and then rejects the call instead of executing it. The single + * bounded after-budget exception is a tool that enumerated its display + * fields (`displaySafeEscapedArgFields`) whose non-ASCII content is benign + * typographic punctuation — rendered question text, never executable + * content, ids, or durable metadata. + * Escapes that are required (control characters) or unavoidable (lone * surrogates) never set this. */ escapedNonAsciiArguments?: boolean; diff --git a/packages/ai/test/json-parse.test.ts b/packages/ai/test/json-parse.test.ts index 22952b2c88..912c3e37e6 100644 --- a/packages/ai/test/json-parse.test.ts +++ b/packages/ai/test/json-parse.test.ts @@ -84,6 +84,18 @@ describe("findUnnecessaryUnicodeEscape", () => { expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"\uzz11"}`)).toBeUndefined(); }); + it("still flags em-dash and every other punctuation/symbol/separator escape — the bounded exemption lives in the agent loop, not the scanner", () => { + // The scanner stays strictly evidence-based: any non-ASCII escape flags. + // The display-safe-tool carve-out for benign typographic punctuation is + // decided at execution time against the DECODED arguments (agent-loop + // isDisplaySafeEscapedArguments), never by widening this detector. + expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"sessions \u2014 in-process?"}`)).toBe(String.raw`\u2014`); + expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"\u201cquoted\u201d"}`)).toBe(String.raw`\u201c`); + expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"a\u00a0b"}`)).toBe(String.raw`\u00a0`); + expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"\u20a9"}`)).toBe(String.raw`\u20a9`); + expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"\uff01"}`)).toBe(String.raw`\uff01`); + }); + it("does not flag a truncated escape at the end of a streaming buffer", () => { expect(findUnnecessaryUnicodeEscape(String.raw`{"q":"\ubc`)).toBeUndefined(); }); diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 8da726f5b8..33ad26ae8c 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -734,6 +734,14 @@ export class AskTool implements AgentTool { recoverRoundZeroIntentContract(arguments_, this.session.getDeepInterviewAskStage?.()); readonly strict = true; readonly loadMode = "discoverable"; + /** + * The only ask argument fields rendered to the user as display text: the + * question wording and the option labels. Everything else — ids, + * deep-interview metadata (intent contracts/reviews, references), workflow + * gate metadata — is structured or durable and stays subject to the + * fail-closed escaped-non-ASCII rejection even after the resample budget. + */ + readonly displaySafeEscapedArgFields = ["questions.question", "questions.options.label"] as const; constructor(private readonly session: ToolSession) { this.description = prompt.render(askDescription);