@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.
- package/README.md +20 -9
- package/dist/claude-consumer-tool-bridge.js +15 -0
- package/dist/claude-consumer-tool-bridge.js.map +1 -1
- package/dist/claude-event-adapter.d.ts +26 -2
- package/dist/claude-event-adapter.js +72 -3
- package/dist/claude-event-adapter.js.map +1 -1
- package/dist/claude-harness.d.ts +42 -3
- package/dist/claude-harness.js +284 -52
- package/dist/claude-harness.js.map +1 -1
- package/dist/claude-mcp-bridge.d.ts +8 -2
- package/dist/claude-mcp-bridge.js +21 -5
- package/dist/claude-mcp-bridge.js.map +1 -1
- package/dist/claude-query-options-builder.d.ts +2 -2
- package/dist/claude-query-options-builder.js +25 -15
- package/dist/claude-query-options-builder.js.map +1 -1
- package/dist/claude-skill-mcp-server.js +5 -0
- package/dist/claude-skill-mcp-server.js.map +1 -1
- package/dist/claude-stream-broker.d.ts +170 -5
- package/dist/claude-stream-broker.js +313 -19
- package/dist/claude-stream-broker.js.map +1 -1
- package/dist/mcp-meta-keys.d.ts +15 -0
- package/dist/mcp-meta-keys.js +20 -0
- package/dist/mcp-meta-keys.js.map +1 -0
- package/dist/test/tsconfig.tsbuildinfo +1 -1
- package/dist/transcript.js +20 -0
- package/dist/transcript.js.map +1 -1
- package/package.json +4 -8
package/dist/claude-harness.js
CHANGED
|
@@ -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:
|
|
477
|
-
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.
|
|
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
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
618
|
-
try {
|
|
619
|
-
summaryQuery.close?.();
|
|
660
|
+
catch (err) {
|
|
661
|
+
upstreamThrow = err;
|
|
620
662
|
}
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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?.
|
|
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
|
-
|
|
829
|
+
const onAbort = () => {
|
|
708
830
|
controller.abort();
|
|
709
831
|
broker?.abort();
|
|
710
832
|
consumerToolBroker?.abort();
|
|
711
|
-
}
|
|
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:
|
|
721
|
-
contextHistory: threadState.contextHistory,
|
|
860
|
+
pendingContext: [],
|
|
861
|
+
contextHistory: threadState.contextHistory.map(normalizeMessageForSidecar),
|
|
722
862
|
});
|
|
723
863
|
}
|
|
724
|
-
const queryResult = query({ prompt
|
|
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
|
-
|
|
767
|
-
for (const
|
|
768
|
-
|
|
769
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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 :
|
|
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 = {
|
|
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
|
}
|