@gajae-code/agent-core 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.15.6] - 2026-08-30
6
+
7
+ ## [0.15.5] - 2026-08-29
8
+
5
9
  ## [0.15.4] - 2026-08-29
6
10
 
7
11
  ### Added
@@ -12,9 +16,10 @@
12
16
 
13
17
  ### Changed
14
18
 
15
- - Escaped non-ASCII tool-call arguments whose every `\uXXXX` escape corroborates a decoded non-ASCII character inside a tool's declared `displaySafeEscapedArgFields` now degrade to a single warning and execute the decoded call, instead of discarding the turn and charging the bounded resample/managed-fallback retry chain. Previously even purely cosmetic display text (e.g. an `ask` question written in Korean or with emoji) burned the full resample budget and then failed the run closed, which made the default Anthropic presets error out on a model-side encoding habit. Raw-evidence corroboration is now per-scalar (offset + process-keyed scalar tag against any decoded non-ASCII character in a declared display field) rather than restricted to U+2014, and literal non-ASCII display text alongside a corroborated escape no longer needs evidence. Load-bearing fields, ASCII-landing escapes, missing/malformed evidence, and non-display-safe tools keep the fail-closed discard-and-reject behavior. (#4983)
19
+ - Current provider adapters no longer mark syntactically valid `\uXXXX` arguments as guarded: JSON parsing has already produced the canonical string, so valid escaped Hangul, emoji, ASCII, and other scalars execute like literal UTF-8 in every tool. The existing `displaySafeEscapedArgFields` path remains only for compatibility with legacy producers that still attach non-malformed positional evidence. Malformed evidence, unauthenticated managed evidence, incomplete/conflicting arguments, and unpaired surrogates remain fail-closed.
16
20
  ### Fixed
17
21
 
22
+ - Managed fallback snapshots now convert unauthenticated Unicode evidence into `incompleteArgumentsReason: "malformed"` instead of preserving a boolean-only guard that could lose the evidence needed by terminal validation.
18
23
  - Compaction no longer crashes on a persisted tool call whose `arguments` payload is null. `serializeConversation` passed that value straight into `Object.entries`, which threw `TypeError: Object.entries requires that input parameter not be null or undefined`. Because this runs inside compaction — itself the recovery path for context overflow — the failure surfaced as `Context overflow recovery failed: Object.entries requires ...`, and the next request went out uncompacted until the provider rejected it with `prompt is too long`. Malformed argument payloads now serialize as an empty argument list instead of aborting the summary.
19
24
 
20
25
  ## [0.15.2] - 2026-08-25
@@ -5,7 +5,7 @@ import { type AssistantMessage, type AssistantMessageEvent, type CursorExecHandl
5
5
  import type { AppendOnlyContextManager } from "./append-only-context";
6
6
  import type { AttemptRunHandle, AttemptScope } from "./attempt-scope";
7
7
  import type { HarmonyAuditEvent } from "./harmony-leak";
8
- import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, ManagedLogicalRunId, RunCancellationDomainBridge, RunResourceLedger, RunTerminalRequest, StreamFn, ToolCallContext } from "./types";
8
+ import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentMetadataResolverContext, AgentState, AgentTool, AgentToolContext, ManagedLogicalRunId, RunCancellationDomainBridge, RunResourceLedger, RunTerminalRequest, StreamFn, ToolCallContext } from "./types";
9
9
  /**
10
10
  * Whether persisted history ends at a point where a new model turn can resume.
11
11
  * Assistant-ended histories require an in-memory queued message and are handled
@@ -271,7 +271,7 @@ export declare class Agent {
271
271
  * only included for `"anthropic"` requests). Falls back to the static
272
272
  * {@link metadata} value when no resolver is set.
273
273
  */
274
- metadataForProvider(provider: string): Record<string, unknown> | undefined;
274
+ metadataForProvider(provider: string, model?: Model, transport?: AgentMetadataResolverContext["transport"]): Record<string, unknown> | undefined;
275
275
  /**
276
276
  * Install a function that resolves request metadata at call time. The
277
277
  * resolver receives the target provider string and can gate provider-specific
@@ -280,7 +280,7 @@ export declare class Agent {
280
280
  * credential. Pass `undefined` to clear and revert to the static
281
281
  * {@link metadata} value.
282
282
  */
283
- setMetadataResolver(resolver: ((provider: string) => Record<string, unknown> | undefined) | undefined): void;
283
+ setMetadataResolver(resolver: ((context: AgentMetadataResolverContext) => Record<string, unknown> | undefined) | undefined): void;
284
284
  /**
285
285
  * Read the active OpenTelemetry configuration. Returns `undefined` when
286
286
  * instrumentation is disabled. Callers spawning child runs (e.g. subagent
@@ -358,6 +358,18 @@ export declare class Agent {
358
358
  setProvisionalAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
359
359
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
360
360
  setShouldPause(fn: AgentLoopConfig["shouldPause"] | undefined): void;
361
+ /** The currently installed cooperative pause checkpoint, if any. */
362
+ get shouldPause(): AgentLoopConfig["shouldPause"] | undefined;
363
+ /**
364
+ * Fence old-turn steering admission.
365
+ *
366
+ * The loop polls steering UPSTREAM of its pause checkpoint (and again on the
367
+ * immediate-interrupt path), so a cooperative stop alone cannot prevent one
368
+ * more old-turn model call once a steering message has already been dequeued.
369
+ * While the fence returns true the poll yields no messages AND does not
370
+ * dequeue, so the queue survives intact for the next turn.
371
+ */
372
+ setSteeringAdmissionFence(fn: (() => boolean) | undefined): void;
361
373
  setMaintainContext(fn: AgentLoopConfig["maintainContext"] | undefined): void;
362
374
  /**
363
375
  * Publish an event produced OUTSIDE the agent loop (a provider that executed the tool
@@ -6,6 +6,20 @@ import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
6
6
  import type { AgentTelemetryConfig } from "./telemetry";
7
7
  /** Stream function - can return sync or Promise for async config lookup */
8
8
  export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
9
+ /**
10
+ * Request context supplied to provider-aware metadata resolvers.
11
+ *
12
+ * The model is the exact model selected for the concrete request (including
13
+ * fallback and ephemeral requests), while `transport` distinguishes the
14
+ * built-in stream path from a caller-supplied stream function. Metadata that
15
+ * carries provider identity must use both values to fail closed when routing
16
+ * is not the canonical provider path.
17
+ */
18
+ export interface AgentMetadataResolverContext {
19
+ provider: string;
20
+ model?: Model;
21
+ transport?: "default" | "custom";
22
+ }
9
23
  /** Stable identifier for a managed logical run, shared by all of its retry attempts. */
10
24
  export type ManagedLogicalRunId = number;
11
25
  /** A resource owned by a prompt run until its promise settles. */
@@ -233,7 +247,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
233
247
  * current when `AgentLoopConfig` was first constructed). Overrides the static
234
248
  * `metadata` field when present.
235
249
  */
236
- metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
250
+ metadataResolver?: (context: AgentMetadataResolverContext) => Record<string, unknown> | undefined;
237
251
  /**
238
252
  * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
239
253
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.15.4",
4
+ "version": "0.15.6",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -32,9 +32,9 @@
32
32
  "fmt": "biome format --write ."
33
33
  },
34
34
  "dependencies": {
35
- "@gajae-code/ai": "0.15.4",
36
- "@gajae-code/natives": "0.15.4",
37
- "@gajae-code/utils": "0.15.4",
35
+ "@gajae-code/ai": "0.15.6",
36
+ "@gajae-code/natives": "0.15.6",
37
+ "@gajae-code/utils": "0.15.6",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  EventStream,
15
15
  isProviderSafetyStopAuthenticated,
16
16
  isZodSchema,
17
+ SERVER_OVERLOADED_PROVIDER_CODE,
17
18
  streamSimple,
18
19
  type ToolChoice,
19
20
  type ToolResultMessage,
@@ -403,12 +404,57 @@ export const ESCAPED_NONASCII_RECOVERY_PROMPT = escapedNonAsciiRecoveryPrompt;
403
404
  const MAX_ESCAPED_NONASCII_RESAMPLES = 2;
404
405
 
405
406
  /** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
407
+ interface EscapedToolCallMetadata {
408
+ guarded: boolean;
409
+ malformed: boolean;
410
+ evidence: UnicodeEscapeEvidence | undefined;
411
+ incompleteArguments: boolean;
412
+ incompleteArgumentsReason: unknown;
413
+ }
414
+
415
+ const acceptedToolCallMetadata = new WeakMap<object, EscapedToolCallMetadata>();
416
+
417
+ function escapedToolCallMetadata(
418
+ block: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
419
+ ): EscapedToolCallMetadata {
420
+ const guardRead = managedOwnPropertyRead(block, "escapedNonAsciiArguments");
421
+ const evidenceRead = managedOwnPropertyRead(block, "escapedUnicodeArgumentEvidence");
422
+ const incompleteRead = managedPropertyRead(block, "incompleteArguments");
423
+ const incompleteReasonRead = managedPropertyRead(block, "incompleteArgumentsReason");
424
+ const inheritedGuard = managedInheritedProperty(block, "escapedNonAsciiArguments");
425
+ const inheritedEvidence = managedInheritedProperty(block, "escapedUnicodeArgumentEvidence");
426
+ let evidence: UnicodeEscapeEvidence | undefined;
427
+ try {
428
+ evidence = managedUnicodeEscapeEvidence(evidenceRead.value);
429
+ } catch {
430
+ evidence = undefined;
431
+ }
432
+ const malformedMetadata =
433
+ !guardRead.ok ||
434
+ !evidenceRead.ok ||
435
+ !incompleteRead.ok ||
436
+ !incompleteReasonRead.ok ||
437
+ inheritedGuard ||
438
+ inheritedEvidence ||
439
+ (typeof incompleteRead.value === "string" && SANITIZER_SENTINELS.has(incompleteRead.value)) ||
440
+ (typeof incompleteReasonRead.value === "string" && SANITIZER_SENTINELS.has(incompleteReasonRead.value)) ||
441
+ (evidenceRead.present && (evidenceRead.value === undefined || evidence === undefined || evidence.malformed));
442
+ const incomplete = incompleteRead.ok && incompleteRead.value === true;
443
+ return {
444
+ guarded:
445
+ malformedMetadata ||
446
+ (guardRead.present && guardRead.value === true) ||
447
+ (evidenceRead.present && evidenceRead.value !== undefined),
448
+ malformed: malformedMetadata || incomplete,
449
+ evidence,
450
+ incompleteArguments: incomplete,
451
+ incompleteArgumentsReason:
452
+ !incompleteReasonRead.ok || malformedMetadata ? "malformed" : incompleteReasonRead.value,
453
+ };
454
+ }
455
+
406
456
  function hasEscapedNonAsciiToolCall(message: AssistantMessage): boolean {
407
- return message.content.some(
408
- block =>
409
- block.type === "toolCall" &&
410
- (block.escapedNonAsciiArguments === true || block.escapedUnicodeArgumentEvidence !== undefined),
411
- );
457
+ return message.content.some(block => block.type === "toolCall" && escapedToolCallMetadata(block).guarded);
412
458
  }
413
459
 
414
460
  const ESCAPED_NONASCII_DIAGNOSTIC_TOOL_CALL_COUNT_MAX = 8;
@@ -419,9 +465,7 @@ function escapedNonAsciiToolCallShape(message: AssistantMessage): {
419
465
  escapedToolCallCountCapped: boolean;
420
466
  } {
421
467
  const count = message.content.filter(
422
- block =>
423
- block.type === "toolCall" &&
424
- (block.escapedNonAsciiArguments === true || block.escapedUnicodeArgumentEvidence !== undefined),
468
+ block => block.type === "toolCall" && escapedToolCallMetadata(block).guarded,
425
469
  ).length;
426
470
  return {
427
471
  escapedToolCallCount: Math.min(count, ESCAPED_NONASCII_DIAGNOSTIC_TOOL_CALL_COUNT_MAX),
@@ -430,9 +474,53 @@ function escapedNonAsciiToolCallShape(message: AssistantMessage): {
430
474
  }
431
475
 
432
476
  /** Remove transient raw-evidence metadata before any message can become durable. */
477
+ function stripToolCallEvidence<T extends { escapedUnicodeArgumentEvidence?: unknown }>(toolCall: T): T {
478
+ try {
479
+ if (nodeUtilTypes.isProxy(toolCall)) {
480
+ const sanitized = Object.create(null) as T;
481
+ for (const key of [
482
+ "type",
483
+ "id",
484
+ "name",
485
+ "arguments",
486
+ "thoughtSignature",
487
+ "intent",
488
+ "customWireName",
489
+ "escapedNonAsciiArguments",
490
+ "incompleteArguments",
491
+ "incompleteArgumentsReason",
492
+ ]) {
493
+ const read = managedPropertyRead(toolCall, key);
494
+ if (read.ok && read.value !== undefined)
495
+ Object.defineProperty(sanitized, key, { value: read.value, enumerable: true });
496
+ }
497
+ return sanitized;
498
+ }
499
+ const descriptor = Object.getOwnPropertyDescriptor(toolCall, "escapedUnicodeArgumentEvidence");
500
+ if (!descriptor) return toolCall;
501
+ if (descriptor.configurable && Reflect.deleteProperty(toolCall, "escapedUnicodeArgumentEvidence"))
502
+ return toolCall;
503
+ const sanitized = Object.create(null) as T;
504
+ for (const key of Reflect.ownKeys(toolCall)) {
505
+ if (key === "escapedUnicodeArgumentEvidence") continue;
506
+ const own = Object.getOwnPropertyDescriptor(toolCall, key);
507
+ if (!own?.enumerable || !("value" in own)) continue;
508
+ try {
509
+ Object.defineProperty(sanitized, key, own);
510
+ } catch {
511
+ // Skip hostile descriptors; required fields are already captured in the record.
512
+ }
513
+ }
514
+ return sanitized;
515
+ } catch {
516
+ return Object.create(null) as T;
517
+ }
518
+ }
519
+
433
520
  function stripUnicodeEscapeEvidence(message: AssistantMessage): void {
434
- for (const block of message.content) {
435
- if (block.type === "toolCall") delete block.escapedUnicodeArgumentEvidence;
521
+ for (let index = 0; index < message.content.length; index += 1) {
522
+ const block = message.content[index];
523
+ if (block?.type === "toolCall") message.content[index] = stripToolCallEvidence(block);
436
524
  }
437
525
  }
438
526
 
@@ -606,14 +694,13 @@ function allEscapedToolCallsDisplaySafe(
606
694
  let sawEscapedCall = false;
607
695
  for (const block of message.content) {
608
696
  if (block.type !== "toolCall") continue;
609
- if (block.escapedNonAsciiArguments !== true && block.escapedUnicodeArgumentEvidence === undefined) continue;
697
+ const metadata = escapedToolCallMetadata(block);
698
+ if (!metadata.guarded) continue;
699
+ if (metadata.malformed) return false;
610
700
  sawEscapedCall = true;
611
701
  const tool = tools?.find(candidate => candidate.name === block.name);
612
702
  const args = block.arguments as Record<string, unknown>;
613
- if (
614
- !isDisplaySafeEscapedArguments(tool, args) ||
615
- !isDisplaySafeRawEscapeEvidence(tool, args, block.escapedUnicodeArgumentEvidence)
616
- )
703
+ if (!isDisplaySafeEscapedArguments(tool, args) || !isDisplaySafeRawEscapeEvidence(tool, args, metadata.evidence))
617
704
  return false;
618
705
  }
619
706
  return sawEscapedCall;
@@ -656,6 +743,25 @@ function managedProperty(value: unknown, key: string): unknown {
656
743
  return managedPropertyRead(value, key).value;
657
744
  }
658
745
 
746
+ function managedOwnPropertyRead(value: unknown, key: string): { present: boolean; ok: boolean; value: unknown } {
747
+ if (!value || typeof value !== "object") return { present: false, ok: true, value: undefined };
748
+ try {
749
+ if (!Object.hasOwn(value, key)) return { present: false, ok: true, value: undefined };
750
+ return { present: true, ok: true, value: Reflect.get(value, key) };
751
+ } catch {
752
+ return { present: true, ok: false, value: undefined };
753
+ }
754
+ }
755
+
756
+ function managedInheritedProperty(value: unknown, key: string): boolean {
757
+ if (!value || typeof value !== "object") return false;
758
+ try {
759
+ return !Object.hasOwn(value, key) && key in value;
760
+ } catch {
761
+ return true;
762
+ }
763
+ }
764
+
659
765
  function managedTransportFailure(failure: unknown) {
660
766
  const facts = managedProperty(failure, "transportFailure");
661
767
  return facts && typeof facts === "object" ? transportFailureFacts(facts) : undefined;
@@ -678,6 +784,19 @@ function isManagedProviderSafetyStopAuthenticated(value: unknown): boolean {
678
784
  function managedRetryableFailure(failure: unknown): boolean {
679
785
  const facts = managedTransportFailure(failure);
680
786
  if (!facts) return false;
787
+ // OpenAI's typed statusless capacity-overload code (issue #5018) never
788
+ // becomes managed transaction authority. Before the code survived as
789
+ // transport facts this failure produced none, so the staged attempt was
790
+ // always committed; the shared Responses parser and Codex events now carry
791
+ // it, and this check preserves that committed-failure behavior instead of
792
+ // discarding the transaction. It reads only typed facts, never error text.
793
+ if (
794
+ facts.status === undefined &&
795
+ facts.providerCode === SERVER_OVERLOADED_PROVIDER_CODE &&
796
+ (facts.openaiErrorCode === undefined || facts.openaiErrorCode === SERVER_OVERLOADED_PROVIDER_CODE)
797
+ ) {
798
+ return false;
799
+ }
681
800
  // A typed provider safety stop is terminal evidence ahead of any transport
682
801
  // class, but only with adapter-minted provenance: unauthenticated labels
683
802
  // are stripped at the stream exit (`sanitizeProviderSafetyStopProvenance`)
@@ -748,6 +867,12 @@ function sanitizeProviderSafetyStopProvenance(
748
867
  if (isManagedPlainRecord(detached)) {
749
868
  const rebuilt = { ...detached } as AssistantMessage;
750
869
  delete rebuilt.errorKind;
870
+ if (!Array.isArray(rebuilt.content)) {
871
+ const repaired = managedAssistantShell(message, model);
872
+ delete repaired.errorKind;
873
+ return repaired;
874
+ }
875
+ restoreTransientUnicodeEscapeEvidence(rebuilt.content, message);
751
876
  return rebuilt;
752
877
  }
753
878
  const rebuilt = managedAssistantShell(message, model);
@@ -1947,7 +2072,11 @@ function managedAssistantShell(
1947
2072
  // `message.role === "assistant"` check passes while the detached
1948
2073
  // snapshot retains none of the message identity.
1949
2074
  const source =
1950
- snapshotRecord !== undefined && managedProperty(snapshotRecord, "role") === "assistant" ? snapshotRecord : value;
2075
+ snapshotRecord !== undefined &&
2076
+ managedProperty(snapshotRecord, "role") === "assistant" &&
2077
+ (managedProperty(snapshotRecord, "content") !== undefined || managedProperty(value, "content") === undefined)
2078
+ ? snapshotRecord
2079
+ : value;
1951
2080
  if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError("shell.role");
1952
2081
  const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
1953
2082
  // Providers may deliver `content` as a string, missing value, or a primitive
@@ -2048,11 +2177,18 @@ function managedContentBlock(block: unknown): AssistantMessage["content"] {
2048
2177
 
2049
2178
  function managedUnicodeEscapeEvidence(value: unknown): UnicodeEscapeEvidence | undefined {
2050
2179
  if (!isManagedPlainRecord(value)) return undefined;
2051
- const positionsValue = managedProperty(value, "positions");
2052
- const totalPositions = managedProperty(value, "totalPositions");
2053
- const truncated = managedProperty(value, "truncated");
2054
- const malformed = managedProperty(value, "malformed");
2055
- const integrity = managedProperty(value, "integrity");
2180
+ const envelopeReads = Object.fromEntries(
2181
+ ["positions", "totalPositions", "truncated", "malformed", "integrity"].map(key => [
2182
+ key,
2183
+ managedOwnPropertyRead(value, key),
2184
+ ]),
2185
+ ) as Record<string, { present: boolean; ok: boolean; value: unknown }>;
2186
+ if (Object.values(envelopeReads).some(read => !read.present || !read.ok)) return undefined;
2187
+ const positionsValue = envelopeReads.positions!.value;
2188
+ const totalPositions = envelopeReads.totalPositions!.value;
2189
+ const truncated = envelopeReads.truncated!.value;
2190
+ const malformed = envelopeReads.malformed!.value;
2191
+ const integrity = envelopeReads.integrity!.value;
2056
2192
  if (!Array.isArray(positionsValue) || positionsValue.length > 32) return undefined;
2057
2193
  if (
2058
2194
  typeof totalPositions !== "number" ||
@@ -2067,12 +2203,19 @@ function managedUnicodeEscapeEvidence(value: unknown): UnicodeEscapeEvidence | u
2067
2203
  const positions: UnicodeEscapeEvidence["positions"][number][] = [];
2068
2204
  for (const positionValue of positionsValue) {
2069
2205
  if (!isManagedPlainRecord(positionValue)) return undefined;
2070
- const offset = managedProperty(positionValue, "offset");
2071
- const scalarTag = managedProperty(positionValue, "scalarTag");
2072
- const pathTag = managedProperty(positionValue, "pathTag");
2073
- const location = managedProperty(positionValue, "location");
2074
- const valueOrdinal = managedProperty(positionValue, "valueOrdinal");
2075
- const valueOffset = managedProperty(positionValue, "valueOffset");
2206
+ const positionReads = Object.fromEntries(
2207
+ ["offset", "scalarTag", "pathTag", "location", "valueOrdinal", "valueOffset"].map(key => [
2208
+ key,
2209
+ managedOwnPropertyRead(positionValue, key),
2210
+ ]),
2211
+ ) as Record<string, { present: boolean; ok: boolean; value: unknown }>;
2212
+ if (Object.values(positionReads).some(read => !read.present || !read.ok)) return undefined;
2213
+ const offset = positionReads.offset!.value;
2214
+ const scalarTag = positionReads.scalarTag!.value;
2215
+ const pathTag = positionReads.pathTag!.value;
2216
+ const location = positionReads.location!.value;
2217
+ const valueOrdinal = positionReads.valueOrdinal!.value;
2218
+ const valueOffset = positionReads.valueOffset!.value;
2076
2219
  if (
2077
2220
  typeof offset !== "number" ||
2078
2221
  !Number.isSafeInteger(offset) ||
@@ -2093,14 +2236,15 @@ function managedUnicodeEscapeEvidence(value: unknown): UnicodeEscapeEvidence | u
2093
2236
  }
2094
2237
  positions.push({ offset, scalarTag, pathTag, location, valueOrdinal, valueOffset });
2095
2238
  }
2096
- return { positions, totalPositions, truncated, malformed, integrity };
2239
+ const evidence = { positions, totalPositions, truncated, malformed, integrity };
2240
+ return verifyUnicodeEscapeEvidence(evidence) ? evidence : undefined;
2097
2241
  }
2098
2242
 
2099
2243
  function restoreTransientUnicodeEscapeEvidence(content: AssistantMessage["content"], liveMessage: unknown): void {
2100
2244
  const liveContent = managedProperty(liveMessage, "content");
2101
2245
  if (!Array.isArray(liveContent)) return;
2102
2246
  for (const destination of content) {
2103
- if (destination.type !== "toolCall") continue;
2247
+ if (!destination || typeof destination !== "object" || destination.type !== "toolCall") continue;
2104
2248
  const matches = liveContent.filter(
2105
2249
  candidate =>
2106
2250
  isManagedPlainRecord(candidate) &&
@@ -2108,12 +2252,52 @@ function restoreTransientUnicodeEscapeEvidence(content: AssistantMessage["conten
2108
2252
  managedProperty(candidate, "id") === destination.id &&
2109
2253
  managedProperty(candidate, "name") === destination.name,
2110
2254
  );
2111
- if (matches.length !== 1) continue;
2112
- const rawEvidence = managedProperty(matches[0], "escapedUnicodeArgumentEvidence");
2113
- if (rawEvidence === undefined) continue;
2114
- const evidence = managedUnicodeEscapeEvidence(rawEvidence);
2115
- if (evidence) attachUnicodeEscapeEvidence(destination, evidence);
2116
- else destination.escapedNonAsciiArguments = true;
2255
+ const evidenceReads = matches.map(candidate =>
2256
+ managedOwnPropertyRead(candidate, "escapedUnicodeArgumentEvidence"),
2257
+ );
2258
+ const inheritedEvidence = matches.map(candidate =>
2259
+ managedInheritedProperty(candidate, "escapedUnicodeArgumentEvidence"),
2260
+ );
2261
+ const guardReads = matches.map(candidate => managedOwnPropertyRead(candidate, "escapedNonAsciiArguments"));
2262
+ const inheritedGuards = matches.map(candidate => managedInheritedProperty(candidate, "escapedNonAsciiArguments"));
2263
+ if (matches.length !== 1) {
2264
+ if (
2265
+ evidenceReads.some(read => read.present) ||
2266
+ inheritedEvidence.some(Boolean) ||
2267
+ guardReads.some(read => read.present) ||
2268
+ inheritedGuards.some(Boolean)
2269
+ ) {
2270
+ destination.incompleteArguments = true;
2271
+ destination.incompleteArgumentsReason = "malformed";
2272
+ }
2273
+ continue;
2274
+ }
2275
+ const guardRead = guardReads[0]!;
2276
+ if (!guardRead.ok || inheritedGuards[0]) {
2277
+ destination.incompleteArguments = true;
2278
+ destination.incompleteArgumentsReason = "malformed";
2279
+ }
2280
+ if (guardRead.ok && guardRead.present && guardRead.value === true) destination.escapedNonAsciiArguments = true;
2281
+ const evidenceRead = evidenceReads[0]!;
2282
+ if (!evidenceRead.present || !evidenceRead.ok || evidenceRead.value === undefined || inheritedEvidence[0]) {
2283
+ if (evidenceRead.present || inheritedEvidence[0]) {
2284
+ destination.incompleteArguments = true;
2285
+ destination.incompleteArgumentsReason = "malformed";
2286
+ }
2287
+ continue;
2288
+ }
2289
+ const rawEvidence = evidenceRead.value;
2290
+ let evidence: UnicodeEscapeEvidence | undefined;
2291
+ try {
2292
+ evidence = managedUnicodeEscapeEvidence(rawEvidence);
2293
+ } catch {
2294
+ evidence = undefined;
2295
+ }
2296
+ if (evidence && !evidence.malformed) attachUnicodeEscapeEvidence(destination, evidence);
2297
+ else {
2298
+ destination.incompleteArguments = true;
2299
+ destination.incompleteArgumentsReason = "malformed";
2300
+ }
2117
2301
  }
2118
2302
  }
2119
2303
 
@@ -2140,12 +2324,47 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
2140
2324
  const thoughtSignature = managedProperty(value, "thoughtSignature");
2141
2325
  const intent = managedProperty(value, "intent");
2142
2326
  const customWireName = managedProperty(value, "customWireName");
2143
- const incompleteArguments = managedProperty(value, "incompleteArguments");
2144
- const incompleteArgumentsReason = managedProperty(value, "incompleteArgumentsReason");
2145
- const escapedNonAsciiArguments = managedProperty(value, "escapedNonAsciiArguments");
2146
- const rawEscapedUnicodeArgumentEvidence = managedProperty(value, "escapedUnicodeArgumentEvidence");
2147
- const escapedUnicodeArgumentEvidence = managedUnicodeEscapeEvidence(rawEscapedUnicodeArgumentEvidence);
2148
- const escapedArgumentsGuarded = escapedNonAsciiArguments === true || rawEscapedUnicodeArgumentEvidence !== undefined;
2327
+ const incompleteArgumentsRead = managedPropertyRead(value, "incompleteArguments");
2328
+ const incompleteArgumentsReasonRead = managedPropertyRead(value, "incompleteArgumentsReason");
2329
+ const escapedGuardRead = managedOwnPropertyRead(value, "escapedNonAsciiArguments");
2330
+ const evidenceRead = managedOwnPropertyRead(value, "escapedUnicodeArgumentEvidence");
2331
+ const inheritedGuard = managedInheritedProperty(value, "escapedNonAsciiArguments");
2332
+ const inheritedEvidence = managedInheritedProperty(value, "escapedUnicodeArgumentEvidence");
2333
+ const inheritedIncompleteArguments = managedInheritedProperty(value, "incompleteArguments");
2334
+ const inheritedIncompleteArgumentsReason = managedInheritedProperty(value, "incompleteArgumentsReason");
2335
+ const incompleteArguments = incompleteArgumentsRead.value;
2336
+ const incompleteArgumentsReason = incompleteArgumentsReasonRead.value;
2337
+ const incompleteMetadataSentinel =
2338
+ (typeof incompleteArguments === "string" && SANITIZER_SENTINELS.has(incompleteArguments)) ||
2339
+ (typeof incompleteArgumentsReason === "string" && SANITIZER_SENTINELS.has(incompleteArgumentsReason));
2340
+ const incompleteMetadataMalformed =
2341
+ !incompleteArgumentsRead.ok ||
2342
+ !incompleteArgumentsReasonRead.ok ||
2343
+ inheritedIncompleteArguments ||
2344
+ inheritedIncompleteArgumentsReason ||
2345
+ incompleteMetadataSentinel;
2346
+ const escapedNonAsciiArguments = escapedGuardRead.value;
2347
+ const rawEscapedUnicodeArgumentEvidence = evidenceRead.value;
2348
+ let escapedUnicodeArgumentEvidence: UnicodeEscapeEvidence | undefined;
2349
+ try {
2350
+ escapedUnicodeArgumentEvidence = managedUnicodeEscapeEvidence(rawEscapedUnicodeArgumentEvidence);
2351
+ } catch {
2352
+ escapedUnicodeArgumentEvidence = undefined;
2353
+ }
2354
+ const invalidEscapedUnicodeEvidence =
2355
+ (evidenceRead.present &&
2356
+ (!evidenceRead.ok ||
2357
+ rawEscapedUnicodeArgumentEvidence === undefined ||
2358
+ escapedUnicodeArgumentEvidence === undefined ||
2359
+ escapedUnicodeArgumentEvidence.malformed)) ||
2360
+ !escapedGuardRead.ok ||
2361
+ inheritedGuard ||
2362
+ inheritedEvidence ||
2363
+ incompleteMetadataMalformed;
2364
+ const escapedArgumentsGuarded =
2365
+ invalidEscapedUnicodeEvidence ||
2366
+ (escapedGuardRead.present && escapedGuardRead.ok && escapedNonAsciiArguments === true) ||
2367
+ (evidenceRead.present && evidenceRead.ok && escapedUnicodeArgumentEvidence !== undefined);
2149
2368
  return {
2150
2369
  type,
2151
2370
  id,
@@ -2154,16 +2373,22 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
2154
2373
  ...(typeof thoughtSignature === "string" ? { thoughtSignature } : {}),
2155
2374
  ...(typeof intent === "string" ? { intent } : {}),
2156
2375
  ...(typeof customWireName === "string" ? { customWireName } : {}),
2157
- ...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
2158
- ...(typeof incompleteArgumentsReason === "string"
2159
- ? {
2160
- incompleteArgumentsReason: incompleteArgumentsReason as
2161
- | "truncated"
2162
- | "malformed"
2163
- | "conflicting"
2164
- | "ambiguous",
2165
- }
2166
- : {}),
2376
+ ...(invalidEscapedUnicodeEvidence
2377
+ ? { incompleteArguments: true }
2378
+ : typeof incompleteArguments === "boolean"
2379
+ ? { incompleteArguments }
2380
+ : {}),
2381
+ ...(invalidEscapedUnicodeEvidence
2382
+ ? { incompleteArgumentsReason: "malformed" as const }
2383
+ : typeof incompleteArgumentsReason === "string"
2384
+ ? {
2385
+ incompleteArgumentsReason: incompleteArgumentsReason as
2386
+ | "truncated"
2387
+ | "malformed"
2388
+ | "conflicting"
2389
+ | "ambiguous",
2390
+ }
2391
+ : {}),
2167
2392
  ...(escapedArgumentsGuarded
2168
2393
  ? { escapedNonAsciiArguments: true }
2169
2394
  : typeof escapedNonAsciiArguments === "boolean"
@@ -2582,7 +2807,57 @@ class ManagedAttemptTransaction {
2582
2807
  }
2583
2808
 
2584
2809
  acceptedAssistantSnapshot(message: AssistantMessage): AssistantMessage {
2585
- return this.#assistantSnapshot(message);
2810
+ const sourceContent = Array.isArray(message.content) ? message.content : [];
2811
+ const sourceMetadata: Array<EscapedToolCallMetadata | undefined> = [];
2812
+ for (let index = 0; index < sourceContent.length; index += 1) {
2813
+ const block = sourceContent[index];
2814
+ sourceMetadata[index] = block?.type === "toolCall" ? escapedToolCallMetadata(block) : undefined;
2815
+ }
2816
+ let snapshot = this.#assistantSnapshot(message);
2817
+ if (snapshot.role !== "assistant") return snapshot;
2818
+ if (!Array.isArray(snapshot.content)) {
2819
+ snapshot = managedAssistantShell(message, this.model, this.#degradedFieldDiagnostics, true);
2820
+ }
2821
+ for (let index = 0; index < sourceContent.length; index += 1) {
2822
+ const sourceBlock = sourceContent[index];
2823
+ if (sourceBlock?.type !== "toolCall") continue;
2824
+ const metadata = sourceMetadata[index];
2825
+ let snapshotBlock = snapshot.content[index];
2826
+ if (snapshotBlock?.type !== "toolCall") {
2827
+ const normalized = managedAssistantContent(sourceBlock);
2828
+ if (normalized?.type !== "toolCall") continue;
2829
+ snapshotBlock = normalized;
2830
+ snapshot.content[index] = snapshotBlock;
2831
+ }
2832
+ if (metadata) {
2833
+ const detachedMetadata = escapedToolCallMetadata(snapshotBlock);
2834
+ const evidencePresenceChanged = Boolean(metadata.evidence) !== Boolean(detachedMetadata.evidence);
2835
+ const metadataChanged =
2836
+ metadata.guarded !== detachedMetadata.guarded ||
2837
+ metadata.incompleteArguments !== detachedMetadata.incompleteArguments ||
2838
+ evidencePresenceChanged;
2839
+ const combinedMetadata: EscapedToolCallMetadata = {
2840
+ guarded: metadata.guarded || detachedMetadata.guarded || metadataChanged,
2841
+ malformed: metadata.malformed || detachedMetadata.malformed || metadataChanged,
2842
+ evidence: metadata.evidence ?? detachedMetadata.evidence,
2843
+ incompleteArguments: metadata.incompleteArguments || detachedMetadata.incompleteArguments,
2844
+ incompleteArgumentsReason:
2845
+ metadata.incompleteArgumentsReason ?? detachedMetadata.incompleteArgumentsReason,
2846
+ };
2847
+ if (combinedMetadata.malformed) {
2848
+ const marked = {
2849
+ ...snapshotBlock,
2850
+ incompleteArguments: true,
2851
+ incompleteArgumentsReason: "malformed" as const,
2852
+ };
2853
+ snapshot.content[index] = marked;
2854
+ acceptedToolCallMetadata.set(marked, combinedMetadata);
2855
+ } else {
2856
+ acceptedToolCallMetadata.set(snapshotBlock, combinedMetadata);
2857
+ }
2858
+ }
2859
+ }
2860
+ return snapshot;
2586
2861
  }
2587
2862
 
2588
2863
  discard(): void {
@@ -4248,7 +4523,13 @@ async function streamAssistantResponse(
4248
4523
  // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
4249
4524
  const authCredentialType = config.getAuthCredentialType?.(config.model.provider);
4250
4525
 
4251
- const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
4526
+ const resolvedMetadata = config.metadataResolver
4527
+ ? config.metadataResolver({
4528
+ provider: config.model.provider,
4529
+ model: config.model,
4530
+ transport: streamFunction === streamSimple ? "default" : "custom",
4531
+ })
4532
+ : config.metadata;
4252
4533
 
4253
4534
  // Synthetic recovery requests choose their tool mode explicitly below and
4254
4535
  // must never consume a queued dynamic choice intended for an ordinary turn.
@@ -4751,20 +5032,23 @@ async function executeToolCalls(
4751
5032
  let steeringMessages: AgentMessage[] | undefined;
4752
5033
  let steeringCheck: Promise<void> | null = null;
4753
5034
 
4754
- const records = toolCalls.map(toolCall => ({
4755
- toolCall,
4756
- tool: findActiveTool(tools, toolCall.name),
4757
- args: toolCall.arguments as Record<string, unknown>,
4758
- eventFields: undefined as { toolCallId: string; toolName: string; intent: string | undefined } | undefined,
4759
- started: false,
4760
- result: undefined as AgentToolResult<any> | undefined,
4761
- isError: false,
4762
- skipped: false,
4763
- toolResultMessage: undefined as ToolResultMessage | undefined,
4764
- resultEmitted: false,
4765
- argumentValidationFailed: false,
4766
- }));
4767
-
5035
+ const records = toolCalls.map(toolCall => {
5036
+ const metadata = acceptedToolCallMetadata.get(toolCall) ?? escapedToolCallMetadata(toolCall);
5037
+ return {
5038
+ toolCall: stripToolCallEvidence(toolCall),
5039
+ metadata,
5040
+ tool: findActiveTool(tools, toolCall.name),
5041
+ args: toolCall.arguments as Record<string, unknown>,
5042
+ eventFields: undefined as { toolCallId: string; toolName: string; intent: string | undefined } | undefined,
5043
+ started: false,
5044
+ result: undefined as AgentToolResult<any> | undefined,
5045
+ isError: false,
5046
+ skipped: false,
5047
+ toolResultMessage: undefined as ToolResultMessage | undefined,
5048
+ resultEmitted: false,
5049
+ argumentValidationFailed: false,
5050
+ };
5051
+ });
4768
5052
  const checkSteering = async (): Promise<void> => {
4769
5053
  // Never consume steering once the run's own signal is aborted: an aborted
4770
5054
  // run cannot deliver it (the loop hands drained steering back and ends), and
@@ -4792,6 +5076,7 @@ async function executeToolCalls(
4792
5076
 
4793
5077
  const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
4794
5078
  if (record.resultEmitted) return;
5079
+ record.toolCall = stripToolCallEvidence(record.toolCall);
4795
5080
  const { toolCall } = record;
4796
5081
  const eventFields =
4797
5082
  record.eventFields ?? ({ toolCallId: toolCall.id, toolName: toolCall.name, intent: toolCall.intent } as const);
@@ -4845,6 +5130,24 @@ async function executeToolCalls(
4845
5130
  stream.push({ type: "message_start", message: toolResultMessage, scope });
4846
5131
  stream.push({ type: "message_end", message: toolResultMessage, scope });
4847
5132
  };
5133
+ const isInvalidEscapedRecord = (record: (typeof records)[number]): boolean => {
5134
+ const metadata = record.metadata;
5135
+ if (metadata.malformed) return true;
5136
+ if (!metadata.guarded) return false;
5137
+ return !(
5138
+ isDisplaySafeEscapedArguments(record.tool, record.args) &&
5139
+ isDisplaySafeRawEscapeEvidence(record.tool, record.args, metadata.evidence)
5140
+ );
5141
+ };
5142
+ const hasInvalidEscapedCall = records.some(isInvalidEscapedRecord);
5143
+ if (hasInvalidEscapedCall) {
5144
+ for (const record of records) {
5145
+ if (isInvalidEscapedRecord(record)) continue;
5146
+ record.skipped = true;
5147
+ record.toolCall = stripToolCallEvidence(record.toolCall);
5148
+ emitToolResult(record, createSkippedToolResult(), true);
5149
+ }
5150
+ }
4848
5151
 
4849
5152
  /**
4850
5153
  * Prepare every value needed to publish and invoke one dispatch before claiming that it
@@ -4903,7 +5206,7 @@ async function executeToolCalls(
4903
5206
  };
4904
5207
 
4905
5208
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
4906
- if (interruptState.triggered) {
5209
+ if (record.skipped || interruptState.triggered) {
4907
5210
  // Skip both span emission and the collector orphan record here. The
4908
5211
  // scheduler-task finalizer emits the skipped result and collector record;
4909
5212
  // the tail sweep below remains a defensive fallback for unexpected throws.
@@ -4911,6 +5214,7 @@ async function executeToolCalls(
4911
5214
  return;
4912
5215
  }
4913
5216
 
5217
+ record.toolCall = stripToolCallEvidence(record.toolCall);
4914
5218
  const { toolCall, tool } = record;
4915
5219
  let argsForExecution = toolCall.arguments as Record<string, unknown>;
4916
5220
  if (intentTracing) {
@@ -4948,16 +5252,17 @@ async function executeToolCalls(
4948
5252
 
4949
5253
  await runInActiveSpan(toolSpan, async () => {
4950
5254
  try {
4951
- const escapedUnicodeArgumentEvidence = toolCall.escapedUnicodeArgumentEvidence;
4952
- const escapedArgumentsGuarded =
4953
- toolCall.escapedNonAsciiArguments || escapedUnicodeArgumentEvidence !== undefined;
4954
- if (escapedArgumentsGuarded) delete toolCall.escapedUnicodeArgumentEvidence;
4955
- if (toolCall.incompleteArguments) {
5255
+ const metadata = record.metadata;
5256
+ const escapedUnicodeArgumentEvidence = metadata.evidence;
5257
+ const escapedArgumentsGuarded = metadata.guarded;
5258
+ if (escapedArgumentsGuarded) record.toolCall = stripToolCallEvidence(record.toolCall);
5259
+ const incompleteArguments = metadata.malformed || metadata.incompleteArguments;
5260
+ if (incompleteArguments) {
4956
5261
  record.argumentValidationFailed = true;
4957
5262
  // The provider flagged this call's arguments as unsafe to execute.
4958
5263
  // The typed reason selects accurate recovery guidance; callers that
4959
5264
  // only read the boolean still get a safe, actionable rejection.
4960
- const reason = toolCall.incompleteArgumentsReason;
5265
+ const reason = metadata.incompleteArgumentsReason;
4961
5266
  const detail =
4962
5267
  reason === "malformed"
4963
5268
  ? `The terminal arguments for tool call "${toolCall.name}" did not decode to a valid JSON object. The arguments cannot be executed. Re-issue the call with valid, complete arguments.`
@@ -5265,6 +5570,7 @@ function createAbortedToolResult(
5265
5570
  reason: "aborted" | "error",
5266
5571
  errorMessage?: string,
5267
5572
  ): ToolResultMessage {
5573
+ toolCall = stripToolCallEvidence(toolCall);
5268
5574
  const message = reason === "aborted" ? "Tool execution was aborted" : "Tool execution failed due to an error";
5269
5575
  const result: AgentToolResult<any> = {
5270
5576
  content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
package/src/agent.ts CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
  AgentEvent,
39
39
  AgentLoopConfig,
40
40
  AgentMessage,
41
+ AgentMetadataResolverContext,
41
42
  AgentState,
42
43
  AgentTool,
43
44
  AgentToolContext,
@@ -67,6 +68,9 @@ const RUNTIME_FAILURE_CODES = new Set([
67
68
  "local_snapshot_failure",
68
69
  "provider_down",
69
70
  "provider_unavailable",
71
+ "provider_rejected",
72
+ "provider_http_402",
73
+ "provider_http_429",
70
74
  "upstream_stream_interrupted",
71
75
  "argument_validation",
72
76
  "execution",
@@ -120,13 +124,21 @@ function safeErrorStatus(error: unknown): number | undefined {
120
124
  try {
121
125
  return (
122
126
  extractHttpStatusFromError({ status: (error as { errorStatus?: unknown } | undefined)?.errorStatus }) ??
123
- extractHttpStatusFromError(error)
127
+ extractHttpStatusFromError(error) ??
128
+ extractHttpStatusFromError((error as { transportFailure?: unknown } | undefined)?.transportFailure)
124
129
  );
125
130
  } catch {
126
131
  return undefined;
127
132
  }
128
133
  }
129
134
 
135
+ function providerFailureCode(error: unknown): string | undefined {
136
+ const status = safeErrorStatus(error);
137
+ if (status === undefined) return undefined;
138
+ if (status === 402 || status === 429) return `provider_http_${status}`;
139
+ return "provider_rejected";
140
+ }
141
+
130
142
  function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[]): void {
131
143
  for (const message of messages) {
132
144
  if (!("role" in message) || message.role !== "user") continue;
@@ -455,7 +467,7 @@ export class Agent {
455
467
  #sessionId?: string;
456
468
  #providerSessionId?: string;
457
469
  #metadata?: Record<string, unknown>;
458
- #metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
470
+ #metadataResolver?: (context: AgentMetadataResolverContext) => Record<string, unknown> | undefined;
459
471
  #providerSessionState?: Map<string, ProviderSessionState>;
460
472
  #thinkingBudgets?: ThinkingBudgets;
461
473
  #temperature?: number;
@@ -494,6 +506,8 @@ export class Agent {
494
506
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
495
507
  #onBeforeYield?: () => Promise<void> | void;
496
508
  #shouldPause?: AgentLoopConfig["shouldPause"];
509
+ /** While set and returning true, steering is neither admitted nor dequeued. */
510
+ #steeringAdmissionFence?: () => boolean;
497
511
  #maintainContext?: AgentLoopConfig["maintainContext"];
498
512
  #telemetry?: AgentLoopConfig["telemetry"];
499
513
  #appendOnlyContext?: AppendOnlyContextManager;
@@ -658,8 +672,12 @@ export class Agent {
658
672
  * only included for `"anthropic"` requests). Falls back to the static
659
673
  * {@link metadata} value when no resolver is set.
660
674
  */
661
- metadataForProvider(provider: string): Record<string, unknown> | undefined {
662
- if (this.#metadataResolver) return this.#metadataResolver(provider);
675
+ metadataForProvider(
676
+ provider: string,
677
+ model?: Model,
678
+ transport?: AgentMetadataResolverContext["transport"],
679
+ ): Record<string, unknown> | undefined {
680
+ if (this.#metadataResolver) return this.#metadataResolver({ provider, model, transport });
663
681
  return this.#metadata;
664
682
  }
665
683
 
@@ -671,7 +689,9 @@ export class Agent {
671
689
  * credential. Pass `undefined` to clear and revert to the static
672
690
  * {@link metadata} value.
673
691
  */
674
- setMetadataResolver(resolver: ((provider: string) => Record<string, unknown> | undefined) | undefined): void {
692
+ setMetadataResolver(
693
+ resolver: ((context: AgentMetadataResolverContext) => Record<string, unknown> | undefined) | undefined,
694
+ ): void {
675
695
  this.#metadataResolver = resolver;
676
696
  }
677
697
 
@@ -880,6 +900,24 @@ export class Agent {
880
900
  this.#shouldPause = fn;
881
901
  }
882
902
 
903
+ /** The currently installed cooperative pause checkpoint, if any. */
904
+ get shouldPause(): AgentLoopConfig["shouldPause"] | undefined {
905
+ return this.#shouldPause;
906
+ }
907
+
908
+ /**
909
+ * Fence old-turn steering admission.
910
+ *
911
+ * The loop polls steering UPSTREAM of its pause checkpoint (and again on the
912
+ * immediate-interrupt path), so a cooperative stop alone cannot prevent one
913
+ * more old-turn model call once a steering message has already been dequeued.
914
+ * While the fence returns true the poll yields no messages AND does not
915
+ * dequeue, so the queue survives intact for the next turn.
916
+ */
917
+ setSteeringAdmissionFence(fn: (() => boolean) | undefined): void {
918
+ this.#steeringAdmissionFence = fn;
919
+ }
920
+
883
921
  setMaintainContext(fn: AgentLoopConfig["maintainContext"] | undefined): void {
884
922
  this.#maintainContext = fn;
885
923
  }
@@ -1900,6 +1938,12 @@ export class Agent {
1900
1938
  skipInitialSteeringPoll = false;
1901
1939
  return [];
1902
1940
  }
1941
+ // Fenced: yield nothing and dequeue nothing, so a steer submitted while a
1942
+ // fold is being claimed is neither consumed by the run being wound down
1943
+ // nor lost.
1944
+ if (this.#steeringAdmissionFence?.() === true) {
1945
+ return [];
1946
+ }
1903
1947
  const queued = this.#dequeueSteeringMessages();
1904
1948
  if (this.#activeRunId !== runId) {
1905
1949
  this.#steeringQueue = [...queued, ...this.#steeringQueue];
@@ -2080,6 +2124,10 @@ export class Agent {
2080
2124
  if (this.#activeRunId !== runId) {
2081
2125
  return;
2082
2126
  }
2127
+ const providerCode = providerFailureCode(err);
2128
+ const runtimeFailureCode = abortController.signal.aborted
2129
+ ? "aborted"
2130
+ : (managedLocalErrorDiagnostic(err)?.errorKind ?? providerCode);
2083
2131
 
2084
2132
  const errorMsg: AgentMessage = {
2085
2133
  role: "assistant",
@@ -2116,10 +2164,7 @@ export class Agent {
2116
2164
  // signal, and a local staging failure comes from the identity-
2117
2165
  // checked managedLocalErrorDiagnostic — a foreign error that
2118
2166
  // self-declares a local kind still maps to agent_failed.
2119
- error: sanitizeAgentFailure(
2120
- err,
2121
- abortController.signal.aborted ? "aborted" : managedLocalErrorDiagnostic(err)?.errorKind,
2122
- ),
2167
+ error: sanitizeAgentFailure(err, runtimeFailureCode),
2123
2168
  scope: handle.scope,
2124
2169
  });
2125
2170
  this.requestRunTerminal(managedLogicalRunOwner ?? runId, {
package/src/types.ts CHANGED
@@ -28,6 +28,21 @@ export type StreamFn = (
28
28
  ...args: Parameters<typeof streamSimple>
29
29
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
30
30
 
31
+ /**
32
+ * Request context supplied to provider-aware metadata resolvers.
33
+ *
34
+ * The model is the exact model selected for the concrete request (including
35
+ * fallback and ephemeral requests), while `transport` distinguishes the
36
+ * built-in stream path from a caller-supplied stream function. Metadata that
37
+ * carries provider identity must use both values to fail closed when routing
38
+ * is not the canonical provider path.
39
+ */
40
+ export interface AgentMetadataResolverContext {
41
+ provider: string;
42
+ model?: Model;
43
+ transport?: "default" | "custom";
44
+ }
45
+
31
46
  /** Stable identifier for a managed logical run, shared by all of its retry attempts. */
32
47
  export type ManagedLogicalRunId = number;
33
48
  /** A resource owned by a prompt run until its promise settles. */
@@ -257,7 +272,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
257
272
  * current when `AgentLoopConfig` was first constructed). Overrides the static
258
273
  * `metadata` field when present.
259
274
  */
260
- metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
275
+ metadataResolver?: (context: AgentMetadataResolverContext) => Record<string, unknown> | undefined;
261
276
 
262
277
  /**
263
278
  * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.