@gajae-code/agent-core 0.10.2 → 0.11.1

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/src/agent-loop.ts CHANGED
@@ -6,15 +6,18 @@ import {
6
6
  type AssistantMessage,
7
7
  type AssistantMessageEvent,
8
8
  type Context,
9
+ classifyFallbackTrigger,
9
10
  EventStream,
10
11
  isContextOverflow,
11
12
  isZodSchema,
12
13
  streamSimple,
13
14
  type ToolResultMessage,
14
15
  type TSchema,
16
+ transportFailureFacts,
15
17
  validateToolArguments,
16
18
  zodToWireSchema,
17
19
  } from "@gajae-code/ai";
20
+ import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
18
21
  import { sanitizeText } from "@gajae-code/utils";
19
22
  import {
20
23
  createHarmonyAuditEvent,
@@ -51,10 +54,30 @@ import type {
51
54
  AgentMessage,
52
55
  AgentTool,
53
56
  AgentToolResult,
57
+ ManagedAttemptOutcome,
54
58
  StreamFn,
55
59
  } from "./types";
56
60
 
57
61
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
62
+ /**
63
+ * Defensive caps for a provisional managed attempt. These are intentionally
64
+ * well above ordinary streamed responses; they only bound memory when an
65
+ * upstream emits an unbounded event stream before the attempt can commit.
66
+ */
67
+ export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
68
+ export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
69
+
70
+ class ManagedAttemptBufferOverflowError extends Error {
71
+ readonly status = 503;
72
+
73
+ constructor() {
74
+ super("Managed fallback attempt exceeded the provisional event buffer limit");
75
+ this.name = "ManagedAttemptBufferOverflowError";
76
+ }
77
+ }
78
+
79
+ const managedAttemptTextEncoder = new TextEncoder();
80
+
58
81
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
59
82
  /**
60
83
  * Detect empty "successful" responses that indicate a proxy-level context
@@ -66,6 +89,99 @@ function isEmptyResponseOverflow(message: AssistantMessage): boolean {
66
89
  return isContextOverflow(message);
67
90
  }
68
91
 
92
+ /** Managed fallback owns retry policy; only typed transport facts may discard an attempt. */
93
+ function managedTransportFailure(failure: unknown) {
94
+ if (failure && typeof failure === "object" && "transportFailure" in failure) {
95
+ const facts = (failure as { transportFailure?: unknown }).transportFailure;
96
+ if (facts && typeof facts === "object") return transportFailureFacts(facts);
97
+ }
98
+ return transportFailureFacts(failure);
99
+ }
100
+
101
+ function managedRetryableFailure(failure: unknown): boolean {
102
+ const facts = managedTransportFailure(failure);
103
+ if (!facts) return false;
104
+ const trigger = classifyFallbackTrigger(facts);
105
+ return (
106
+ trigger.class === "rate_limit" ||
107
+ trigger.class === "quota" ||
108
+ trigger.class === "auth" ||
109
+ trigger.class === "server"
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Neutralize leaked reserved control tokens in-place across the outgoing
115
+ * history so a re-send no longer carries the poison that triggered
116
+ * `Request blocked (code=invalid_prompt)`. Only string text fields are
117
+ * rewritten; no history item is ever dropped or reordered. Returns whether any
118
+ * byte actually changed — the circuit breaker uses this to decide between a
119
+ * single repaired resend (changed) and immediate fail-fast (unchanged).
120
+ */
121
+ function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
122
+ let changed = false;
123
+ const repairString = (value: string): string => {
124
+ const next = neutralizeReservedControlTokens(value);
125
+ if (next !== value) changed = true;
126
+ return next;
127
+ };
128
+ for (const message of messages) {
129
+ const content = (message as { content?: unknown }).content;
130
+ if (typeof content === "string") {
131
+ (message as { content: string }).content = repairString(content);
132
+ } else if (Array.isArray(content)) {
133
+ for (const block of content) {
134
+ if (!block || typeof block !== "object") continue;
135
+ const record = block as Record<string, unknown>;
136
+ for (const key of ["text", "thinking"]) {
137
+ const value = record[key];
138
+ if (typeof value === "string") record[key] = repairString(value);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return changed;
144
+ }
145
+
146
+ function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
147
+ return {
148
+ type: "retryable_discarded",
149
+ failure: { message, transportFailure: managedTransportFailure(message) },
150
+ };
151
+ }
152
+
153
+ function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
154
+ const details = error as { message?: unknown; errorStatus?: unknown; status?: unknown };
155
+ const status =
156
+ typeof details.errorStatus === "number"
157
+ ? details.errorStatus
158
+ : typeof details.status === "number"
159
+ ? details.status
160
+ : undefined;
161
+ const transportFailure =
162
+ managedTransportFailure(error) ?? (status === undefined ? undefined : { kind: "transport" as const, status });
163
+ return {
164
+ role: "assistant",
165
+ content: [],
166
+ api: config.model.api,
167
+ provider: config.model.provider,
168
+ model: config.model.id,
169
+ usage: {
170
+ input: 0,
171
+ output: 0,
172
+ cacheRead: 0,
173
+ cacheWrite: 0,
174
+ totalTokens: 0,
175
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
176
+ },
177
+ stopReason: "error",
178
+ errorMessage: typeof details.message === "string" ? details.message : String(error),
179
+ errorStatus: status,
180
+ ...(transportFailure ? { transportFailure } : {}),
181
+ timestamp: Date.now(),
182
+ };
183
+ }
184
+
69
185
  class HarmonyLeakInterruption extends Error {
70
186
  constructor(
71
187
  readonly detection: HarmonyDetection,
@@ -132,6 +248,7 @@ export function agentLoop(
132
248
  config: AgentLoopConfig,
133
249
  signal?: AbortSignal,
134
250
  streamFn?: StreamFn,
251
+ emitManagedAgentStart = true,
135
252
  ): EventStream<AgentEvent, AgentMessage[]> {
136
253
  const stream = createAgentStream();
137
254
 
@@ -141,16 +258,19 @@ export function agentLoop(
141
258
  ...context,
142
259
  messages: [...context.messages, ...prompts],
143
260
  };
144
-
145
- stream.push({ type: "agent_start" });
146
- stream.push({ type: "turn_start" });
261
+ const transaction = config.fallbackManaged
262
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
263
+ : undefined;
264
+ const attemptStream = transaction ?? stream;
265
+ if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
266
+ attemptStream.push({ type: "turn_start" });
147
267
  for (const prompt of prompts) {
148
268
  stream.push({ type: "message_start", message: prompt });
149
269
  stream.push({ type: "message_end", message: prompt });
150
270
  }
151
271
 
152
272
  try {
153
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
273
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
154
274
  } catch (err) {
155
275
  stream.fail(err);
156
276
  }
@@ -172,6 +292,7 @@ export function agentLoopContinue(
172
292
  config: AgentLoopConfig,
173
293
  signal?: AbortSignal,
174
294
  streamFn?: StreamFn,
295
+ emitManagedAgentStart = true,
175
296
  ): EventStream<AgentEvent, AgentMessage[]> {
176
297
  if (context.messages.length === 0) {
177
298
  throw new Error("Cannot continue: no messages in context");
@@ -186,12 +307,15 @@ export function agentLoopContinue(
186
307
  (async () => {
187
308
  const newMessages: AgentMessage[] = [];
188
309
  const currentContext: AgentContext = { ...context };
189
-
190
- stream.push({ type: "agent_start" });
191
- stream.push({ type: "turn_start" });
310
+ const transaction = config.fallbackManaged
311
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
312
+ : undefined;
313
+ const attemptStream = transaction ?? stream;
314
+ if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
315
+ attemptStream.push({ type: "turn_start" });
192
316
 
193
317
  try {
194
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
318
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
195
319
  } catch (err) {
196
320
  stream.fail(err);
197
321
  }
@@ -207,6 +331,94 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
207
331
  );
208
332
  }
209
333
 
334
+ /** Capture an event-time value because providers commonly mutate partial messages in place. */
335
+ function managedAttemptSnapshot<T>(value: T): T {
336
+ return structuredClone(value);
337
+ }
338
+
339
+ /**
340
+ * Holds managed-attempt assistant output above the public event stream. A
341
+ * cancelled provider attempt is therefore unobservable to sessions and their
342
+ * side-effect consumers. Non-managed streams bypass this object entirely.
343
+ */
344
+ class ManagedAttemptTransaction {
345
+ #batch: Array<
346
+ | { type: "event"; event: AgentEvent }
347
+ | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
348
+ > = [];
349
+ #stagedEventCount = 0;
350
+ #stagedBytes = 0;
351
+ #discarded = false;
352
+ #committed = false;
353
+
354
+ constructor(
355
+ private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
356
+ private readonly onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void,
357
+ ) {}
358
+
359
+ push(event: AgentEvent): void {
360
+ if (this.#committed) {
361
+ this.stream.push(event);
362
+ return;
363
+ }
364
+ this.#stage(event);
365
+ }
366
+
367
+ end(messages: AgentMessage[]): void {
368
+ this.stream.end(messages);
369
+ }
370
+
371
+ stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
372
+ this.#batch.push({
373
+ type: "assistant_event",
374
+ message: managedAttemptSnapshot(message),
375
+ event: managedAttemptSnapshot(event),
376
+ });
377
+ }
378
+
379
+ flush(): void {
380
+ if (this.#discarded || this.#committed) return;
381
+ for (const item of this.#batch) {
382
+ if (item.type === "assistant_event") {
383
+ this.onAssistantMessageEvent?.(item.message, item.event);
384
+ } else {
385
+ this.stream.push(item.event);
386
+ }
387
+ }
388
+ this.#batch = [];
389
+ this.#stagedBytes = 0;
390
+ this.#stagedEventCount = 0;
391
+ this.#committed = true;
392
+ }
393
+
394
+ discard(): void {
395
+ this.#batch = [];
396
+ this.#stagedBytes = 0;
397
+ this.#stagedEventCount = 0;
398
+ this.#discarded = true;
399
+ }
400
+
401
+ #stage(event: AgentEvent): void {
402
+ let bytes: number;
403
+ try {
404
+ bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
405
+ } catch {
406
+ bytes = MANAGED_ATTEMPT_MAX_STAGED_BYTES + 1;
407
+ }
408
+ if (
409
+ this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
410
+ this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
411
+ ) {
412
+ this.discard();
413
+ throw new ManagedAttemptBufferOverflowError();
414
+ }
415
+ this.#batch.push({ type: "event", event: managedAttemptSnapshot(event) });
416
+ this.#stagedEventCount += 1;
417
+
418
+ this.#stagedBytes += bytes;
419
+ }
420
+ }
421
+
210
422
  /**
211
423
  * Build the `agent_end` event payload. When telemetry is enabled, snapshots
212
424
  * the run collector so consumers receive {@link AgentRunSummary} +
@@ -549,7 +761,10 @@ async function runLoop(
549
761
  signal: AbortSignal | undefined,
550
762
  stream: EventStream<AgentEvent, AgentMessage[]>,
551
763
  streamFn?: StreamFn,
764
+ initialTransaction?: ManagedAttemptTransaction,
552
765
  ): Promise<void> {
766
+ const loopSignal = signal ?? new AbortController().signal;
767
+
553
768
  const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
554
769
  const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
555
770
  const stepCounter = { count: 0 };
@@ -560,12 +775,14 @@ async function runLoop(
560
775
  currentContext,
561
776
  newMessages,
562
777
  config,
563
- signal,
778
+ loopSignal,
779
+
564
780
  stream,
565
781
  telemetry,
566
782
  invokeAgentSpan,
567
783
  stepCounter,
568
784
  streamFn,
785
+ initialTransaction,
569
786
  ),
570
787
  );
571
788
  } catch (err) {
@@ -587,18 +804,28 @@ async function runLoopBody(
587
804
  currentContext: AgentContext,
588
805
  newMessages: AgentMessage[],
589
806
  config: AgentLoopConfig,
590
- signal: AbortSignal | undefined,
807
+ loopSignal: AbortSignal,
808
+
591
809
  stream: EventStream<AgentEvent, AgentMessage[]>,
592
810
  telemetry: AgentTelemetry | undefined,
593
811
  invokeAgentSpan: Span | undefined,
594
812
  stepCounter: StepCounter,
595
813
  streamFn?: StreamFn,
814
+ initialTransaction?: ManagedAttemptTransaction,
596
815
  ): Promise<void> {
597
816
  let firstTurn = true;
598
817
  // Check for steering messages at start (user may have typed while waiting)
599
818
  let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
600
819
  let harmonyRetryAttempt = 0;
820
+ // Whether at least one assistant response has been produced in THIS run. The
821
+ // mid-run maintenance checkpoint only fires between tool iterations (after a
822
+ // model response); pre-turn maintenance is the pre-prompt check's job, so the
823
+ // first iteration is skipped to avoid duplicating/racing it.
824
+ let modelHasResponded = false;
601
825
  let harmonyTruncateResumeCount = 0;
826
+ // Fires at most one repaired resend per run for the poisoned-history
827
+ // `invalid_prompt` circuit breaker below.
828
+ let invalidPromptRepairAttempted = false;
602
829
 
603
830
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
604
831
  while (true) {
@@ -606,13 +833,21 @@ async function runLoopBody(
606
833
 
607
834
  // Inner loop: process tool calls and steering messages
608
835
  while (hasMoreToolCalls || pendingMessages.length > 0) {
836
+ const transaction =
837
+ initialTransaction ??
838
+ (config.fallbackManaged
839
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
840
+ : undefined);
841
+ initialTransaction = undefined;
842
+ const attemptStream = transaction ?? stream;
609
843
  if (!firstTurn) {
610
- stream.push({ type: "turn_start" });
844
+ attemptStream.push({ type: "turn_start" });
611
845
  } else {
612
846
  firstTurn = false;
613
847
  }
614
848
 
615
- // Process pending messages (inject before next assistant response)
849
+ // Commit queued user input outside the provisional assistant transaction so a
850
+ // discarded managed attempt cannot lose it before its retry continuation.
616
851
  if (pendingMessages.length > 0) {
617
852
  for (const message of pendingMessages) {
618
853
  stream.push({ type: "message_start", message });
@@ -623,20 +858,63 @@ async function runLoopBody(
623
858
  pendingMessages = [];
624
859
  }
625
860
 
861
+ // Cooperative mid-run context maintenance. Runs after pending
862
+ // tool/steering messages are materialized into durable context and
863
+ // before syncContextBeforeModelCall / the model call — the only
864
+ // boundary where the full unsent context is already durable. A
865
+ // non-"not-needed" outcome means context was (or was attempted to be)
866
+ // rewritten, so end the run WITHOUT the lossy agent_end finalization;
867
+ // the maintenance owner resumes the run on the rewritten context.
868
+ // "not-needed" falls through to the model call.
869
+ if (config.maintainContext && modelHasResponded && !loopSignal.aborted) {
870
+ const lifecycle = {
871
+ signal: loopSignal,
872
+ awaitEventDrain: (invocationSignal: AbortSignal) =>
873
+ stream.waitForConsumerDrain(AbortSignal.any([loopSignal, invocationSignal])),
874
+ };
875
+ const maintenanceOutcome = await config.maintainContext(currentContext, lifecycle);
876
+ // A callback can settle after its loop has been cancelled. Never let a
877
+ // stale "not-needed" fall through to streamAssistantResponse, which
878
+ // invokes the provider before it observes the aborted signal.
879
+ const outcome = loopSignal.aborted ? "aborted" : maintenanceOutcome;
880
+
881
+ if (outcome !== "not-needed") {
882
+ stream.push({
883
+ type: "agent_end",
884
+ messages: newMessages,
885
+ stopReason: "maintenance",
886
+ maintenanceOutcome: outcome,
887
+ });
888
+ stream.end(newMessages);
889
+ return;
890
+ }
891
+ }
892
+
626
893
  // Refresh prompt/tool context from live state before each model call
627
894
  if (config.syncContextBeforeModelCall) {
628
895
  await config.syncContextBeforeModelCall(currentContext);
629
896
  }
630
897
 
898
+ const contextMessageCount = currentContext.messages.length;
899
+ const newMessageCount = newMessages.length;
900
+
631
901
  // Stream assistant response
632
902
  let recovered: HarmonyRecoveredToolCall | undefined;
633
903
  let message: AssistantMessage;
904
+ const attemptTransaction = transaction;
634
905
  try {
906
+ const attemptConfig = attemptTransaction
907
+ ? {
908
+ ...config,
909
+ onAssistantMessageEvent: (partial: AssistantMessage, event: AssistantMessageEvent) =>
910
+ attemptTransaction.stageAssistantMessageEvent(partial, event),
911
+ }
912
+ : config;
635
913
  message = await streamAssistantResponse(
636
914
  currentContext,
637
- config,
638
- signal,
639
- stream,
915
+ attemptConfig,
916
+ loopSignal,
917
+ attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
640
918
  telemetry,
641
919
  invokeAgentSpan,
642
920
  stepCounter,
@@ -652,7 +930,21 @@ async function runLoopBody(
652
930
  harmonyRetryAttempt = 0;
653
931
  harmonyTruncateResumeCount = 0;
654
932
  } catch (err) {
655
- if (!(err instanceof HarmonyLeakInterruption)) throw err;
933
+ if (!(err instanceof HarmonyLeakInterruption)) {
934
+ if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
935
+ transaction.discard();
936
+ currentContext.messages.splice(contextMessageCount);
937
+ newMessages.splice(newMessageCount);
938
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(managedFailureMessage(err, config)));
939
+ stream.end(newMessages);
940
+ return;
941
+ }
942
+ throw err;
943
+ }
944
+ if (config.fallbackManaged) {
945
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
946
+ throw err;
947
+ }
656
948
  if (err.recovered) {
657
949
  if (harmonyTruncateResumeCount >= 2) {
658
950
  await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
@@ -694,7 +986,31 @@ async function runLoopBody(
694
986
  continue;
695
987
  }
696
988
  }
989
+ // Session-level invalid_prompt circuit breaker (bounded, neutralize-only).
990
+ // A poisoned-history rejection (`Request blocked (code=invalid_prompt)`) is
991
+ // a deterministic content fault: re-sending the same history re-triggers it,
992
+ // so naive session auto-retry would burn its whole budget re-poisoning the
993
+ // model. On the first invalid_prompt of this run, neutralize leaked control
994
+ // tokens in history IN PLACE (never dropping items). If that changed the
995
+ // outgoing bytes, resend exactly once with the repaired history; if
996
+ // neutralization cannot change anything (nothing left to repair), fall
997
+ // through to terminal handling and fail fast. Budget = one repaired resend.
998
+ // Runs before the response is committed so the resend is a clean retry;
999
+ // managed fallback owns its own retry policy, so this is scoped to the
1000
+ // non-managed session path where uncontrolled auto-retry would recur.
1001
+ if (
1002
+ !config.fallbackManaged &&
1003
+ message.stopReason === "error" &&
1004
+ !invalidPromptRepairAttempted &&
1005
+ isInvalidPromptError(message)
1006
+ ) {
1007
+ invalidPromptRepairAttempted = true;
1008
+ if (repairInvalidPromptHistory(currentContext.messages)) {
1009
+ continue;
1010
+ }
1011
+ }
697
1012
  newMessages.push(message);
1013
+ modelHasResponded = true;
698
1014
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
699
1015
 
700
1016
  // Detect empty "successful" responses (stopReason "stop" + empty content).
@@ -710,6 +1026,30 @@ async function runLoopBody(
710
1026
  : "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
711
1027
  }
712
1028
 
1029
+ if (config.fallbackManaged && message.stopReason === "error" && managedRetryableFailure(message)) {
1030
+ transaction?.discard();
1031
+ currentContext.messages.splice(contextMessageCount);
1032
+ newMessages.splice(newMessageCount);
1033
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(message));
1034
+ stream.end(newMessages);
1035
+ return;
1036
+ }
1037
+
1038
+ if (config.fallbackManaged && message.stopReason === "aborted") {
1039
+ transaction?.discard();
1040
+ currentContext.messages.splice(contextMessageCount);
1041
+ newMessages.splice(newMessageCount);
1042
+ await config.onManagedAttemptOutcome?.({ type: "run_terminal", reason: "cancelled" });
1043
+ stream.end(newMessages);
1044
+ return;
1045
+ }
1046
+
1047
+ // One provider invocation is committed before any tool can run.
1048
+ transaction?.flush();
1049
+ if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1050
+ await config.onManagedAttemptAccepted?.();
1051
+ }
1052
+
713
1053
  if (message.stopReason === "error" || message.stopReason === "aborted") {
714
1054
  // Create placeholder tool results for any tool calls in the aborted message
715
1055
  // This maintains the tool_use/tool_result pairing that the API requires
@@ -747,7 +1087,7 @@ async function runLoopBody(
747
1087
  const executionResult = await executeToolCalls(
748
1088
  currentContext,
749
1089
  message,
750
- signal,
1090
+ loopSignal,
751
1091
  stream,
752
1092
  config,
753
1093
  telemetry,
@@ -925,8 +1265,10 @@ async function streamAssistantResponse(
925
1265
 
926
1266
  try {
927
1267
  return await runInActiveSpan(chatSpan, async () => {
1268
+ const fallbackAttempt = config.fallbackManaged ? config.nextFallbackAttempt?.(config.model) : undefined;
928
1269
  const response = await streamFunction(config.model, llmContext, {
929
1270
  ...config,
1271
+ fallbackAttempt,
930
1272
  apiKey: resolvedApiKey,
931
1273
  authCredentialType,
932
1274
  metadata: resolvedMetadata,