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 @@ -7,8 +7,10 @@
- Managed fallback snapshot failures now retain a typed `local_snapshot_failure` error kind when they reach the agent terminal event, so session retry policy can auto-recover them with a bounded same-model retry instead of treating the local failure as provider fallback evidence or surfacing it as terminal.
- Managed fallback buffer overflows now retain a typed `local_buffer_overflow` error kind on the terminal assistant message, so session retry policy surfaces them immediately without provider-fallback attribution instead of admitting them to the bounded `unknown` retry class.
- Managed local-failure diagnostics: `ManagedAttemptSnapshotError` and `ManagedAttemptBufferOverflowError` now carry a stable `stage` discriminator naming the exact rejecting site (`shell.role`, `shell.content`, `event.snapshot`, `event.contentIndex`, `event.delta`, `event.content`, `event.toolcall`, `event.done.reason`, `event.error.reason`, `event.unknownType`, `staging.losslessSnapshot`, `staging.measure`, `staging.sanitize`, `staging.overflow`, `overflow.preMeasure`, `overflow.staged`), and the run-loop failure boundary emits ONE bounded shape-only `logger.warn` per stream invocation (stage, error kind, model, provider, snapshot mode, staged event count/bytes, and content block count for the content stage). The diagnostic is gated on the module-private local error identities and its stage is whitelisted against the closed vocabulary, so neither a foreign error that self-labels a local failure kind nor an in-module regression can route arbitrary text into the log; it never records raw text, thinking, tool arguments, or any provider payload, and the user-facing message string is unchanged so session-side classification keeps matching. Previously all 14 rejecting sites shared one static message, leaving no way to identify which provider shape a normalizer must be taught to accept.

- A turn whose tool arguments arrive flagged `escapedNonAsciiArguments` is now resampled instead of being reported as a tool failure: the defective assistant turn is dropped from history and the request is re-issued, up to twice per turn, before the terminal per-call rejection takes over. Hand-spelled `\uXXXX` arguments decode into valid-looking but silently wrong text (observed as garbled Hangul in `ask` prompts) and no post-parse repair can recover them, but the defect is a wire-format accident that resampling clears - surfacing it as a tool error instead burned the whole turn and fed the literal escape syntax back into the context the model samples from next. Scoped to the non-managed session path, matching the existing `invalid_prompt` and reasoning-content repairs; managed fallback keeps owning its own retry policy.
- Visible-text Harmony leak retries now close the already-published assistant lifecycle with an empty aborted terminal stripped of raw provider payload before contaminated history is removed and a replacement request begins, preventing both orphaned streaming updates and leaked control text in durable history or replay.
- Managed fallback sessions now recover `\uXXXX`-escaped non-ASCII tool arguments instead of rejecting them: `ManagedAttemptOutcome` gains a typed `escaped_arguments_discarded` variant that the loop reports after dropping the defective turn from history, and the session policy answers it with a bounded same-model retry that never charges the fallback chain, advances models, or mutates credentials - the wire defect is a sampling accident, not provider evidence, so the chain must not advance on it.
- Unmanaged escaped-non-ASCII resampling now stages a detached, provider-metadata-preserving assistant lifecycle until validation, publishes live safety updates before dispatch, and defers terminal `message_end` publication until subscriber-triggered cancellation is resolved so persisted assistant state and aborted tool-result pairing cannot disagree.
- The agent loop still rejects a tool call flagged `escapedNonAsciiArguments` before execution once the resample budget is spent, with a retryable error telling the model to re-issue the call writing non-ASCII characters literally.
- The emergency compaction system now includes a `transcriptFileBytes` floor (48 MiB, 75% of the managed-storage per-file cap) so a long-running session compacts before its append-only transcript reaches the 64 MiB limit. `CompactionTriggerReason` adds `"transcriptFile"`, `EmergencyCompactionSample` adds `transcriptFileBytes`, and `EmergencyCompactionLimits` adds `transcriptFileBytes`.
- Managed fallback attempt snapshots no longer fail the whole run on benign provider shape variations: an assistant message whose `content` is a bare string or is missing now degrades to an empty content array, and staged assistant events with out-of-vocabulary `done`/`error` reasons or an unknown string `type` degrade to schema-valid values instead of throwing a non-retryable `ManagedAttemptSnapshotError`. This matches the closed `StopReason` vocabulary already normalized elsewhere in the shell. Object-shaped and other exotic non-array `content` stays fail-closed under the named `shell.content` diagnostic, as does sanitizer-sentinel string content (`[unserializable]`/`[accessor]`/`[truncated]`/`[Circular]`, which mark a non-cloneable original value such as a proxy-wrapped content array rather than provider string variance — degrading those would silently drop real content behind a successful empty turn), and hostile inputs keep failing fast with no retry authority: a live proxy root, a throwing `get`/`getOwnPropertyDescriptor` trap, and a non-string event `type` all remain local snapshot failures.
Expand Down
164 changes: 146 additions & 18 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const MANAGED_LOCAL_FAILURE_STAGES = [
"staging.losslessSnapshot",
"staging.measure",
"staging.sanitize",
"staging.preMeasure",
"staging.overflow",
"overflow.preMeasure",
"overflow.staged",
Expand Down Expand Up @@ -868,7 +869,16 @@ function losslessDetachedClone<T>(value: T): T {
} catch {
if (key === "transportFailure" && isManagedPlainRecord(descriptor.value)) {
const transport: Record<string, unknown> = {};
for (const transportKey of ["kind", "status", "code", "providerCode", "retryAfterMs"] as const) {
for (const transportKey of [
"kind",
"status",
"code",
"providerCode",
"openaiErrorCode",
"anthropicErrorType",
"retryAfterMs",
"headers",
] as const) {
const transportDescriptor = Object.getOwnPropertyDescriptor(descriptor.value, transportKey);
if (!transportDescriptor || !("value" in transportDescriptor)) continue;
try {
Expand Down Expand Up @@ -1147,11 +1157,12 @@ function warnManagedSnapshotFailure(
* cancelled provider attempt is therefore unobservable to sessions and their
* side-effect consumers. Non-managed streams bypass this object entirely.
*/
type ManagedAttemptBatchItem =
| { type: "event"; event: AgentEvent }
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };

class ManagedAttemptTransaction {
#batch: Array<
| { type: "event"; event: AgentEvent }
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
> = [];
#batch: ManagedAttemptBatchItem[] = [];
#stagedEventCount = 0;
#stagedBytes = 0;
/** Shape snapshot retained across discard() for bounded failure diagnostics. */
Expand All @@ -1171,6 +1182,10 @@ class ManagedAttemptTransaction {

push(event: AgentEvent): void {
if (this.#committed) {
if (event.type === "message_end" || event.type === "turn_end") {
this.#batch.push({ type: "event", event });
return;
Comment on lines +1185 to +1187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Settle deferred terminals before retrying Harmony leaks

When a response streams visible text and then triggers an unrecoverable text-surface Harmony leak, the text path marks this transaction committed, so its subsequent message_end is retained here. The abort_retry branch at line 1993 then continues with a new transaction without flushing or discarding this batch, leaving subscribers with a published message_start/message_update lifecycle that never receives message_end and potentially leaving leaked text orphaned in the TUI. Settle the retained terminal—or explicitly reconcile the visible attempt—before taking that retry path.

Useful? React with 👍 / 👎.

}
this.stream.push(event);
return;
}
Expand All @@ -1195,7 +1210,7 @@ class ManagedAttemptTransaction {
}

flush(): void {
if (this.#discarded || this.#committed) return;
if (this.#discarded) return;
for (const item of this.#batch) {
if (item.type === "assistant_event") {
this.onAssistantMessageEvent?.(item.message, item.event);
Expand All @@ -1209,6 +1224,47 @@ class ManagedAttemptTransaction {
this.#committed = true;
}

flushNonTerminal(): void {
if (this.#discarded || this.#committed) return;
const retained: ManagedAttemptBatchItem[] = [];
for (const item of this.#batch) {
if (this.#isTerminalItem(item)) {
retained.push(item);
} else if (item.type === "assistant_event") {
this.onAssistantMessageEvent?.(item.message, item.event);
} else {
this.stream.push(item.event);
}
}
this.#batch = retained;
}

commitCallbacksAndUpdates(): void {
if (this.#discarded || this.#committed) return;
for (const item of this.#batch) {
if (item.type === "assistant_event") {
this.onAssistantMessageEvent?.(item.message, item.event);
} else if (item.event.type !== "message_end" && item.event.type !== "turn_end") {
this.stream.push(item.event);
}
}
this.#batch = this.#batch.filter(
item => item.type === "event" && (item.event.type === "message_end" || item.event.type === "turn_end"),
);
this.#committed = true;
}

replacePendingAssistantMessage(message: AssistantMessage): void {
this.#batch = this.#batch.map(item => {
if (item.type === "assistant_event") {
return { ...item, message, event: this.#assistantEventSnapshot(item.event, message) };
}
if (item.event.type === "message_end") return { ...item, event: { ...item.event, message } };
if (item.event.type === "turn_end") return { ...item, event: { ...item.event, message } };
return item;
});
}

get committed(): boolean {
return this.#committed;
}
Expand Down Expand Up @@ -1270,9 +1326,19 @@ class ManagedAttemptTransaction {
#stage(event: AgentEvent): void {
if (this.snapshotMode === "lossless") {
const snapshot = this.#repairAssistantEvent(event);
let rawBytes: number | undefined;
try {
rawBytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
} catch {
rawBytes = undefined;
}
if (rawBytes !== undefined && this.#wouldOverflow(rawBytes)) {
this.discard();
throw new ManagedAttemptSnapshotError("staging.preMeasure");
}
let detached: AgentEvent;
try {
detached = this.#losslessSnapshot(snapshot);
detached = this.#losslessAgentEventSnapshot(snapshot);
} catch {
this.discard();
throw new ManagedAttemptSnapshotError("staging.losslessSnapshot");
Expand Down Expand Up @@ -1376,6 +1442,28 @@ class ManagedAttemptTransaction {
return losslessDetachedClone(value);
}

#losslessAgentEventSnapshot(event: AgentEvent): AgentEvent {
switch (event.type) {
case "message_start":
case "message_end":
return { ...event, message: this.#losslessSnapshot(event.message) };
case "message_update": {
const message = this.#losslessSnapshot(event.message);
if (message.role !== "assistant") return { ...event, message };
const assistantMessageEvent = this.#assistantEventSnapshot(event.assistantMessageEvent, message);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore partial after detaching assistant events

When a streamed partial contains the live Headers case exercised at this head, #losslessSnapshot(event.assistantMessageEvent) omits partial because cloning that property fails. #assistantEventSnapshot then checks "partial" in snapshot rather than the original event type, so this call publishes text/tool-call updates without their required partial; both message_update.assistantMessageEvent and onAssistantMessageEvent receive an invalid runtime shape. Reattach the detached message based on the original event or its discriminant.

Useful? React with 👍 / 👎.

return { ...event, message, assistantMessageEvent };
}
case "turn_end":
return {
...event,
message: this.#losslessSnapshot(event.message),
toolResults: this.#losslessSnapshot(event.toolResults),
};
default:
return this.#losslessSnapshot(event);
}
}

#assistantSnapshot(message: AssistantMessage): AssistantMessage {
return this.snapshotMode === "lossless"
? this.#losslessSnapshot(message)
Expand All @@ -1387,8 +1475,13 @@ class ManagedAttemptTransaction {
const snapshot = this.#losslessSnapshot(event);
if (snapshot.type === "done") return { ...snapshot, message };
if (snapshot.type === "error") return { ...snapshot, error: message };
if ("partial" in snapshot) return { ...snapshot, partial: message };
return snapshot;
if (snapshot.type === "toolChoiceIncapability") return snapshot;
return { ...snapshot, partial: message };
}

#isTerminalItem(item: ManagedAttemptBatchItem): boolean {
if (item.type === "assistant_event") return item.event.type === "done" || item.event.type === "error";
return item.event.type === "message_end" || item.event.type === "turn_end";
}
}

Expand Down Expand Up @@ -2043,6 +2136,18 @@ async function runLoopBody(
}
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
} else {
if (escapedToolTransaction?.committed) {
const contaminated = currentContext.messages.at(-1);
if (contaminated?.role !== "assistant") throw err;
const sanitized = escapedToolTransaction.acceptedAssistantSnapshot({
...contaminated,
content: [],
stopReason: "aborted",
providerPayload: undefined,
});
escapedToolTransaction.replacePendingAssistantMessage(sanitized);
escapedToolTransaction.flush();
}
if (harmonyRetryAttempt >= 2) {
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
throw new Error(
Expand Down Expand Up @@ -2144,10 +2249,11 @@ async function runLoopBody(
// 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 owns its own retry policy, so this is scoped
// to the non-managed session path, matching the repairs above.
// 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.
if (
!config.fallbackManaged &&
message.stopReason !== "error" &&
message.stopReason !== "aborted" &&
escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
Expand All @@ -2161,6 +2267,24 @@ async function runLoopBody(
// still the tail: callbacks may append user/system history while the
// response settles, and none of that history belongs to this retry.
removeCommittedAssistantMessage(currentContext.messages, message);
// A managed invocation ends the run here and reports the discarded
// attempt to the session's fallback policy through the typed
// 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.
if (config.fallbackManaged) {
transaction?.discard();
currentContext.messages.splice(contextMessageCount);
newMessages.splice(newMessageCount);
await config.onManagedAttemptOutcome?.({
type: "escaped_arguments_discarded",
message,
scope: transaction?.scope,
});
stream.end(newMessages);
return;
}
continue;
}
escapedNonAsciiResampleAttempt = 0;
Expand Down Expand Up @@ -2220,23 +2344,27 @@ async function runLoopBody(
}

// One provider invocation is committed before any tool can run.
transaction?.flush();
if (escapedToolTransaction) {
const acceptedMessage = escapedToolTransaction.acceptedAssistantSnapshot(message);
const acceptedIndex = currentContext.messages.lastIndexOf(message);
if (acceptedIndex >= 0) currentContext.messages[acceptedIndex] = acceptedMessage;
const contextIndex = currentContext.messages.lastIndexOf(message);
if (contextIndex >= 0) currentContext.messages[contextIndex] = acceptedMessage;
const producedIndex = newMessages.lastIndexOf(message);
if (producedIndex >= 0) newMessages[producedIndex] = acceptedMessage;
message = acceptedMessage;
escapedToolTransaction.flushNonTerminal();
// Tool-call updates are staged so an escaped turn can disappear
// atomically. Once accepted, drain every published update through the
// Agent/AgentSession consumers before dispatch: streaming edit guards
// can then abort the run before any tool execute() is entered.
if (message.stopReason !== "aborted" && message.stopReason !== "error") {
if (loopSignal.aborted) break;
if (loopSignal.aborted) message.stopReason = "aborted";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required Unreleased changelog entries

This commit changes shipped behavior in both packages/agent and packages/coding-agent, but it updates neither package changelog, so the provisional-event and cancellation fixes will be omitted from the release notes. Add entries under each affected package's ## [Unreleased] section as required by the repository contract.

AGENTS.md reference: AGENTS.md:L188-L188

Useful? React with 👍 / 👎.

if (stream.hasActiveConsumer) await stream.waitForConsumerDrain(new AbortController().signal);
if (loopSignal.aborted) break;
if (loopSignal.aborted) message.stopReason = "aborted";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Publish the aborted message_end after post-flush cancellation

When an event consumer aborts during waitForConsumerDrain—for example, when the streaming-edit guard rejects an accepted tool call—the transaction has already flushed a detached message_end whose message still has stopReason: "toolUse". Mutating only this separate accepted snapshot afterward does not update that queued event, and Agent appends the stale message to its state in packages/agent/src/agent.ts:1868-1881; the subsequent turn_end and placeholder tool result therefore describe an aborted turn while the public and persisted assistant lifecycle records a normal tool-use completion. Publish or replace the terminal message_end with the aborted snapshot before finalizing the turn.

Useful? React with 👍 / 👎.

}
escapedToolTransaction.replacePendingAssistantMessage(message);
escapedToolTransaction.flush();
Comment on lines +2364 to +2365

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Republish terminals for already-committed transactions

When an accepted tool-call response streamed text first, the text path at agent-loop.ts:2778 has already committed the transaction, so its message_end is published with stopReason: "toolUse" before this drain. If a consumer then aborts while draining a later tool-call update, replacePendingAssistantMessage() has an empty batch and flush() returns because the transaction is committed, while the loop emits aborted placeholder results and turn_end; persisted/public assistant state therefore remains inconsistent. Fresh evidence at this exact head is that the terminal replacement only works for transactions that were never committed by visible text.

Useful? React with 👍 / 👎.

} else {
transaction?.flush();
}
if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
await config.onManagedAttemptAccepted?.();
Expand Down Expand Up @@ -2860,7 +2988,7 @@ async function streamAssistantResponse(
// a later escaped tool call therefore falls through to the
// existing terminal per-call rejection instead.
if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
provisionalToolTransaction?.flush();
provisionalToolTransaction?.commitCallbacksAndUpdates();
}
}
break;
Expand Down
6 changes: 6 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export type ManagedAttemptOutcome =
};
scope?: AttemptScope;
}
| {
type: "escaped_arguments_discarded";
/** The defective assistant turn; already removed from usable history by the loop. */
message: AssistantMessage;
scope?: AttemptScope;
}
| { type: "context_overflow_discarded"; message: AssistantMessage; scope?: AttemptScope }
| { type: "run_terminal"; reason: "cancelled" | "error" | "exhausted"; scope?: AttemptScope };

Expand Down
Loading
Loading