fix(coding-agent): repaired per-field double-encoded JSON in task tool
- Added `repairDoubleEncodedJsonString` to unescape fields double-encoded by the model (e.g. literal `\n`, `\"`, `\uXXXX` in `context`/`assignment`/`description`). - Scoped repair to natural-language fields only, leaving code-bearing tools untouched. - Applied repair on both render and execution paths in `TaskTool`.
This commit is contained in:
@@ -48,6 +48,7 @@ import { runSubprocess } from "./executor";
|
||||
import { AgentOutputManager } from "./output-manager";
|
||||
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
|
||||
import { renderResult, renderCall as renderTaskCall } from "./render";
|
||||
import { repairTaskParams } from "./repair-args";
|
||||
import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
|
||||
import {
|
||||
applyNestedPatches,
|
||||
@@ -247,7 +248,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
||||
}
|
||||
|
||||
renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
|
||||
return renderTaskCall(args as TaskParams, options, theme);
|
||||
return renderTaskCall(repairTaskParams(args as TaskParams), options, theme);
|
||||
}
|
||||
|
||||
/** Dynamic description that reflects current disabled-agent settings */
|
||||
@@ -292,7 +293,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
|
||||
): Promise<AgentToolResult<TaskToolDetails>> {
|
||||
const params = rawParams as TaskParams;
|
||||
const params = repairTaskParams(rawParams as TaskParams);
|
||||
const simpleMode = this.#getTaskSimpleMode();
|
||||
const validationError = validateTaskModeParams(simpleMode, params);
|
||||
if (validationError) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Repair double-encoded JSON string arguments for the task tool.
|
||||
*
|
||||
* Models occasionally JSON-escape a string value twice when emitting a
|
||||
* `task` tool call, so a `context`/`assignment` that should read
|
||||
*
|
||||
* # Role
|
||||
* You are a judge … "describe this" … return —
|
||||
*
|
||||
* arrives — after the one JSON decode the provider already applied — as the
|
||||
* literal text
|
||||
*
|
||||
* # Role\nYou are a judge … \"describe this\" … return \u2014
|
||||
*
|
||||
* i.e. every newline, quote, and unicode character is still backslash-escaped.
|
||||
* The subagent then receives that garbled prompt, and the call preview renders
|
||||
* one long blob with visible `\n` / `\"` / `\uXXXX`.
|
||||
*
|
||||
* The *whole-arguments* form of this quirk (the entire `arguments` blob is a
|
||||
* JSON string) is already auto-corrected by the validator's JSON-string
|
||||
* coercion. This module handles the *per-field* form, where the object parses
|
||||
* fine but an individual string value is double-encoded — the validator never
|
||||
* fires there because a double-encoded string is still a structurally valid
|
||||
* string.
|
||||
*
|
||||
* This is deliberately scoped to the task tool's natural-language fields
|
||||
* (`context`, `assignment`, `description`). It is NOT applied to code-bearing
|
||||
* tools (write/edit/bash/search), where a backslash or quote is load-bearing
|
||||
* and a false-positive unescape would silently corrupt a file or command.
|
||||
*/
|
||||
import type { TaskItem, TaskParams } from "./types";
|
||||
|
||||
/** A backslash that escapes a structural char — `\"`, `\\`, `\/`, or `\uXXXX`. */
|
||||
const STRUCTURAL_ESCAPE = /\\(?:["\\/]|u[0-9a-fA-F]{4})/;
|
||||
|
||||
/**
|
||||
* Whether `value` carries the signature of whole-string double-encoding rather
|
||||
* than an incidental escape mention. A lone `\n`/`\t` in an instruction (e.g.
|
||||
* "split lines on \n") is far more likely a literal mention than a
|
||||
* double-encoded document, so it is left alone; a structural escape (`\"`,
|
||||
* `\\`, `\uXXXX`) or two-plus escape sequences indicates a re-escaped payload.
|
||||
*/
|
||||
function hasDoubleEncodeSignature(value: string): boolean {
|
||||
if (STRUCTURAL_ESCAPE.test(value)) return true;
|
||||
let count = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) === 0x5c /* \ */) {
|
||||
count += 1;
|
||||
if (count >= 2) return true;
|
||||
i += 1; // skip the escaped char so `\\` counts once
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the once-unescaped string when `value` is uniformly double-encoded
|
||||
* JSON (a well-formed JSON string body that decodes to a different string);
|
||||
* otherwise return `value` unchanged.
|
||||
*
|
||||
* The `JSON.parse(\`"${value}"\`)` round-trip is the safety net: it only
|
||||
* succeeds when *every* backslash begins a valid JSON escape and no bare
|
||||
* double-quote exists — exactly the signature of double-encoding. Genuine
|
||||
* prose with a Windows path (`C:\Users`), a regex (`\d+`), an embedded quote,
|
||||
* or a real (already-decoded) newline makes the parse throw, so the value is
|
||||
* returned untouched.
|
||||
*/
|
||||
export function repairDoubleEncodedJsonString(value: string): string {
|
||||
// Fast path: no backslash → nothing was escaped → the parse can never differ.
|
||||
if (!value.includes("\\")) return value;
|
||||
if (!hasDoubleEncodeSignature(value)) return value;
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(`"${value}"`);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return typeof decoded === "string" && decoded !== value ? decoded : value;
|
||||
}
|
||||
|
||||
/** Repair a single (possibly partial) task item's prose fields. */
|
||||
function repairTaskItem(task: TaskItem): TaskItem {
|
||||
if (task === null || typeof task !== "object") return task;
|
||||
const assignment =
|
||||
typeof task.assignment === "string" ? repairDoubleEncodedJsonString(task.assignment) : task.assignment;
|
||||
const description =
|
||||
typeof task.description === "string" ? repairDoubleEncodedJsonString(task.description) : task.description;
|
||||
if (assignment === task.assignment && description === task.description) return task;
|
||||
return { ...task, assignment, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair double-encoded prose in task-tool params (`context` and each task's
|
||||
* `assignment`/`description`). Returns the same reference when nothing changed
|
||||
* so callers can cheaply skip work. Defensive against partially-streamed args
|
||||
* (missing/undefined fields, partial task arrays) so it is safe on the render
|
||||
* path as well as on execution.
|
||||
*/
|
||||
export function repairTaskParams(params: TaskParams): TaskParams {
|
||||
if (params === null || typeof params !== "object") return params;
|
||||
|
||||
const context = typeof params.context === "string" ? repairDoubleEncodedJsonString(params.context) : params.context;
|
||||
|
||||
let tasks = params.tasks;
|
||||
if (Array.isArray(params.tasks)) {
|
||||
let changed = false;
|
||||
const repaired = params.tasks.map(task => {
|
||||
const next = repairTaskItem(task);
|
||||
if (next !== task) changed = true;
|
||||
return next;
|
||||
});
|
||||
if (changed) tasks = repaired;
|
||||
}
|
||||
|
||||
if (context === params.context && tasks === params.tasks) return params;
|
||||
return { ...params, context, tasks };
|
||||
}
|
||||
Reference in New Issue
Block a user