diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 8b63180223..a3d5facd2b 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index d1e3085d66..07638696db 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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 { @@ -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. */ @@ -2337,14 +2340,16 @@ async function runLoopBody( let escapedNonAsciiToolChoiceCaptured = false; let escapedNonAsciiToolChoice: ToolChoice | undefined; let previousMalformedToolSignatures = new Set(); - 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. @@ -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; @@ -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", @@ -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)) { @@ -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. @@ -2705,7 +2722,10 @@ 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); @@ -2713,11 +2733,20 @@ async function runLoopBody( 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; @@ -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); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index a65797d2f5..083e6542c3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -19,6 +19,7 @@ import { type ThinkingBudgets, type ToolChoice, type ToolResultMessage, + type UserMessage, } from "@gajae-code/ai"; import { CURSOR_COMPOSER_BASH_POLICY_RECOVERY_PROMPT, @@ -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; @@ -1867,6 +1870,7 @@ export class Agent { return (await this.#maintainContext?.(context, lifecycle)) ?? "not-needed"; } : undefined, + transientRecoveryMessage: options?.transientRecoveryMessage, telemetry: this.#telemetry, }; diff --git a/packages/agent/src/prompts/escaped-nonascii-recovery.md b/packages/agent/src/prompts/escaped-nonascii-recovery.md new file mode 100644 index 0000000000..0bf28116a6 --- /dev/null +++ b/packages/agent/src/prompts/escaped-nonascii-recovery.md @@ -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. diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 246da15152..f7d59c4633 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -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 } @@ -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 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 e94971d213..78b7ffb5c9 100644 --- a/packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts +++ b/packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts @@ -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> = []; + 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> = []; const mock = createMockModel({ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2ab8464884..cdf797da5c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -84,6 +84,9 @@ - A resident text cache demotion now names the OS failure behind it. `ResidentCacheTrustError` lifts the wrapped errno into `causeCode` and a bounded, single-line `causeSummary`; `SessionManager` logs both on `Resident cache trust rejection` and reports `residentCacheDegradedCauseCode` in observability stats; the managed-sidecar disposal warning gains the path-free `causeCode` only, so the cache path it deliberately withholds stays withheld. Previously the record carried `reason` alone, which collapses distinct failures: `blob_create_failed` reads identically whether the instance directory vanished under a live store (`ENOENT`), the process exhausted descriptors (`EMFILE`), or the tree turned read-only (`EACCES`). That mattered in practice — long-running sessions that had externalized a large edit snapshot began aborting every turn once their cache went missing, and the demotion record could not distinguish that from a hostile-path rejection without attaching a debugger to a process that was already failing. - Fixed `/usage` and `/usage check` omitting provider limits for stored OAuth accounts. Cache-only snapshots now use the provider's resolved base URL when reading usage, and explicit checks render the successful probe report directly instead of depending on a cache-key-identical readback (#4634). - `/usage` shows quota resets again. Canonicalizing multi-account management (`364f14022`) rewired the interactive `/usage` handler from the graphical panel to the account-inventory text view, and that view rendered only `label: N% used (M% left)` — no bars, no reset countdown — leaving the command unable to answer when a quota comes back and stranding `handleUsageCommand`/`renderUsageReports` as unreachable code. Plain `/usage` in the TUI renders the panel again, sourced from the same cache-only inventory snapshot the text view reads, so the cache-only contract is preserved and no fetch or probe is reintroduced; `/usage check` keeps the text path, where the per-credential health verdict is the point. Account rows on every surface (TUI, ACP, Telegram) now carry `resets in ()`, and the panel itself gained multi-account reset lines, hour-precision countdowns past 48h (`6d 14h`, previously rounded to a bare `7d` at anywhere from 6.6 to 7.4 days), and set-aware account-label truncation so pooled credentials sharing a domain no longer collapse into identical columns. +- A Round-0 deep-interview `ask` whose `deepInterview` object is present with topology identity (`round: 0`, `component: "review-topology"`, `dimension: "topology"`) but omits required topology fields (`ambiguity`, `intent_contract`) is now rejected before coercion with a targeted correction naming the omitted fields and the exact `intent_contract` shape, instead of generic zod issues plus a full payload echo. The incomplete object is not a retired-pair recovery candidate, so it fell through to schema validation whose message named neither the contract nor what a corrected retry must contain — and a metadata-only retry repeated the same invalid shape (#4649). Recovery stays fail-closed: `intent_contract` is never synthesized (items and affirmative labels are the locked-intent evidence), and the recorder still locks intent only on an affirmative user answer. Valid contract-only Round 0 and post-Round-0 payloads validate unchanged. +- Added an opt-in crash upstream so local crash signatures can be aggregated across installs, which the agent-dir-scoped index could never answer on its own. `crashReport.upstream` (default `off`) plus `crashReport.upstreamDsn` (or `GJC_CRASH_SENTRY_DSN`) gate a hand-rolled Sentry envelope POST; no DSN literal is compiled into the binary, so a build has no destination to fall back to and `off` costs one settings read and no IO. The relay never runs on the fatal path — a crashing process still performs exactly one `O_APPEND` journal write, and relaying happens at the next startup after index compaction, bounded to 8 signatures per run with a 10s timeout. A Sentry SDK is deliberately not used: SDK defaults attach breadcrumbs, environment, and argv, which would defeat the point of the outbound sanitizer. Every crash-derived field must pass `sanitizeExternalCrashV1` and a refusal drops that signature outright rather than falling back to a less-sanitized payload; the emitted payload is a fixed key set (`event_id`, `timestamp`, `platform`, `level`, `logger`, `release`, `environment`, `fingerprint`, `exception`, `tags`, `extra`, `sdk`) with `user`, `server_name`, `contexts`, `breadcrumbs`, `request`, `modules`, env vars, argv, and hostname structurally absent. The gjc fingerprint is sent as Sentry's `fingerprint` array so grouping is ours rather than Sentry's heuristics — one upstream issue per gjc signature, verified by two events with disjoint stack frames merging into a single group. `gjc crash relay` exposes the same batch as a loud, non-zero-on-refusal command. A new `relayed` journal event stamps `relayedAt` monotonically so re-runs do not resend unless `lastSeen` advanced, and `relayedAt` is deliberately not an input to index eviction. The `gjc crash report` GitHub flow keeps its per-invocation, digest-confirmed consent boundary unchanged; the two egress channels are separate with separate rules. +- Managed-fallback sessions now steer escaped-non-ASCII retries instead of re-issuing them blind, and bound them. The agent loop's `escaped_arguments_discarded` outcome reports whether the discarded attempt still lacked a transient recovery instruction, and the session's retry continuation attaches exactly one such instruction (naming the `\uXXXX` defect and demanding literal UTF-8) through the new `transientRecoveryMessage` prompt option, so the deterministic Hangul-escaping failure observed on `ask` payloads is corrected on the retry instead of exhausting the budget every turn. Because those retries are deliberately un-charged (the defect is not provider evidence), each continuation is a fresh loop with a fresh in-loop resample budget and the fallback chain never exhausts on them — so a deterministic escaper previously looped forever under managed fallback (measured: 2,940 provider calls in 5s before an external timeout). The session now bounds escaped retries per logical run (steered retry + blind retry, reset each user turn) and fails closed through the terminal exhaustion message. The instruction is transient: sent only to the provider, never committed to durable history, never riding a later request; the terminal per-call rejection stays fail-closed. - Fixed resume listing scaling its read-syscall count with total transcript bytes. The trailing `header_patch` scan walks back to BOF whenever `cwd`/`title` stay unresolved (#3633), which is the common case because only `/rename` and workspace moves ever emit a patch; because the scan borrowed the caller's 4 KiB prefix buffer, that walk cost one `read` per 4 KiB of every candidate transcript on each `--resume`, `--continue`, and picker open. The scan now owns a 64 KiB buffer, so the same bytes are covered in ~16x fewer syscalls. Measured on a real 31-session workspace holding 105 MB of transcripts (largest 41 MB): 25,715 reads / 61.9 s before, 1,652 reads / 0.5 s after, with all 24 recovered titles unchanged. Buried-title recovery, the bytes examined, the `header_patch` marker prefilter, and listing results are unchanged. - Fixed Telegram forum topics freezing after the identity header: an attached, trusted session whose topic-host lease expired (20 s `HEARTBEAT_TTL_MS`) could never renew it, because `renewActiveTopicLeases` only renewed sessions that already passed the trusted-lease gate, so every later `turn_stream`/`context_update`/tool frame was rejected pre-send with "trusted attachment lease is stale" and the topic never updated again (#4647). A live attachment that still owns its exact logical session and holds an authorized recovery lease may now re-arm its own expired host lease — from the ownership heartbeat and once more before the publication gate — mirroring `acquireLease` admission (expired-but-owned active lease, or a same-owner resume inside the disconnect-grace window, which also covers the incident's persisted `disconnect_grace` record). Dropped sessions, closed endpoints, foreign lease owners, archive-fenced/inactive topics, malformed bindings, and cross-session ownership checks all still fail closed. Daemon generation bumped 169→170. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5785548774..5d073c5028 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -48,7 +48,7 @@ import { type StablePrefixSnapshot, ThinkingLevel, } from "@gajae-code/agent-core"; -import { normalizeMessagesForProvider } from "@gajae-code/agent-core/agent-loop"; +import { ESCAPED_NONASCII_RECOVERY_PROMPT, normalizeMessagesForProvider } from "@gajae-code/agent-core/agent-loop"; import type { AttemptRunHandle, AttemptScope, AttemptScopeAuthority } from "@gajae-code/agent-core/attempt-scope"; import { AUTO_HANDOFF_THRESHOLD_FOCUS, @@ -104,6 +104,7 @@ import type { TransportFailureFacts, Usage, UsageReport, + UserMessage, } from "@gajae-code/ai/core"; import { classifyContextOverflow, @@ -2018,6 +2019,15 @@ export interface DefaultFallbackRuntimeState { const AGENT_CONTINUE_BUSY_RESCHEDULE_BASE_DELAY_MS = 100; const AGENT_CONTINUE_BUSY_RESCHEDULE_MAX_DELAY_MS = 5_000; const AGENT_CONTINUE_BUSY_MAX_RESCHEDULES = 50; +/** + * Maximum un-charged managed-fallback retries for escaped-non-ASCII tool-call + * turns within one logical run. Each retry is a fresh loop with its own + * in-loop resample budget, so without this cap a deterministic escaper loops + * forever: the fallback chain never sees a charge to exhaust on. Matching the + * agent loop's own per-loop budget (MAX_ESCAPED_NONASCII_RESAMPLES + 1 wire + * attempts) keeps one steering retry plus one blind retry before the run ends. + */ +const MAX_ESCAPED_NONASCII_MANAGED_RETRIES = 2; function agentContinueBusyRescheduleDelayMs(attempt: number): number { const exponential = AGENT_CONTINUE_BUSY_RESCHEDULE_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1); @@ -2325,6 +2335,8 @@ export class AgentSession { #retryPromise: Promise | undefined = undefined; #retryResolve: (() => void) | undefined = undefined; #defaultFallbackController: FallbackChainController | undefined; + /** Managed escaped-non-ASCII retries issued for the current logical run. Bounded so a deterministic escaper cannot loop forever through un-charged fallback retries. */ + #escapedNonAsciiManagedRetries = 0; #overflowMaintenanceAttempts = 0; #defaultFallbackExhaustedLastTurn = false; #fallbackInvocationId = 0; @@ -18018,6 +18030,15 @@ export class AgentSession { return undefined; } + #escapedNonAsciiRecoveryMessage(): UserMessage { + return { + role: "user", + content: ESCAPED_NONASCII_RECOVERY_PROMPT, + synthetic: true, + timestamp: Date.now(), + }; + } + #managedFallbackPromptOptions(): { fallbackManaged?: boolean; nextFallbackAttempt?: (model: Model) => FallbackAttemptToken; @@ -18043,6 +18064,9 @@ export class AgentSession { } async #resetDefaultFallbackForNewTurn(): Promise { + // A fresh user turn gets a fresh escaped-non-ASCII retry budget: the + // defect is per-turn wire luck, not a sticky model property. + this.#escapedNonAsciiManagedRetries = 0; const controller = this.#defaultFallbackChain(); if (this.#defaultFallbackExhaustedLastTurn) { this.#defaultFallbackExhaustedLastTurn = false; @@ -18146,15 +18170,35 @@ export class AgentSession { // evidence: never charge the attempt, advance the chain, or suppress the // selector. The loop already removed the defective turn from history and // bounded its own resample budget, so this decision just re-issues the - // same request on the same model. Once the loop declines (budget spent), - // it falls through to the terminal per-call rejection, so the retry here - // is a continuation of the same logical run rather than a new prompt. + // same request on the same model. The re-issue carries the transient + // steering instruction exactly once (when the discarded attempt did not + // already have one), so a deterministic escaper has a reason to change + // its spelling; the instruction never lands in durable history. + // + // The retries are un-charged by design, so a deterministic escaper + // would otherwise loop forever: each continuation is a fresh loop with + // a fresh in-loop resample budget, and the fallback chain never sees a + // charge to exhaust on. Bound them per logical run and fail closed to + // the terminal exhaustion message once the budget is spent — the same + // fail-closed answer the unmanaged path gives via the per-call + // rejection. New user turns reset the budget in #resetDefaultFallbackForNewTurn. + this.#escapedNonAsciiManagedRetries += 1; + if (this.#escapedNonAsciiManagedRetries > MAX_ESCAPED_NONASCII_MANAGED_RETRIES) { + return this.#managedFallbackExhaustionDecision( + outcome.message, + `Managed fallback retried the escaped non-ASCII tool-call turn ${MAX_ESCAPED_NONASCII_MANAGED_RETRIES} times without a literal-UTF-8 re-issue; giving up so the run fails closed instead of looping.`, + ); + } this.#defaultFallbackChain().discardStartedAttempt(); + const steering = outcome.steeringPending === true; return { type: "retry", continuation: async ownership => { if (!ownership.isCurrent() || ownership.lease.signal.aborted) return; - await this.agent.continue(this.#managedFallbackPromptOptions()); + await this.agent.continue({ + ...this.#managedFallbackPromptOptions(), + ...(steering ? { transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage() } : {}), + }); }, }; } diff --git a/packages/coding-agent/test/agent-session-escaped-nonascii-managed-steering.test.ts b/packages/coding-agent/test/agent-session-escaped-nonascii-managed-steering.test.ts new file mode 100644 index 0000000000..6ec4d9d9d8 --- /dev/null +++ b/packages/coding-agent/test/agent-session-escaped-nonascii-managed-steering.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { Agent, type AgentMessage, type AgentTool } from "@gajae-code/agent-core"; +import { ESCAPED_NONASCII_RECOVERY_PROMPT } from "@gajae-code/agent-core/agent-loop"; +import { getBundledModel, type Message, type Model } from "@gajae-code/ai"; +import { createMockModel } from "@gajae-code/ai/providers/mock"; +import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; +import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { AgentSession } from "@gajae-code/coding-agent/session/agent-session"; +import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; +import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { TempDir } from "@gajae-code/utils"; +import * as z from "zod/v4"; + +const QUESTION = "마지막 병목"; + +function identityConverter(messages: AgentMessage[]): Message[] { + return messages.filter( + message => message.role === "user" || message.role === "assistant" || message.role === "toolResult", + ) as Message[]; +} + +function escapedTurn(id: string) { + return { + content: [ + { + type: "toolCall" as const, + id, + name: "ask", + arguments: { question: QUESTION }, + escapedNonAsciiArguments: true, + }, + ], + }; +} + +function literalTurn(id: string) { + return { content: [{ type: "toolCall" as const, id, name: "ask", arguments: { question: QUESTION } }] }; +} + +const schema = z.object({ question: z.string() }); + +function askTool(executed: Array>): AgentTool> { + return { + name: "ask", + label: "Ask", + description: "Ask", + parameters: schema, + async execute(_id, params) { + executed.push(params as Record); + return { content: [{ type: "text", text: "answered" }], details: {} }; + }, + }; +} + +function selector(model: Model): string { + return `${model.provider}/${model.id}`; +} + +/** Matches a user message carrying `text`, regardless of string-vs-blocks content shape. */ +function hasUserText(messages: AgentMessage[], text: string): boolean { + return messages.some(message => { + if (message.role !== "user") return false; + const content = message.content; + if (typeof content === "string") return content === text; + if (!Array.isArray(content)) return false; + return content.some(block => block?.type === "text" && block.text === text); + }); +} + +describe("AgentSession escaped non-ASCII managed steering", () => { + let tempDir: TempDir | undefined; + let authStorage: AuthStorage | undefined; + let session: AgentSession | undefined; + + afterEach(async () => { + await session?.dispose(); + authStorage?.close(); + tempDir?.removeSync(); + }); + + it("carries the transient steering instruction through the managed fallback retry", async () => { + tempDir = TempDir.createSync("@gjc-escaped-managed-steering-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + authStorage.setRuntimeApiKey("openai", "test-key"); + const primary = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!primary || !fallback) throw new Error("Expected bundled test models"); + const modelRegistry = new ModelRegistry(authStorage); + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const executed: Array> = []; + // Wire sequence: escaped (discarded → managed outcome, policy retry), + // escaped again (the retry attempt rode the steering instruction but is + // still defective → discarded again, steering already spent), then a + // literal-UTF-8 turn on the unsteered blind retry, which executes. + const mock = createMockModel({ + responses: [escapedTurn("tc-1"), escapedTurn("tc-2"), literalTurn("tc-3"), { content: ["done"] }], + }); + const agent = new Agent({ + initialState: { model: primary, systemPrompt: ["test"], tools: [askTool(executed)], messages: [] }, + convertToLlm: identityConverter, + streamFn: (model, context, options) => mock.stream(model, context, options), + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "fallback.maxAttempts": 3, + "retry.baseDelayMs": 10, + }); + settings.setModelRole("default", selector(primary)); + session = new AgentSession({ + agent, + sessionManager: manager, + settings, + modelRegistry, + }); + // A chain of 2+ entries is what turns on fallbackManaged for the run. + session.setConfiguredModelChain("default", [selector(primary), selector(fallback)], "test"); + + await session.prompt("ask me"); + await manager.flush(); + + // Four provider calls: initial, steered retry, blind retry, post-tool wrap-up. + expect(mock.model.calls).toHaveLength(4); + + // The first retry (the policy continuation after the first discarded + // outcome) must carry the steering instruction exactly once, and tools + // stay enabled: the steered request is a re-request of the same logical + // turn, not a diagnostic detour. + const steeredRequest = mock.model.calls[1]; + expect(steeredRequest).toBeDefined(); + expect(hasUserText(steeredRequest.context.messages, ESCAPED_NONASCII_RECOVERY_PROMPT)).toBe(true); + expect(steeredRequest.context.tools?.length ?? 0).toBeGreaterThan(0); + + // The instruction is transient: it never lands in durable history and + // never rides a later request once spent. + const durable = manager.buildSessionContext().messages; + expect(hasUserText(durable, ESCAPED_NONASCII_RECOVERY_PROMPT)).toBe(false); + for (const request of mock.model.calls.slice(2)) { + expect(hasUserText(request.context.messages, ESCAPED_NONASCII_RECOVERY_PROMPT)).toBe(false); + } + + // The literal call executed and every defective turn stayed out of history. + expect(executed).toEqual([{ question: QUESTION }]); + const persistedToolCallIds = durable.flatMap(message => + message.role === "assistant" + ? message.content.flatMap(block => (block.type === "toolCall" ? [block.id] : [])) + : [], + ); + expect(persistedToolCallIds).not.toContain("tc-1"); + expect(persistedToolCallIds).not.toContain("tc-2"); + expect(persistedToolCallIds).toContain("tc-3"); + }); + + it("fails closed after the managed escaped retry budget instead of looping forever", async () => { + tempDir = TempDir.createSync("@gjc-escaped-managed-budget-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + authStorage.setRuntimeApiKey("openai", "test-key"); + const primary = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!primary || !fallback) throw new Error("Expected bundled test models"); + const modelRegistry = new ModelRegistry(authStorage); + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const executed: Array> = []; + // A deterministic escaper: every wire attempt escapes, forever. Before + // the session-level bound this looped without end — each managed + // continuation is a fresh loop with a fresh in-loop budget and the + // un-charged fallback chain never exhausts. + const mock = createMockModel({ + handler: () => escapedTurn(`tc-forever-${mock.model.calls.length}`), + }); + const agent = new Agent({ + initialState: { model: primary, systemPrompt: ["test"], tools: [askTool(executed)], messages: [] }, + convertToLlm: identityConverter, + streamFn: (model, context, options) => mock.stream(model, context, options), + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "fallback.maxAttempts": 3, + "retry.baseDelayMs": 10, + }); + settings.setModelRole("default", selector(primary)); + session = new AgentSession({ + agent, + sessionManager: manager, + settings, + modelRegistry, + }); + session.setConfiguredModelChain("default", [selector(primary), selector(fallback)], "test"); + + await session.prompt("ask me"); + await manager.flush(); + + // The run is terminal with an error, never executed anything, and the + // provider-call count is bounded: 1 initial + 2 policy retries (the + // first steered, the second blind), each turn then spending its own + // in-loop resample budget is impossible here because every loop is a + // fresh managed invocation — so the session bound is the only stop. + expect(session.isStreaming).toBe(false); + expect(executed).toEqual([]); + expect(mock.model.calls.length).toBeLessThanOrEqual(8); + const durable = manager.buildSessionContext().messages; + const last = durable.findLast(message => message.role === "assistant"); + expect(last?.stopReason).toBe("error"); + expect(last?.errorMessage ?? "").toContain("escaped non-ASCII"); + // The steering instruction was still transient: exactly one steered + // request, nothing durable. + expect(hasUserText(durable, ESCAPED_NONASCII_RECOVERY_PROMPT)).toBe(false); + const steeredRequests = mock.model.calls.filter(request => + hasUserText(request.context.messages, ESCAPED_NONASCII_RECOVERY_PROMPT), + ); + expect(steeredRequests).toHaveLength(1); + }); +});