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
4 changes: 3 additions & 1 deletion packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
- `Agent.waitForSteeringArrival(signal)` resolves when steering is queued without consuming it, so wait-style tools can end their observation early.

### 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).
- 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, one narrowly scoped exemption applies: 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 post-budget and field-scoped).

- Escaped-non-ASCII turn resamples are now steered instead of blind: each unmanaged resample carries a transient synthetic instruction naming the `\uXXXX` defect and demanding literal UTF-8, so a model that escapes deterministically (observed with Hangul-heavy `ask` payloads exhausting the whole resample budget every turn) has a reason to change its spelling on the retry. The instruction never lands in durable history, tools stay enabled, and the captured logical-turn tool choice is still replayed across the steered attempts; a pending one-shot malformed-tool-call recovery is never displaced by the steering. Managed fallback retries receive the same steering: the typed `escaped_arguments_discarded` outcome now reports whether the discarded attempt still lacked an instruction, and the session's retry continuation attaches the same transient message through the new `transientRecoveryMessage` prompt option, so coding-agent sessions (which run managed) also get exactly one steered re-request before the budget ends.

## [0.14.1] - 2026-08-18
- Compaction pruning no longer kills the turn when a persisted `toolCall.arguments` is `null`. Sessions written by an earlier cold-spill eviction path store `null` where the spill sentinel belongs, and the staleness index dereferenced that payload unguarded, so reloading such a session threw `null is not an object (evaluating 'args.path')` as a turn-fatal error instead of skipping the one unusable call. `ToolCall.arguments` is typed non-nullable, so no type check flagged the gap. Every read of a persisted argument bag — path extraction, `apply_patch` header parsing, idempotent-bash keys, and search target keys — now treats a non-object payload as absent. The original arguments are not lost: the eviction marker still names the blob and rehydration restores them.
Expand Down
72 changes: 52 additions & 20 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
shouldMitigateHarmonyLeak,
signalListLabel,
} from "./harmony-leak";
import escapedNonAsciiRecoveryPrompt from "./prompts/escaped-nonascii-recovery.md" with { type: "text" };
import repeatedToolFailureRecoveryPrompt from "./prompts/repeated-tool-failure-recovery.md" with { type: "text" };
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
import {
Expand Down Expand Up @@ -239,6 +240,8 @@ const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
* budget recovers the overwhelming majority of turns; past it the terminal
* per-call rejection takes over rather than spending the run on retries.
*/
export const ESCAPED_NONASCII_RECOVERY_PROMPT = escapedNonAsciiRecoveryPrompt;

const MAX_ESCAPED_NONASCII_RESAMPLES = 2;

/** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
Expand Down Expand Up @@ -2337,14 +2340,16 @@ async function runLoopBody(
let escapedNonAsciiToolChoiceCaptured = false;
let escapedNonAsciiToolChoice: ToolChoice | undefined;
let previousMalformedToolSignatures = new Set<string>();
type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider" | "escaped-nonascii";
let pendingRecovery:
| {
kind: SyntheticRecoveryKind;
inserted: boolean;
syntheticMessage?: UserMessage;
}
| undefined;
| undefined = config.transientRecoveryMessage
? { kind: "escaped-nonascii", inserted: true, syntheticMessage: config.transientRecoveryMessage }
: undefined;
let malformedToolRecoveryAttempted = false;
let composerBashPolicyRecoveryAttempted = false;
// Deterministic terminal circuit breaker for argument-validation loops.
Expand Down Expand Up @@ -2454,6 +2459,11 @@ async function runLoopBody(
const attemptTransaction = managedTransaction;
const recoveryAttempt = pendingRecovery;
const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
// An escaped-non-ASCII steering resample is a re-request of the SAME
// logical turn, not a diagnostic detour: tools stay enabled and the
// captured logical-turn tool choice is replayed, so a queue-backed
// "required" still lands on the accepted attempt.
const wasEscapedNonAsciiRecoveryAttempt = recoveryAttempt?.kind === "escaped-nonascii";
try {
const getLogicalTurnToolChoice = (): ToolChoice | undefined => {
if (escapedNonAsciiToolChoiceCaptured) return escapedNonAsciiToolChoice;
Expand All @@ -2474,7 +2484,9 @@ async function runLoopBody(
? COMPOSER_BASH_POLICY_RECOVERY_PROMPT
: recoveryAttempt.kind === "malformed-tool-call"
? repeatedToolFailureRecoveryPrompt
: undefined;
: recoveryAttempt.kind === "escaped-nonascii"
? escapedNonAsciiRecoveryPrompt
: undefined;
if (recoveryContent) {
recoveryAttempt.syntheticMessage = {
role: "user",
Expand All @@ -2500,11 +2512,13 @@ async function runLoopBody(
? {
syntheticMessage: recoveryAttempt.syntheticMessage,
disableTools: wasMalformedToolRecoveryAttempt,
forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
forceAutoToolChoice: !wasMalformedToolRecoveryAttempt && !wasEscapedNonAsciiRecoveryAttempt,
}
: undefined,
escapedToolTransaction,
recoveryAttempt ? undefined : { value: getLogicalTurnToolChoice() },
recoveryAttempt && !wasEscapedNonAsciiRecoveryAttempt
? undefined
: { value: getLogicalTurnToolChoice() },
);
const detection = detectHarmonyLeakInAssistantMessage(message);
if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
Expand Down Expand Up @@ -2665,18 +2679,21 @@ async function runLoopBody(
}
}

// Escaped-non-ASCII tool arguments: bounded turn resample.
// Escaped-non-ASCII tool arguments: bounded steered turn resample.
//
// Arguments that spell a printable non-ASCII character as `\uXXXX`
// instead of literal UTF-8 are a wire-format defect, not a decision the
// model needs to be told about. The payload parses cleanly, but one
// mistyped nibble decodes to a different, equally valid character, so it
// can never be verified or repaired after the fact. Reporting it as a
// tool error spends the whole turn and writes the literal escape syntax
// back into the context the model samples from next. Drop the defective
// turn and re-request instead; the per-call rejection in
// `executeToolCalls` stays as the terminal answer once this budget is
// spent. Managed fallback reports the discarded attempt through the
// instead of literal UTF-8 are a wire-format defect. The payload parses
// cleanly, but one mistyped nibble decodes to a different, equally valid
// character, so it can never be verified or repaired after the fact.
// Reporting it as a tool error spends the whole turn and writes the
// literal escape syntax back into the context the model samples from
// next. Drop the defective turn and re-request with a transient
// steering instruction instead: models that escape deterministically
// (rather than as a sampling accident) reproduce the identical defect
// on a blind resample, so the retry names the defect without ever
// committing the escape syntax — or the instruction — to durable
// history. The per-call rejection in `executeToolCalls` stays as the
// terminal answer once this budget is spent. Managed fallback reports the discarded attempt through the
// 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.
Expand Down Expand Up @@ -2705,19 +2722,31 @@ async function runLoopBody(
// outcome below; the policy owns the same-model bounded retry and
// only falls back once it declines. The wire defect is not provider
// evidence, so the outcome deliberately carries no transport facts
// and the fallback chain never advances on it.
// and the fallback chain never advances on it. The outcome names
// whether a steering instruction already rode this attempt, so the
// policy's retry continuation can carry it exactly once instead of
// blindly re-requesting the same defective spelling.
if (config.fallbackManaged) {
transaction?.discard();
currentContext.messages.splice(contextMessageCount);
newMessages.splice(newMessageCount);
await config.onManagedAttemptOutcome?.({
type: "escaped_arguments_discarded",
message,
steeringPending: recoveryAttempt?.kind !== "escaped-nonascii",
scope: transaction?.scope,
});
stream.end(newMessages);
return;
}
// Steer the in-loop retry: name the defect in a transient synthetic
// message so a deterministic escaper has a reason to change its
// spelling. Never displace a different pending recovery (e.g. the
// one-shot malformed-tool-call turn): its mode and one-shot
// accounting must survive an escaped resample inside it.
if (!pendingRecovery || pendingRecovery.kind === "escaped-nonascii") {
pendingRecovery = { kind: "escaped-nonascii", inserted: false };
}
continue;
}
escapedNonAsciiResampleAttempt = 0;
Expand Down Expand Up @@ -3133,10 +3162,13 @@ async function streamAssistantResponse(

// Synthetic recovery requests choose their tool mode explicitly below and
// must never consume a queued dynamic choice intended for an ordinary turn.
const dynamicToolChoice = recoveryMode
? undefined
: toolChoiceOverride
? toolChoiceOverride.value
// An explicit toolChoiceOverride is the exception: it carries the already-
// captured logical-turn choice for a steering resample of that same turn,
// so replaying it never double-consumes the queue.
const dynamicToolChoice = toolChoiceOverride
? toolChoiceOverride.value
: recoveryMode
? undefined
: config.getToolChoice?.();
const dynamicReasoning = config.getReasoning?.();
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ThinkingBudgets,
type ToolChoice,
type ToolResultMessage,
type UserMessage,
} from "@gajae-code/ai";
import {
CURSOR_COMPOSER_BASH_POLICY_RECOVERY_PROMPT,
Expand Down Expand Up @@ -318,6 +319,8 @@ export interface AgentOptions {
}

export interface AgentPromptOptions {
/** One-shot transient recovery instruction sent only to the provider for the next assistant request; never committed to durable history. */
transientRecoveryMessage?: UserMessage;
toolChoice?: ToolChoice;
/** Disable transport replay; fallback accounting is owned by the caller. */
fallbackManaged?: boolean;
Expand Down Expand Up @@ -1867,6 +1870,7 @@ export class Agent {
return (await this.#maintainContext?.(context, lifecycle)) ?? "not-needed";
}
: undefined,
transientRecoveryMessage: options?.transientRecoveryMessage,
telemetry: this.#telemetry,
};

Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/prompts/escaped-nonascii-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Your previous response was discarded before execution: its tool-call arguments spelled non-ASCII text as `\uXXXX` escape sequences instead of literal UTF-8 characters. Escaped text cannot be verified — a single mistyped hex digit silently becomes a different, equally valid character — so such calls are never executed.

Re-issue the same tool call now, writing every non-ASCII character literally (for example 한글, 日本語, émoji — never `\uXXXX`). Do not change the intent or content of the call; only the spelling of the text.
17 changes: 16 additions & 1 deletion packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ export type ManagedAttemptOutcome =
type: "escaped_arguments_discarded";
/** The defective assistant turn; already removed from usable history by the loop. */
message: AssistantMessage;
/**
* True when this discarded attempt had no transient steering instruction
* attached yet. A managed retry continuation should carry the escaped
* non-ASCII recovery instruction exactly once, so a deterministic
* escaper has a reason to change its spelling; the instruction never
* lands in durable history. Absent/false means steering already ran.
*/
steeringPending?: boolean;
scope?: AttemptScope;
}
| { type: "context_overflow_discarded"; message: AssistantMessage; scope?: AttemptScope }
Expand Down Expand Up @@ -346,10 +354,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
/**
* Invoked with the follow-up messages the loop dequeues for the next turn
* (right after {@link getFollowUpMessages}). The consumer may use this to
* attach per-turn state (e.g. a fresh owned-completion lineage) at actual
* attach per-turn state (e.g., a fresh owned-completion lineage) at actual
* resume admission rather than when the message was merely queued.
*/
onFollowUpConsumed?: (messages: AgentMessage[]) => void;
/**
* One-shot transient recovery instruction attached to the first assistant
* request of this loop invocation. Sent only to the provider (never committed
* to durable agent message history) so a caller-owned retry of a discarded
* attempt can name the defect it is retrying around.
*/
transientRecoveryMessage?: UserMessage;
/**
* Supplies one bounded synthetic recovery instruction before the loop would
* otherwise yield. Unlike a follow-up, it is sent only to the provider and
Expand Down
50 changes: 50 additions & 0 deletions packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,56 @@ describe("agentLoop: ASCII-escaped non-ASCII argument guard", () => {
expect(resampleRequest.context.messages.some(message => message.role === "assistant")).toBe(false);
});

it("steers the resample with a transient synthetic instruction and keeps tools enabled", async () => {
const executed: Array<Record<string, unknown>> = [];
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [askTool(executed)] };
const mock = createMockModel({
responses: [escapedTurn("tc-1"), literalTurn("tc-2"), { content: ["done"] }],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };

const stream = agentLoop([createUserMessage("ask me")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}

// The resample request names the defect: a deterministic escaper
// reproduces the identical `\uXXXX` spelling on a blind re-request, so
// the retry must carry the steering instruction.
const resampleRequest = mock.model.calls[1];
expect(resampleRequest).toBeDefined();
const steering = resampleRequest.context.messages.filter(
message =>
message.role === "user" && typeof message.content === "string" && message.content.includes("literal UTF-8"),
);
expect(steering).toHaveLength(1);
// Steering is a re-request of the same logical turn, not a diagnostic
// detour: tools stay available so the corrected call can execute.
expect(resampleRequest.context.tools?.length ?? 0).toBeGreaterThan(0);
expect(executed).toEqual([{ question: QUESTION }]);

// The instruction is transient: it never lands in durable context or in
// the request that follows the accepted turn.
expect(
context.messages.some(
message =>
message.role === "user" &&
typeof message.content === "string" &&
message.content.includes("literal UTF-8"),
),
).toBe(false);
const followUpRequest = mock.model.calls[2];
expect(followUpRequest).toBeDefined();
expect(
followUpRequest.context.messages.filter(
message =>
message.role === "user" &&
typeof message.content === "string" &&
message.content.includes("literal UTF-8"),
),
).toHaveLength(0);
});

it("publishes and stores only the accepted assistant lifecycle", async () => {
const executed: Array<Record<string, unknown>> = [];
const mock = createMockModel({
Expand Down
Loading
Loading