@salesforce/sfdx-agent-harness-claude 0.7.0 → 0.9.0

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.
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import { realpath } from 'node:fs/promises';
6
6
  import { RealClock, UUIDGenerator, buildSummaryPrompt, getErrorMessage, isAbortError, } from '@salesforce/agentic-common';
7
- import { AgentSDKError, AgentSDKErrorType, HarnessBusOwner, McpServerStatus, } from '@salesforce/sfdx-agent-sdk';
7
+ import { AgentSDKError, AgentSDKErrorType, HarnessBusOwner, lowerStreamInput, McpServerStatus, resolveToolApprovalMode, } from '@salesforce/sfdx-agent-sdk';
8
+ import { validateMultimodalFiles } from '@salesforce/llm-gateway-sdk';
8
9
  import { deleteSession, forkSession, getSessionMessages, listSessions, query, } from '@anthropic-ai/claude-agent-sdk';
9
10
  import { ClaudeEventAdapter, mapSessionMessageContent } from './claude-event-adapter.js';
10
11
  import { ClaudeStreamBroker } from './claude-stream-broker.js';
@@ -20,6 +21,18 @@ import { DiskBackedSessionStore } from './disk-backed-session-store.js';
20
21
  import { ThreadContextStore } from './thread-context-store.js';
21
22
  import { teeTextStream } from './text-stream.js';
22
23
  import { MAX_TRANSCRIPT_CHARS, partToTranscriptText, splitIntoChunks } from './transcript.js';
24
+ /**
25
+ * Returns a shallow copy of `m` with `content` normalized: any `MessagePart[]`
26
+ * is reduced to a compact placeholder string via {@link partToTranscriptText} so
27
+ * raw base64 blobs never land in the sidecar JSON. All three
28
+ * `threadContextStore.write` call sites route through this helper.
29
+ */
30
+ function normalizeMessageForSidecar(m) {
31
+ return {
32
+ ...m,
33
+ content: typeof m.content === 'string' ? m.content : m.content.map((p) => partToTranscriptText(p)).join(' '),
34
+ };
35
+ }
23
36
  export class ClaudeHarness {
24
37
  envBuilder;
25
38
  optionsBuilder;
@@ -209,8 +222,8 @@ export class ClaudeHarness {
209
222
  projectRoot: resolvedProjectRoot,
210
223
  config,
211
224
  rulesAppend,
225
+ model: llmGatewayClient.getModel(),
212
226
  skillMap,
213
- modelId: llmGatewayClient.getModel().name,
214
227
  threads,
215
228
  mcpServers,
216
229
  });
@@ -473,8 +486,8 @@ export class ClaudeHarness {
473
486
  if (source.contextHistory.length === 0 && source.pendingContext.length === 0)
474
487
  return;
475
488
  await this.threadContextStore.write(projectRoot, threadId, {
476
- contextHistory: [...source.contextHistory],
477
- pendingContext: [...source.pendingContext],
489
+ contextHistory: source.contextHistory.map(normalizeMessageForSidecar),
490
+ pendingContext: source.pendingContext.map(normalizeMessageForSidecar),
478
491
  });
479
492
  }
480
493
  /**
@@ -568,6 +581,21 @@ export class ClaudeHarness {
568
581
  ?.warn(`Best-effort destroyThread failed during compactThread for thread "${threadId}" on agent "${agentId}": ${getErrorMessage(err)}`, { agentId, threadId }, err instanceof Error ? err : new Error(String(err)));
569
582
  }
570
583
  }
584
+ /**
585
+ * Pattern of upstream-Bedrock SSE-truncation strings the gateway
586
+ * occasionally surfaces as the assistant's `result.result` text instead
587
+ * of throwing. Observed empirically against the Salesforce LLM Gateway's
588
+ * Bedrock-Claude path: the same string also surfaces as a late-throw on
589
+ * the chat-stream path (see PR #424). When `summarize()` sees this
590
+ * pattern in the result body it retries instead of returning the error
591
+ * string verbatim as the "summary."
592
+ *
593
+ * Temporary workaround for an upstream LLM Gateway regression (#396 /
594
+ * #453 / #479). Remove together with the `stream()` post-finish
595
+ * swallow once the gateway team confirms the trailing-JSON shape is
596
+ * fixed end-to-end — cleanup tracked by #474.
597
+ */
598
+ static UPSTREAM_TRUNCATION_PATTERN = /API Error: Truncated event message received\.?/i;
571
599
  /**
572
600
  * Issue a single non-agentic model call (`maxTurns: 1`) for one-shot
573
601
  * summarization. Used internally by `compactThread()`; not part of the
@@ -578,12 +606,20 @@ export class ClaudeHarness {
578
606
  * don't want the summary call leaking into the harness store.
579
607
  * - no MCP servers, no consumer tools, no rules append, no
580
608
  * `canUseTool` broker — the task is text-in / text-out.
609
+ *
610
+ * Retries up to 2 times on the upstream Bedrock SSE-truncation pattern
611
+ * (see {@link UPSTREAM_TRUNCATION_PATTERN}). The Salesforce LLM Gateway's
612
+ * Bedrock-Claude path occasionally returns a successful `result` whose
613
+ * body is the upstream truncation error string rather than a real
614
+ * summary; without retry the harness would happily seed the new thread
615
+ * with that error string. Each retry uses a fresh ephemeral session id
616
+ * and fresh `query()` invocation.
581
617
  */
582
618
  async summarize(agent, prompt) {
583
619
  if (!agent.config.orgJwt) {
584
620
  throw new Error('Agent has no orgJwt configured');
585
621
  }
586
- const { env, nativeModelId } = await this.envBuilder.build(agent.config.orgJwt, agent.modelId);
622
+ const { env, nativeModelId } = await this.envBuilder.build(agent.config.orgJwt, agent.model.name);
587
623
  const summaryConfig = {
588
624
  orgJwt: agent.config.orgJwt,
589
625
  // The role declaration + summarization imperative + the
@@ -598,34 +634,58 @@ export class ClaudeHarness {
598
634
  'Preserve numeric values, dates, and proper names verbatim. ' +
599
635
  'Do not invent details that are not present in the transcript.',
600
636
  };
601
- const options = this.optionsBuilder.build(agent.projectRoot, summaryConfig, { maxSteps: 1 }, env, undefined, nativeModelId, undefined, undefined, undefined);
602
- // Override post-build: ephemeral session, no persistence, no
603
- // sessionStore (rejected together with persistSession:false).
604
- options.persistSession = false;
605
- delete options.sessionStore;
606
- delete options.resume;
607
- delete options.sessionId;
608
- const summaryQuery = query({ prompt, options: options });
609
- let resultText;
610
- try {
611
- for await (const msg of summaryQuery) {
612
- if (msg.type === 'result' && msg.subtype === 'success') {
613
- resultText = msg.result;
637
+ const buildOptions = () => {
638
+ const options = this.optionsBuilder.build(agent.projectRoot, summaryConfig, { maxSteps: 1 }, env, undefined, nativeModelId, undefined, undefined, undefined);
639
+ // Override post-build: ephemeral session, no persistence, no
640
+ // sessionStore (rejected together with persistSession:false).
641
+ options.persistSession = false;
642
+ delete options.sessionStore;
643
+ delete options.resume;
644
+ delete options.sessionId;
645
+ return options;
646
+ };
647
+ const MAX_ATTEMPTS = 3;
648
+ let lastFailureReason;
649
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
650
+ const summaryQuery = query({ prompt, options: buildOptions() });
651
+ let resultText;
652
+ let upstreamThrow;
653
+ try {
654
+ for await (const msg of summaryQuery) {
655
+ if (msg.type === 'result' && msg.subtype === 'success') {
656
+ resultText = msg.result;
657
+ }
614
658
  }
615
659
  }
616
- }
617
- finally {
618
- try {
619
- summaryQuery.close?.();
660
+ catch (err) {
661
+ upstreamThrow = err;
620
662
  }
621
- catch {
622
- /* iterator already closed */
663
+ finally {
664
+ try {
665
+ summaryQuery.close?.();
666
+ }
667
+ catch {
668
+ /* iterator already closed */
669
+ }
623
670
  }
671
+ const truncatedThrow = upstreamThrow !== undefined &&
672
+ ClaudeHarness.UPSTREAM_TRUNCATION_PATTERN.test(getErrorMessage(upstreamThrow));
673
+ const truncatedBody = resultText !== undefined && ClaudeHarness.UPSTREAM_TRUNCATION_PATTERN.test(resultText);
674
+ if (truncatedThrow || truncatedBody) {
675
+ lastFailureReason = truncatedThrow
676
+ ? getErrorMessage(upstreamThrow)
677
+ : `result body was upstream truncation: ${resultText}`;
678
+ continue;
679
+ }
680
+ if (upstreamThrow !== undefined) {
681
+ throw upstreamThrow;
682
+ }
683
+ if (!resultText || resultText.length === 0) {
684
+ throw new Error('Summary call produced no result text');
685
+ }
686
+ return resultText;
624
687
  }
625
- if (!resultText || resultText.length === 0) {
626
- throw new Error('Summary call produced no result text');
627
- }
628
- return resultText;
688
+ throw new Error(`Summary call hit upstream Bedrock SSE truncation on all ${MAX_ATTEMPTS} attempts. Last failure: ${lastFailureReason}`);
629
689
  }
630
690
  /**
631
691
  * Two-pass summarization for transcripts above the char budget. The
@@ -644,32 +704,94 @@ export class ClaudeHarness {
644
704
  ? this.summarize(agent, buildSummaryPrompt(merged))
645
705
  : this.summarizeInChunks(agent, merged);
646
706
  }
707
+ /**
708
+ * Validates a `MessagePart[]` and lowers it to a single-message `AsyncIterable<SDKUserMessage>`
709
+ * carrying Anthropic content blocks (`text`, `image`, `document`).
710
+ *
711
+ * Pre-stream validation and the input-part guard live in the SDK's {@link lowerStreamInput}:
712
+ * `image` / `file` parts are validated against the agent's gateway `Model.supportedFormats` and
713
+ * the global gateway caps via the shared `validateMultimodalFiles`, so a file is accepted or
714
+ * rejected identically on the Claude and Mastra paths (we deliberately mirror the SDK's
715
+ * conservative caps even though the underlying Bedrock models may accept more), and a
716
+ * `reasoning` / `tool-call` / `tool-result` part rejects with `INVALID_MESSAGE_CONTENT`. This
717
+ * method only supplies the Anthropic-specific lowering of each part.
718
+ *
719
+ * Validation and block construction run synchronously (the returned generator only yields the
720
+ * pre-built message) so failures reject the caller's `stream()` promise before any query starts.
721
+ */
722
+ buildMultimodalPrompt(agentState, parts) {
723
+ const content = lowerStreamInput(parts, (files) => validateMultimodalFiles(files, agentState.model), (part) => {
724
+ switch (part.type) {
725
+ case 'text':
726
+ return { type: 'text', text: part.text };
727
+ case 'image':
728
+ return {
729
+ type: 'image',
730
+ source: { type: 'base64', media_type: part.mimeType, data: part.data },
731
+ };
732
+ case 'file':
733
+ return {
734
+ type: 'document',
735
+ source: { type: 'base64', media_type: part.mimeType, data: part.data },
736
+ ...(part.fileName !== undefined ? { title: part.fileName } : {}),
737
+ };
738
+ }
739
+ });
740
+ const userMessage = {
741
+ type: 'user',
742
+ parent_tool_use_id: null,
743
+ message: { role: 'user', content },
744
+ };
745
+ return (async function* () {
746
+ yield userMessage;
747
+ })();
748
+ }
647
749
  async stream(agentId, threadId, message, streamOptions) {
648
750
  const agentState = this.agents.get(agentId);
649
751
  if (!agentState) {
650
752
  throw new Error(`Agent "${agentId}" not found`);
651
753
  }
652
- const { projectRoot, config, rulesAppend, modelId } = agentState;
754
+ const { projectRoot, config, rulesAppend, model } = agentState;
653
755
  const threadState = agentState.threads.get(threadId);
654
756
  if (!config.orgJwt) {
655
757
  throw new Error(`Agent "${agentId}" has no orgJwt configured`);
656
758
  }
657
- const { env, nativeModelId } = await this.envBuilder.build(config.orgJwt, modelId);
759
+ // Build (and pre-stream-validate) the query prompt. A plain string passes through; a
760
+ // MessagePart[] is validated against the model's capabilities and lowered to a single
761
+ // Anthropic user message carrying text / image / document content blocks. Validation runs
762
+ // synchronously here so failures reject the `stream()` promise (pre-stream contract) rather
763
+ // than surfacing mid-iteration.
764
+ const prompt = typeof message === 'string' ? message : this.buildMultimodalPrompt(agentState, message);
765
+ const { env, nativeModelId } = await this.envBuilder.build(config.orgJwt, model.name);
658
766
  const sessionOptions = threadState?.hasStreamed ? { resume: threadId } : { sessionId: threadId };
659
767
  const pendingContext = threadState?.pendingContext ?? [];
660
768
  const contextConfig = pendingContext.length > 0
661
769
  ? { ...config, instructions: this.buildContextPrompt(config.instructions, pendingContext) }
662
770
  : config;
663
- const adapter = new ClaudeEventAdapter(this.buses.getLogBus());
771
+ // Closure over `agentState.mcpServers` so the adapter and broker can
772
+ // enrich `tool-call` / `tool-result` / `tool-approval-request` events
773
+ // with `serverName` + `annotations` for MCP-bridged tools (#466).
774
+ // Non-MCP tools (built-ins like `Bash`, the in-process
775
+ // `consumer_tools` / `skill_bridge` servers) return undefined and the
776
+ // events stay un-enriched, matching Mastra's shape on the same paths.
777
+ const lookup = (toolName) => lookupMcpToolMetadata(agentState.mcpServers, toolName);
778
+ const adapter = new ClaudeEventAdapter(this.buses.getLogBus(), lookup);
664
779
  const controller = new AbortController();
780
+ // Resolve `requireToolApproval` to its canonical mode (#447):
781
+ // `undefined` (no gating), `'serial'`, or `'batch'`. The broker
782
+ // is allocated for both gating modes; only the emission shape
783
+ // differs.
784
+ const approvalMode = resolveToolApprovalMode(streamOptions?.requireToolApproval);
665
785
  let broker;
666
- if (streamOptions?.requireToolApproval) {
786
+ if (approvalMode !== undefined) {
667
787
  const key = this.brokerKey(agentId, threadId);
668
788
  this.brokers.get(key)?.dispose();
669
789
  broker = new ClaudeStreamBroker({
670
790
  adapter,
671
791
  toolApprovalTimeoutMs: this.toolApprovalTimeoutMs,
672
792
  abortController: controller,
793
+ lookupMcpToolMetadata: lookup,
794
+ mode: approvalMode,
673
795
  });
674
796
  this.brokers.set(key, broker);
675
797
  }
@@ -682,7 +804,7 @@ export class ClaudeHarness {
682
804
  const consumerTools = config.tools ?? [];
683
805
  let consumerToolBroker;
684
806
  let consumerToolsBridge;
685
- if (!streamOptions?.requireToolApproval && consumerTools.length > 0) {
807
+ if (approvalMode === undefined && consumerTools.length > 0) {
686
808
  const key = this.brokerKey(agentId, threadId);
687
809
  this.consumerToolBrokers.get(key)?.dispose();
688
810
  consumerToolBroker = new ConsumerToolBroker(CONSUMER_TOOLS_BRIDGE_SERVER_NAME);
@@ -694,7 +816,7 @@ export class ClaudeHarness {
694
816
  // set; the bridge is plumbing the Claude subprocess uses to reach
695
817
  // those clients via in-process JSON-RPC.
696
818
  const bridgeConfigs = this.buildBridgeConfigsForStream(agentState, consumerToolsBridge);
697
- const options = this.optionsBuilder.build(projectRoot, contextConfig, streamOptions, env, sessionOptions, nativeModelId, broker?.canUseTool, bridgeConfigs, rulesAppend.length > 0 ? rulesAppend : undefined);
819
+ const options = this.optionsBuilder.build(projectRoot, contextConfig, streamOptions, env, sessionOptions, nativeModelId, broker?.preToolUseHook, bridgeConfigs, rulesAppend.length > 0 ? rulesAppend : undefined);
698
820
  options.abortController = controller;
699
821
  // Mirror subprocess writes into the harness-owned store. With this
700
822
  // set, the host-side `getSessionMessages` / `deleteSession` calls
@@ -704,11 +826,28 @@ export class ClaudeHarness {
704
826
  options.sessionStore = this.sessionStore;
705
827
  if (streamOptions?.abortSignal) {
706
828
  streamOptions.abortSignal.throwIfAborted();
707
- streamOptions.abortSignal.addEventListener('abort', () => {
829
+ const onAbort = () => {
708
830
  controller.abort();
709
831
  broker?.abort();
710
832
  consumerToolBroker?.abort();
711
- }, { once: true });
833
+ };
834
+ const abortSignal = streamOptions.abortSignal;
835
+ abortSignal.addEventListener('abort', onAbort);
836
+ // Detach the listener at the per-stream controller's abort
837
+ // event. The harness aborts `controller` from end-of-stream
838
+ // paths (the eventStream generator's `finally`, broker
839
+ // dispose's `abortController.abort()`, consumer-tool broker's
840
+ // abort, the inbound abort listener above), so this fires
841
+ // exactly once per stream — natural completion, error, or
842
+ // forwarded abort. Without this, a long-lived consumer
843
+ // AbortController (session-scoped, app-scoped) would
844
+ // accumulate one listener per turn even when the turn never
845
+ // aborts. Tying cleanup to `controller.signal` instead of
846
+ // `broker?.onTeardown()` covers the no-broker path (most
847
+ // turns) too.
848
+ controller.signal.addEventListener('abort', () => abortSignal.removeEventListener('abort', onAbort), {
849
+ once: true,
850
+ });
712
851
  }
713
852
  if (threadState && pendingContext.length > 0) {
714
853
  threadState.pendingContext = [];
@@ -716,12 +855,13 @@ export class ClaudeHarness {
716
855
  // stream and the next addContext doesn't re-inject the
717
856
  // already-consumed pendingContext into the model's
718
857
  // systemPrompt. contextHistory persists unchanged.
858
+ // Normalize to placeholders so raw base64 never lands on disk.
719
859
  await this.threadContextStore.write(projectRoot, threadId, {
720
- pendingContext: threadState.pendingContext,
721
- contextHistory: threadState.contextHistory,
860
+ pendingContext: [],
861
+ contextHistory: threadState.contextHistory.map(normalizeMessageForSidecar),
722
862
  });
723
863
  }
724
- const queryResult = query({ prompt: message, options });
864
+ const queryResult = query({ prompt, options });
725
865
  if (threadState) {
726
866
  threadState.hasStreamed = true;
727
867
  }
@@ -760,17 +900,38 @@ export class ClaudeHarness {
760
900
  return teeTextStream(consumerToolBroker.initialSink().generator());
761
901
  }
762
902
  const logBus = this.buses.getLogBus();
903
+ // Snapshot here so the catch block can distinguish a
904
+ // consumer-forwarded abort (signal aborted before the iterator
905
+ // threw) from "iterator threw normally and the finally aborted
906
+ // `controller` for listener cleanup." Without this, the natural
907
+ // error path would mis-classify as abort once the finally runs.
908
+ const consumerAbortSignal = streamOptions?.abortSignal;
763
909
  async function* eventStream() {
764
910
  let terminalEmitted = false;
765
911
  try {
766
- for await (const msg of queryResult) {
767
- for (const event of adapter.mapMessage(msg)) {
768
- if (event.type === 'finish') {
769
- terminalEmitted = true;
912
+ try {
913
+ for await (const msg of queryResult) {
914
+ for (const event of adapter.mapMessage(msg)) {
915
+ if (event.type === 'finish') {
916
+ terminalEmitted = true;
917
+ }
918
+ yield event;
770
919
  }
771
- yield event;
772
920
  }
773
921
  }
922
+ finally {
923
+ // Abort the per-stream controller on every exit path
924
+ // (natural completion, iterator throw, consumer
925
+ // `return()` from a `break`). The abort fires the
926
+ // `controller.signal` listeners we register above
927
+ // (consumer-AbortSignal listener detach, broker
928
+ // teardown), so a turn that never explicitly aborts
929
+ // still releases its inbound listener. Idempotent —
930
+ // the inbound abort listener already aborted
931
+ // `controller` when applicable.
932
+ if (!controller.signal.aborted)
933
+ controller.abort();
934
+ }
774
935
  }
775
936
  catch (err) {
776
937
  // The Salesforce LLM Gateway's Bedrock-Claude path
@@ -798,13 +959,20 @@ export class ClaudeHarness {
798
959
  });
799
960
  return;
800
961
  }
801
- if (controller.signal.aborted) {
962
+ if (consumerAbortSignal?.aborted) {
802
963
  // When the consumer-supplied AbortSignal fires, the
803
964
  // harness forwards it onto its local controller and the
804
965
  // Claude SDK iterator throws whatever it throws (varies
805
966
  // by upstream version). Normalize to the SDK's
806
967
  // documented abort contract: AbortError-named Error +
807
968
  // `code: 'abort'`. Matches the Mastra harness convention.
969
+ //
970
+ // We check the CONSUMER's signal here, not
971
+ // `controller.signal` — the per-stream controller is
972
+ // also aborted from the generator's `finally` for
973
+ // listener cleanup on natural error paths, so checking
974
+ // it would mis-classify regular iterator failures as
975
+ // aborts.
808
976
  yield {
809
977
  type: 'error',
810
978
  error: new DOMException('The stream was aborted.', 'AbortError'),
@@ -848,8 +1016,8 @@ export class ClaudeHarness {
848
1016
  for (const [name, state] of agent.mcpServers) {
849
1017
  if (state.currentStatus !== McpServerStatus.Connected)
850
1018
  continue;
851
- const timeout = agent.config.mcpServers?.[name]?.timeout;
852
- out[name] = buildBridgeForServer(name, state.client, state.tools, timeout);
1019
+ const serverConfig = agent.config.mcpServers?.[name];
1020
+ out[name] = buildBridgeForServer(name, state.client, state.tools, serverConfig?.timeout, serverConfig?.alwaysLoad);
853
1021
  }
854
1022
  if (consumerToolsBridge) {
855
1023
  out[CONSUMER_TOOLS_BRIDGE_SERVER_NAME] = consumerToolsBridge;
@@ -870,7 +1038,16 @@ export class ClaudeHarness {
870
1038
  * fresh continuation sink returned here.
871
1039
  */
872
1040
  async submitToolResult(agentId, threadId, toolResult) {
873
- const broker = this.consumerToolBrokers.get(this.brokerKey(agentId, threadId));
1041
+ const key = this.brokerKey(agentId, threadId);
1042
+ // A `ClaudeStreamBroker` only exists when the originating turn was
1043
+ // approval-gated. The broker has no path to settle a
1044
+ // consumer-executed tool result (its decision shape is
1045
+ // approve/decline only), so refuse with cross-harness-parity
1046
+ // guidance — Mastra's submitToolResult emits the same shape.
1047
+ if (this.brokers.has(key)) {
1048
+ throw new Error(`ClaudeHarness.submitToolResult(): thread "${threadId}" on agent "${agentId}" is in approval-gated mode; settle pending approvals via approveToolCall / declineToolCall first`);
1049
+ }
1050
+ const broker = this.consumerToolBrokers.get(key);
874
1051
  if (!broker) {
875
1052
  throw new Error(`ClaudeHarness.submitToolResult(): no active consumer-tool stream for thread "${threadId}" on agent "${agentId}"`);
876
1053
  }
@@ -916,6 +1093,9 @@ export class ClaudeHarness {
916
1093
  // `pendingContext` → systemPrompt path on the next stream. Order is
917
1094
  // injection-order followed by persisted-transcript-order, matching
918
1095
  // the Mastra harness shape.
1096
+ // Consumer-visible content round-trips unchanged: contextHistory entries
1097
+ // are returned exactly as passed to addContext(), including any raw base64
1098
+ // in image/file parts. Only the sidecar-persisted copy is normalized (see addContext()).
919
1099
  const contextHistory = threadState?.contextHistory ?? [];
920
1100
  return contextHistory.length === 0 ? persisted : [...contextHistory, ...persisted];
921
1101
  }
@@ -940,7 +1120,7 @@ export class ClaudeHarness {
940
1120
  }
941
1121
  buildContextPrompt(baseInstructions, messages) {
942
1122
  const contextBlock = messages
943
- .map((m) => `[${m.role}]: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
1123
+ .map((m) => `[${m.role}]: ${typeof m.content === 'string' ? m.content : m.content.map((p) => partToTranscriptText(p)).join(' ')}`)
944
1124
  .join('\n');
945
1125
  const prefix = baseInstructions ? `${baseInstructions}\n\n` : '';
946
1126
  return `${prefix}<prior-context>\n${contextBlock}\n</prior-context>`;
@@ -967,9 +1147,12 @@ export class ClaudeHarness {
967
1147
  // path (`memory.saveMessages`) propagates libsql failures, and
968
1148
  // silently masking a write failure here would recreate the very
969
1149
  // cross-restart drop the sidecar exists to prevent.
1150
+ // Normalize MessagePart[] content to placeholder text before writing so
1151
+ // raw base64 blobs never land in the sidecar JSON (same pattern as
1152
+ // compactThread's transcript builder).
970
1153
  await this.threadContextStore.write(agent.projectRoot, threadId, {
971
- pendingContext: threadState.pendingContext,
972
- contextHistory: threadState.contextHistory,
1154
+ pendingContext: threadState.pendingContext.map(normalizeMessageForSidecar),
1155
+ contextHistory: threadState.contextHistory.map(normalizeMessageForSidecar),
973
1156
  });
974
1157
  }
975
1158
  /**
@@ -1107,6 +1290,51 @@ export class ClaudeHarness {
1107
1290
  function isDisposedError(err) {
1108
1291
  return err instanceof AgentSDKError && err.type === AgentSDKErrorType.DISPOSED;
1109
1292
  }
1293
+ /**
1294
+ * Resolve the originating MCP server name and (optional) annotations for a
1295
+ * Claude-namespaced tool name (`mcp__<server>__<tool>`). Returns `undefined`
1296
+ * for tools that aren't in any MCP server's catalog (built-ins like `Bash`,
1297
+ * the in-process `consumer_tools` / `skill_bridge` SDK MCP servers, or any
1298
+ * tool name that doesn't carry the `mcp__` prefix).
1299
+ *
1300
+ * Selects the **longest matching prefix whose catalog actually contains the
1301
+ * tool**. The `__` separator makes single-underscore prefix collisions
1302
+ * impossible (`mcp__foo__` is not a prefix of `mcp__foo_extra__`), but a
1303
+ * server name that itself contains `__` does collide with a shorter sibling:
1304
+ * `mcp__foo__bar__baz` could come from server `foo` + tool `bar__baz` or
1305
+ * from server `foo__bar` + tool `baz`. The existence check on the
1306
+ * candidate's `tools` array picks the server that actually catalogs the
1307
+ * tool; the longest-prefix tiebreak handles the rarer case where both
1308
+ * legitimately catalog a tool that resolves to the same fully-namespaced
1309
+ * name.
1310
+ *
1311
+ * Mirrors `lookupMcpToolMetadata` in the Mastra harness so the cross-harness
1312
+ * `serverName` / `annotations` enrichment contract has identical observable
1313
+ * behavior on both harnesses.
1314
+ */
1315
+ function lookupMcpToolMetadata(mcpServers, namespacedToolName) {
1316
+ if (!namespacedToolName.startsWith('mcp__'))
1317
+ return undefined;
1318
+ let best;
1319
+ for (const [serverName, state] of mcpServers) {
1320
+ const prefix = `mcp__${serverName}__`;
1321
+ if (!namespacedToolName.startsWith(prefix))
1322
+ continue;
1323
+ const bareName = namespacedToolName.slice(prefix.length);
1324
+ const tool = state.tools.find((t) => t.name === bareName);
1325
+ if (!tool)
1326
+ continue;
1327
+ if (!best || serverName.length > best.serverName.length) {
1328
+ best = { serverName, tool };
1329
+ }
1330
+ }
1331
+ if (!best)
1332
+ return undefined;
1333
+ return {
1334
+ serverName: best.serverName,
1335
+ ...(best.tool.annotations ? { annotations: best.tool.annotations } : {}),
1336
+ };
1337
+ }
1110
1338
  /**
1111
1339
  * Race a promise against an `AbortSignal` so a wedged operation (e.g. a
1112
1340
  * stuck stdio MCP child during `connect()` or `listTools()`) cannot block
@@ -1195,7 +1423,11 @@ function projectMcpServerStateToInfo(name, state) {
1195
1423
  }
1196
1424
  }
1197
1425
  function mapToolToInfo(serverName, tool) {
1198
- const info = { name: `mcp__${serverName}__${tool.name}` };
1426
+ const info = {
1427
+ name: `mcp__${serverName}__${tool.name}`,
1428
+ serverName,
1429
+ toolName: tool.name,
1430
+ };
1199
1431
  if (tool.description !== undefined && tool.description !== '') {
1200
1432
  info.description = tool.description;
1201
1433
  }