@salesforce/sfdx-agent-sdk 0.15.0 → 0.17.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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@salesforce/sfdx-agent-sdk` are documented in this file.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.17.0] - 2026-06-09
7
+
8
+ ### Features
9
+ - **release**: auto-generate per-package CHANGELOG.md on publish ([#567](https://github.com/forcedotcom/agentic-dx/pull/567))
10
+ - unify tool-exposure policy under toolSearch.alwaysActive ([#566](https://github.com/forcedotcom/agentic-dx/pull/566))
11
+ - add ClaudeAgentConfig.skillSearch + decouple Mastra skillSearch from toolSearch ([#563](https://github.com/forcedotcom/agentic-dx/pull/563))
12
+ - **agent-sdk**: preserve MCP clients across updateAgentConfig ([#560](https://github.com/forcedotcom/agentic-dx/pull/560))
13
+ - **agent-sdk,harness-claude,harness-mastra**: first-class tool-result redaction ([#546](https://github.com/forcedotcom/agentic-dx/pull/546))
14
+
15
+ ### Fixes
16
+ - **harness-mastra**: honor MCPServerConfig.alwaysLoad when toolSearch is set ([#558](https://github.com/forcedotcom/agentic-dx/pull/558))
17
+
18
+ ### Chores
19
+ - **deps-dev**: bump the eslint group across 1 directory with 2 updates ([#553](https://github.com/forcedotcom/agentic-dx/pull/553))
20
+ - **deps-dev**: bump the dev-dependencies group across 1 directory with 4 updates ([#536](https://github.com/forcedotcom/agentic-dx/pull/536))
21
+ - **deps-dev**: bump the vitest group across 1 directory with 3 updates ([#552](https://github.com/forcedotcom/agentic-dx/pull/552))
22
+
package/README.md CHANGED
@@ -47,7 +47,8 @@ for await (const event of eventStream) {
47
47
  process.stdout.write(event.text);
48
48
  } else if (event.type === 'error') {
49
49
  console.error(event.error.message);
50
- break;
50
+ // Don't break — the SDK invariant guarantees a synthetic
51
+ // `FinishEvent('error')` follows; the loop exits naturally next tick.
51
52
  }
52
53
  }
53
54
 
@@ -58,14 +59,19 @@ await manager.shutdown();
58
59
 
59
60
  ## API Reference
60
61
 
61
- ### `createAgentManager<F>(storageRootFolder, harnessFactory, connectivityResolver?): Promise<AgentManager<H>>`
62
+ ### `createAgentManager<F>(storageRootFolder, harnessFactory, options?): Promise<AgentManager<H>>`
62
63
 
63
64
  Factory function that creates an `AgentManager` backed by the provided `HarnessFactory`. The `storageRootFolder` must be
64
65
  an existing directory and is used for persistent state (the harness's runtime data plus the SDK's per-agent identity
65
66
  files at `${storageRootFolder}/agents/<id>.json`). The SDK verifies that the constructed harness uses a supported
66
- protocol version, replays any persisted agents the harness can still serve, and returns the manager. The optional
67
- `connectivityResolver` overrides the default sf-CLI-based org resolution — used by e2e tests and custom-auth
68
- deployments; production callers leave it unset.
67
+ protocol version, replays any persisted agents the harness can still serve, and returns the manager.
68
+
69
+ The third-positional `options` bag carries per-manager opt-ins. Production callers typically leave it unset:
70
+
71
+ | Option | Type | Purpose |
72
+ | ---------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
+ | `connectivityResolver` | `AgentConnectivityResolver` | Overrides the default sf-CLI-based org resolution — used by e2e tests and custom-auth deployments. |
74
+ | `hooksForAgent` | `HooksForAgent` | Sync callback resolving a per-agent `AgentHooks` bag (today carries `onToolResult`). Invoked once per `createAgent`, boot-time restore, and `Agent.updateAgentConfig`. See "Tool-Result Redaction" below. |
69
75
 
70
76
  The harness type `H` is **inferred from the factory's `create()` return type**, so consumers don't pass an explicit type
71
77
  argument:
@@ -152,31 +158,33 @@ keeps unparameterized call sites working.
152
158
 
153
159
  A single conversation thread.
154
160
 
155
- | Method | Signature | Description |
156
- | ------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
157
- | `getId` | `() => string` | Session/thread identifier. |
158
- | `chat` | `(message: string, options?: ChatOptions) => Promise<ChatStreamResult>` | Send a message and stream the response. |
159
- | `submitToolResult` | `(toolResult: ToolResultInfo) => Promise<ChatStreamResult>` | Return a consumer-executed tool result and resume the stream. |
160
- | `approveToolCall` | `(toolCallId: string, options?: { remember?: boolean }) => Promise<ChatStreamResult>` | Approve a pending tool call. |
161
- | `declineToolCall` | `(toolCallId: string) => Promise<ChatStreamResult>` | Decline a pending tool call. |
162
- | `getMessageHistory` | `() => Promise<Message[]>` | Retrieve all messages in chronological order. |
163
- | `clearHistory` | `() => Promise<void>` | Delete all messages. |
164
- | `getContextUsage` | `() => ContextUsage` | Snapshot of how much of the model's context window the most recent turn used. |
165
- | `addContext` | `(message: string \| Message[]) => Promise<void>` | Inject context without triggering an LLM response. |
166
- | `subscribe` | `(callback: (event: ChatEvent) => void) => void` | Register a real-time event listener. |
167
- | `unsubscribe` | `(callback: (event: ChatEvent) => void) => void` | Remove a listener. |
168
- | `onTelemetry` | `(callback: TelemetryEventCallback) => Unsubscribe` | Subscribe to telemetry scoped to this session. |
169
- | `onLog` | `(callback: (record: LogRecord) => void) => Unsubscribe` | Subscribe to logs scoped to this session. |
170
- | `dispose` | `() => void` | Release session-level event resources. Idempotent. |
161
+ | Method | Signature | Description |
162
+ | ------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
163
+ | `getId` | `() => string` | Session/thread identifier. |
164
+ | `chat` | `(message: string, options?: ChatOptions) => Promise<ChatStreamResult>` | Send a message and stream the response. The returned `eventStream` is the single iterator for the entire chat turn. |
165
+ | `submitToolResult` | `(toolResult: ToolResultInfo) => Promise<void>` | Return a consumer-executed tool result. Control message on the existing turn — post-resume events flow on the same stream. |
166
+ | `approveToolCall` | `(toolCallId: string, options?: { remember?: boolean }) => Promise<void>` | Approve a pending tool call. Control message on the existing turn — post-resume events flow on the same stream. |
167
+ | `declineToolCall` | `(toolCallId: string) => Promise<void>` | Decline a pending tool call. Control message on the existing turn — post-resume events flow on the same stream. |
168
+ | `getMessageHistory` | `() => Promise<Message[]>` | Retrieve all messages in chronological order. |
169
+ | `clearHistory` | `() => Promise<void>` | Delete all messages. |
170
+ | `getContextUsage` | `() => ContextUsage` | Snapshot of how much of the model's context window the most recent turn used. |
171
+ | `addContext` | `(message: string \| Message[]) => Promise<void>` | Inject context without triggering an LLM response. |
172
+ | `subscribe` | `(callback: (event: ChatEvent) => void) => void` | Register a real-time event listener. |
173
+ | `unsubscribe` | `(callback: (event: ChatEvent) => void) => void` | Remove a listener. |
174
+ | `onTelemetry` | `(callback: TelemetryEventCallback) => Unsubscribe` | Subscribe to telemetry scoped to this session. |
175
+ | `onLog` | `(callback: (record: LogRecord) => void) => Unsubscribe` | Subscribe to logs scoped to this session. |
176
+ | `dispose` | `() => void` | Release session-level event resources. Idempotent. |
171
177
 
172
178
  ### `ChatStreamResult`
173
179
 
174
- Returned by `chat()`, `submitToolResult()`, `approveToolCall()`, and `declineToolCall()`.
180
+ Returned by `chat()`. The single `eventStream` covers the entire chat turn — including post-resume events from
181
+ `submitToolResult` / `approveToolCall` / `declineToolCall`. Settle methods return `Promise<void>`; the consumer keeps
182
+ iterating the same `eventStream` until it sees a terminal `finish` event.
175
183
 
176
- | Property | Type | Description |
177
- | ------------- | --------------------------- | --------------------------------------- |
178
- | `eventStream` | `AsyncGenerator<ChatEvent>` | Full lifecycle event stream. |
179
- | `textStream` | `AsyncGenerator<string>` | Convenience stream of text-only tokens. |
184
+ | Property | Type | Description |
185
+ | ------------- | --------------------------- | ---------------------------------------------------------------------------- |
186
+ | `eventStream` | `AsyncGenerator<ChatEvent>` | Full lifecycle event stream for the turn (one stream per `chat()` call). |
187
+ | `textStream` | `AsyncGenerator<string>` | Convenience stream of text-only tokens, derived from the same `eventStream`. |
180
188
 
181
189
  ### `ChatEvent`
182
190
 
@@ -237,7 +245,6 @@ type MCPStdioServerConfig = {
237
245
  env?: Record<string, string>;
238
246
  enabled?: boolean;
239
247
  timeout?: number;
240
- alwaysLoad?: boolean;
241
248
  };
242
249
 
243
250
  // Remote server (HTTP/SSE)
@@ -253,16 +260,13 @@ type MCPRemoteServerConfig = {
253
260
  maxReconnectionDelay?: number;
254
261
  reconnectionDelayGrowFactor?: number;
255
262
  };
256
- alwaysLoad?: boolean;
257
263
  };
258
264
  ```
259
265
 
260
- **`alwaysLoad`** opts a server's tool surface out of the active runtime's tool-search deferral. Default (`undefined` /
261
- `false`) lets the runtime defer the server's tools behind a tool-search round-trip when the global tool surface is
262
- large; `true` registers every tool from this server with the model up-front. Useful for small, discovery-critical
263
- surfaces (≤ a few tools the model needs to find without prompting). The Claude harness honors the flag by stamping
264
- `_meta['anthropic/alwaysLoad'] = true` on each forwarded tool (equivalent to `defer_loading: false` on the Claude API).
265
- The Mastra harness eager-loads all MCP tools regardless, so the flag is a no-op there.
266
+ **Tool-exposure policy** (which tools bypass the active runtime's tool-search deferral) is configured per-agent on the
267
+ harness extension surface, not per-server here. See `MastraAgentConfig.toolSearch.alwaysActive` and
268
+ `ClaudeAgentConfig.toolSearch.alwaysActive` for the entry shape that covers "all tools from server X", "tool Y on server
269
+ X", and "tool Y from any source".
266
270
 
267
271
  **`reconnectionOptions`** tunes the HTTP MCP transport's retry / backoff behavior. Forwarded to the underlying SDK
268
272
  transport on both harnesses (Claude's `@modelcontextprotocol/sdk` `StreamableHTTPClientTransport` and Mastra's
@@ -560,17 +564,17 @@ try {
560
564
 
561
565
  #### Streaming method failures
562
566
 
563
- The four streaming methods on `ChatSession` `chat()`, `submitToolResult()`, `approveToolCall()`, and
564
- `declineToolCall()` share a single failure contract. Subscribers registered via `subscribe()` are guaranteed to
567
+ `chat()` opens a turn; `submitToolResult()` / `approveToolCall()` / `declineToolCall()` are control messages on the
568
+ existing turn. All four share a single failure contract. Subscribers registered via `subscribe()` are guaranteed to
565
569
  receive a terminal event for every turn:
566
570
 
567
- - **Pre-stream failure** (the harness rejects before producing a stream): subscribers receive an `ErrorEvent` followed
568
- by a `FinishEvent(finishReason: 'error')`, and the returned promise then rejects with the original error. Wrap the
569
- call in `try/catch` if you need to handle this in the caller.
570
- - **In-stream failure** (the stream yields an `error` event or the underlying generator throws): the `eventStream`
571
- yields the `ErrorEvent` (synthesizing one from a thrown exception if needed) and, if no `FinishEvent` was already
572
- emitted, appends a synthetic `FinishEvent(finishReason: 'error')` at the end. The returned promise resolves normally;
573
- iterate the stream to observe the failure.
571
+ - **Pre-stream failure** (the harness rejects before producing a stream, or a settle call rejects before it routes the
572
+ decision): subscribers receive an `ErrorEvent` followed by a `FinishEvent(finishReason: 'error')`, and the returned
573
+ promise then rejects with the original error. Wrap the call in `try/catch` if you need to handle this in the caller.
574
+ - **In-stream failure** (the active `eventStream` yields an `error` event or the underlying generator throws): the
575
+ stream yields the `ErrorEvent` (synthesizing one from a thrown exception if needed) and, if no `FinishEvent` was
576
+ already emitted, appends a synthetic `FinishEvent(finishReason: 'error')` at the end. The originating `chat()` promise
577
+ resolves normally; iterate the stream to observe the failure.
574
578
  - **Calling a disposed session** throws `AgentSDKError('DISPOSED')` synchronously without notifying subscribers.
575
579
 
576
580
  ### MCP Server Configuration
@@ -613,99 +617,286 @@ reparsing namespaced tool names. `annotations` is `undefined` when the source di
613
617
  `requireToolApproval` accepts a `boolean` (`true` is shorthand for `'serial'`) or one of the mode strings exposed via
614
618
  the `ToolApprovalMode` type alias (`'serial' | 'batch'`):
615
619
 
616
- - **`'serial'`** (the safe default) — each stream surfaces one `tool-approval-request` at a time. The next request, if
617
- any, appears on the continuation stream returned by `approveToolCall` / `declineToolCall`. Works with both consumer
618
- iterator patterns below.
619
- - **`'batch'`** when the model emits parallel `tool_use` blocks, all approval-requests surface on the same stream so
620
- the consumer can render a batch-approval card. **Pattern A iterators only** — a `break`-on-first-approval loop will
621
- miss the subsequent approvals and hang the chat. Opt into `'batch'` only after the consumer iterator collects all
622
- approvals before settling.
620
+ - **`'serial'`** (the safe default) — `tool-approval-request` events surface one at a time. The next request (if any)
621
+ arrives later on the same `eventStream` after the consumer settles the current one.
622
+ - **`'batch'`** — when the model emits parallel `tool_use` blocks, all approval-requests for that batch surface on the
623
+ same `eventStream` together, so the consumer can render a batch-approval card. The consumer must gather all approvals
624
+ before settling — a `break`-on-first-approval loop will hang the turn because Mastra/Claude won't continue emitting
625
+ tool-results until the consumer has settled the entire batch.
623
626
 
624
627
  The SDK also exports `resolveToolApprovalMode(value)` — the canonical
625
628
  `boolean | ToolApprovalMode | undefined → ToolApprovalMode | undefined` normalizer harness implementations should use to
626
629
  dispatch (`undefined` / `false` → `undefined`, `true` → `'serial'`, strings pass through). Reject unknown strings via
627
630
  `throw` instead of reimplementing the boolean-vs-string branch — the helper's defensive throw catches `as any` consumers
628
- passing invalid values that would otherwise silently degrade to "approval gating on but no broker allocated."
631
+ passing invalid values that would otherwise silently degrade to "approval gating on but no coordinator allocated."
632
+
633
+ #### Single stream per turn
634
+
635
+ Issue [#529](https://github.com/forcedotcom/agentic-dx/issues/529) settled the harness contract: **one `eventStream` per
636
+ chat turn.** Settle calls (`approveToolCall` / `declineToolCall` / `submitToolResult`) return `Promise<void>` and behave
637
+ as control messages on the existing turn; the post-resume events (`tool-result`, follow-up `text-delta`, `step-finish`,
638
+ terminal `finish`) flow through the **same** `eventStream` the `chat()` call returned. The consumer should iterate that
639
+ one stream until it sees a terminal `finish` event, calling `approveToolCall` / `declineToolCall` / `submitToolResult`
640
+ in-line as approval-requests / consumer-tool-calls arrive.
629
641
 
630
- > **Glossary:** _Pattern A_ = "collect-all-then-settle" (iterate until natural park, gather all approval-requests, then
631
- > call `approveToolCall`/`declineToolCall`). _Pattern B_ = "return-on-first-approval" (settle the first approval
632
- > mid-iteration, then re-iterate the continuation stream). `'serial'` works with either; `'batch'` requires Pattern A.
642
+ #### Pattern: settle in-line, continue iterating
633
643
 
634
- #### Pattern B — return on first approval (works with `'serial'`)
644
+ The same shape works for `'serial'` and `'batch'` mode:
635
645
 
636
646
  ```typescript
637
647
  const { eventStream } = await session.chat('Run the deployment', {
638
- requireToolApproval: true, // or 'serial'
648
+ requireToolApproval: true, // or 'serial' / 'batch'
639
649
  });
640
650
 
641
651
  for await (const event of eventStream) {
642
652
  if (event.type === 'tool-approval-request') {
643
653
  const approved = await promptUser(event);
644
- const continuation = approved
645
- ? await session.approveToolCall(event.toolCall.toolCallId)
646
- : await session.declineToolCall(event.toolCall.toolCallId);
647
- for await (const e of continuation.eventStream) {
648
- // process continuation
654
+ if (approved) {
655
+ await session.approveToolCall(event.toolCall.toolCallId);
656
+ } else {
657
+ await session.declineToolCall(event.toolCall.toolCallId);
649
658
  }
650
- break; // safe: serial mode surfaces at most one approval per stream
659
+ // Do NOT break keep iterating; tool-result and follow-up text arrive on the same stream.
660
+ } else if (event.type === 'tool-call' && event.serverName === undefined && isConsumerTool(event.toolName)) {
661
+ // Consumer-executed tool: run locally, return the result via submitToolResult.
662
+ const result = await runLocally(event.args);
663
+ await session.submitToolResult({
664
+ toolCallId: event.toolCallId,
665
+ toolName: event.toolName,
666
+ result,
667
+ });
668
+ } else if (event.type === 'text-delta') {
669
+ process.stdout.write(event.text);
670
+ } else if (event.type === 'finish') {
671
+ // Don't break on `'error'` directly — the SDK invariant guarantees an
672
+ // `error` event is followed by a synthetic `FinishEvent('error')` so
673
+ // the loop exits naturally on the next iteration. Breaking on `error`
674
+ // would finalize the iterator before the synthetic `finish` lands and
675
+ // bypass any per-turn cleanup the harness wires onto the source's
676
+ // close/return path.
677
+ break;
651
678
  }
652
679
  }
653
680
  ```
654
681
 
655
- #### Pattern A collect all approvals, then settle (required for `'batch'`)
682
+ #### Pattern variant: batch-collect approvals before settling
683
+
684
+ Same single-stream loop, just gather the batch before deciding (useful for "approve these N tools?" UI cards under
685
+ `requireToolApproval: 'batch'`):
656
686
 
657
687
  ```typescript
658
688
  const { eventStream } = await session.chat('Run the deployment', {
659
689
  requireToolApproval: 'batch',
660
690
  });
661
691
 
662
- const requests: ToolApprovalRequestEvent[] = [];
692
+ const pendingBatch: ToolApprovalRequestEvent[] = [];
693
+
663
694
  for await (const event of eventStream) {
664
695
  if (event.type === 'tool-approval-request') {
665
- requests.push(event); // do NOT break — collect the whole parallel batch
696
+ pendingBatch.push(event);
697
+ // Heuristic: settle the batch on a quiet window OR after the expected count.
698
+ // Don't break — the harness needs us to settle so it can continue.
699
+ continue;
666
700
  }
667
- if (event.type === 'finish' || event.type === 'error') break;
668
- }
669
-
670
- // Render a batch-approval card; settle each decision.
671
- const decisions = await promptUserForBatch(requests);
672
- let continuation: ChatStreamResult | undefined;
673
- for (const event of requests) {
674
- continuation = decisions.get(event.toolCall.toolCallId)
675
- ? await session.approveToolCall(event.toolCall.toolCallId)
676
- : await session.declineToolCall(event.toolCall.toolCallId);
677
- }
678
- if (continuation) {
679
- for await (const e of continuation.eventStream) {
680
- // process the model's follow-up turn
701
+ if (event.type === 'tool-call') {
702
+ // The harness emits `tool-approval-request` BEFORE `tool-call` in batch mode.
703
+ // Once we see a tool-call, the batch for this assistant message is complete.
704
+ if (pendingBatch.length > 0) {
705
+ const decisions = await promptUserForBatch(pendingBatch);
706
+ await Promise.all(
707
+ pendingBatch.map((req) =>
708
+ decisions.get(req.toolCall.toolCallId)
709
+ ? session.approveToolCall(req.toolCall.toolCallId)
710
+ : session.declineToolCall(req.toolCall.toolCallId),
711
+ ),
712
+ );
713
+ pendingBatch.length = 0;
714
+ }
715
+ } else if (event.type === 'text-delta') {
716
+ process.stdout.write(event.text);
717
+ } else if (event.type === 'finish') {
718
+ // Don't break on `'error'` directly — the SDK invariant guarantees an
719
+ // `error` event is followed by a synthetic `FinishEvent('error')` so
720
+ // the loop exits naturally on the next iteration. Breaking on `error`
721
+ // would finalize the iterator before the synthetic `finish` lands and
722
+ // bypass any per-turn cleanup the harness wires onto the source's
723
+ // close/return path.
724
+ break;
681
725
  }
682
726
  }
683
727
  ```
684
728
 
685
- #### `textStream` and `'batch'` mode
686
-
687
- `ChatStreamResult.textStream` is empty on the initial stream when `requireToolApproval: 'batch'` surfaces approval
688
- requests the model emitted `tool_use` blocks, not text. Read text deltas from the continuation stream returned by
689
- `approveToolCall` / `declineToolCall` (the model's follow-up turn after tool execution).
729
+ > **AsyncIterator gotcha:** the `for await` loop is the supported way to drain `eventStream`. If you instead drive the
730
+ > iterator manually (`iter.next()`), call **at most one** `next()` at a time and `await` the result before the next
731
+ > call. Calling `iter.next()` twice without awaiting the first orphans the first request — JS settles `.next()` calls
732
+ > FIFO, so the next yielded value satisfies the orphaned request and is invisible to the caller. Stick to `for await`
733
+ > unless you have a specific reason not to.
690
734
 
691
735
  ### Consumer-Executed Tools
692
736
 
737
+ Tools declared via `AgentConfig.tools` (no `execute` function) run on the consumer side: the model emits a `tool-call`
738
+ event, the consumer runs the tool locally, then calls `session.submitToolResult(...)` to feed the result back.
739
+ `submitToolResult` returns `Promise<void>`; the model's follow-up turn (and any subsequent `tool-call`s) flows through
740
+ the same `eventStream`:
741
+
693
742
  ```typescript
743
+ const { eventStream } = await session.chat('Look up the customer record for tenant T-42.');
744
+
694
745
  for await (const event of eventStream) {
695
- if (event.type === 'tool-call' && event.toolName === 'my-tool') {
746
+ if (event.type === 'tool-call' && event.toolName === 'lookup_customer') {
696
747
  const result = await runLocally(event.args);
697
- const continuation = await session.submitToolResult({
748
+ await session.submitToolResult({
698
749
  toolCallId: event.toolCallId,
699
750
  toolName: event.toolName,
700
751
  result,
701
752
  });
702
- for await (const e of continuation.eventStream) {
703
- // process continuation
704
- }
753
+ // Do NOT break — the model's follow-up turn (text, more tool-calls, finish) arrives on the same stream.
754
+ } else if (event.type === 'text-delta') {
755
+ process.stdout.write(event.text);
756
+ } else if (event.type === 'finish') {
757
+ // Don't break on `'error'` directly — the SDK invariant guarantees an
758
+ // `error` event is followed by a synthetic `FinishEvent('error')` so
759
+ // the loop exits naturally on the next iteration. Breaking on `error`
760
+ // would finalize the iterator before the synthetic `finish` lands and
761
+ // bypass any per-turn cleanup the harness wires onto the source's
762
+ // close/return path.
763
+ break;
705
764
  }
706
765
  }
707
766
  ```
708
767
 
768
+ When `requireToolApproval: true` is also set, consumer-executed tools bypass the approval gate by construction (per
769
+ `StreamOptions.requireToolApproval` JSDoc). They surface as a normal `tool-call` event without a preceding
770
+ `tool-approval-request`. Built-in / MCP tools still gate normally.
771
+
772
+ ### Tool-Result Redaction
773
+
774
+ Secrets in tool output (a live `accessToken` from `sf org list --json`, an API key in `Bash` stdout, a JWT in an MCP
775
+ response) must be scrubbed **before** the result enters the model's context. Once the model has seen a value it can echo
776
+ it in a reply, route it into a later tool call (`Bash` arg, file write), or send it to provider logs — nothing
777
+ downstream (UI scrubbing, transcript redaction) can undo that.
778
+
779
+ The SDK exposes a harness-agnostic redactor type and a per-agent hooks bag (`AgentHooks`). Pass a `hooksForAgent`
780
+ callback to `createAgentManager`; the SDK invokes it once per agent install (`createAgent`, boot-time restore,
781
+ `Agent.updateAgentConfig`), threads the resolved bag through to whichever harness you're using, and the harness wires
782
+ `onToolResult` to its native seam (Claude Agent SDK `PostToolUse` hook, Mastra `processInputStep`). The same redactor
783
+ function works on both harnesses.
784
+
785
+ ```ts
786
+ import {
787
+ createAgentManager,
788
+ type AgentHooks,
789
+ type HooksForAgent,
790
+ type ToolResultRedactor,
791
+ } from '@salesforce/sfdx-agent-sdk';
792
+
793
+ // Optional: wrap your redactor so an exception substitutes a safe stub
794
+ // instead of propagating. The SDK does NOT do this for you — see
795
+ // "Throw policy" below.
796
+ const REDACTION_FAILURE_STUB = '[redaction failed — original withheld]';
797
+
798
+ const failClosed =
799
+ (inner: ToolResultRedactor): ToolResultRedactor =>
800
+ async (input) => {
801
+ try {
802
+ return await inner(input);
803
+ } catch (err) {
804
+ auditLog.warn('redaction-failed', { toolName: input.toolName, toolCallId: input.toolCallId, err });
805
+ return { output: REDACTION_FAILURE_STUB };
806
+ }
807
+ };
808
+
809
+ const baseRedactor: ToolResultRedactor = ({ toolName, output }) => {
810
+ // Bash needs its native shape preserved (see "Bash gotcha" below).
811
+ if (toolName === 'Bash') {
812
+ const bash = output as { stdout: string; stderr: string; interrupted: boolean };
813
+ return { output: { ...bash, stdout: scrub(bash.stdout), stderr: scrub(bash.stderr) } };
814
+ }
815
+ // Pass-through for non-secret-bearing tools — return undefined.
816
+ if (!mayContainSecrets(toolName)) return;
817
+ return { output: scrubDeep(output) };
818
+ };
819
+
820
+ const hooksForAgent: HooksForAgent = (agentId, config): AgentHooks => ({
821
+ onToolResult: failClosed(baseRedactor),
822
+ });
823
+
824
+ const manager = await createAgentManager(storage, factory, { hooksForAgent });
825
+ ```
826
+
827
+ #### `ToolResultRedactor`
828
+
829
+ Sync-or-async callback invoked once per tool result before the model sees it. Returning `{ output }` replaces the value;
830
+ returning `undefined` passes through unchanged.
831
+
832
+ ```ts
833
+ type ToolResultRedactor = (
834
+ input: ToolResultRedactionInput,
835
+ ) => ToolResultRedactionResult | Promise<ToolResultRedactionResult>;
836
+
837
+ type ToolResultRedactionInput = {
838
+ agentId: string;
839
+ threadId: string;
840
+ toolCallId: string;
841
+ toolName: string;
842
+ serverName?: string; // Originating MCP server when applicable.
843
+ output: unknown; // Raw upstream output, unmodified.
844
+ isError: boolean;
845
+ };
846
+
847
+ type ToolResultRedactionResult = { output: unknown } | undefined;
848
+ ```
849
+
850
+ The redactor fires for every tool result type:
851
+
852
+ - **Built-in tools** (`Bash`, `Read`, `Edit`, `Glob`, `Grep`, …) on Claude.
853
+ - **MCP tools** on either harness (with `serverName` populated from the agent's MCP catalog).
854
+ - **Consumer-executed tools** declared via `AgentConfig.tools` on either harness.
855
+
856
+ #### `AgentHooks` and `HooksForAgent`
857
+
858
+ `AgentHooks` is a forward-compatible bag — today it carries `onToolResult`; future hooks (`onToolCall`, `onStep`, …)
859
+ will land on the same shape without churning factory configs. The SDK and harnesses treat unknown fields as opaque, so
860
+ adding a new hook is non-breaking.
861
+
862
+ ```ts
863
+ type AgentHooks = { onToolResult?: ToolResultRedactor };
864
+ type HooksForAgent = (agentId: string, config: AgentConfig) => AgentHooks;
865
+ ```
866
+
867
+ `hooksForAgent` is sync — the SDK does not await it. Consumers needing async setup (e.g. a remote feature flag
868
+ controlling who gets redaction) pre-resolve before calling `createAgentManager`. The callback receives the agent's id
869
+ and the persisted `AgentConfig`, so per-agent variation can branch on either.
870
+
871
+ #### Audit / preserving the original
872
+
873
+ The redactor receives the unmodified `output` at the call site. Consumers needing an audit trail of the original value
874
+ log it themselves before returning the redaction. The SDK does not put the original on its telemetry bus or persist it
875
+ anywhere — that would defeat the point.
876
+
877
+ #### Throw policy
878
+
879
+ The SDK does NOT own fail-closed semantics. If your redactor throws, the harness propagates: Claude routes through the
880
+ Claude Agent SDK's `PostToolUse` hook-error path (which synthesizes `tool_result(is_error=true)` so the failure is
881
+ observable); Mastra propagates from `processInputStep` and surfaces as an `error` ChatEvent on the consumer's
882
+ eventStream. Either way, the original output never reaches the model. Wrap your redactor in `try`/`catch` (see the
883
+ `failClosed` helper above) when you want a richer fail-closed substitute.
884
+
885
+ #### Bash gotcha (Claude only)
886
+
887
+ Claude's built-in `Bash` tool expects responses to keep the `{ stdout, stderr, interrupted }` shape. A bare-string
888
+ return is rejected by the Claude Agent SDK and the original value leaks. The harness does NOT validate this — the
889
+ redactor knows what tool it is redacting.
890
+
891
+ For MCP tools, the replacement must be a valid `CallToolResult` shape (`{ content: [...], isError? }`). For
892
+ consumer-executed tools, any shape the consumer accepts is fine.
893
+
894
+ #### Composition with consumer-supplied hooks (Claude only)
895
+
896
+ If you also register a `PostToolUse` hook via `ClaudeQueryDefaults.hooks.PostToolUse`, both your hook and the harness's
897
+ redaction hook fire — the harness hook is appended **last** in `options.hooks.PostToolUse`, so its `updatedToolOutput`
898
+ is what actually replaces the value the model sees.
899
+
709
900
  ### Connectivity Resolution
710
901
 
711
902
  #### `ResolvedConnectivity`
@@ -843,15 +1034,37 @@ The SDK ships no harness implementation. Consumers pick one by passing a `Harnes
843
1034
  [`@salesforce/sfdx-agent-harness-mastra`](../sfdx-agent-harness-mastra); additional harnesses can ship as independent
844
1035
  npm packages that depend on this SDK as a `peerDependency`.
845
1036
 
846
- Harness authors implement two interfaces and can compose one helper class, all exported from this package:
847
-
848
- | Export | Role |
849
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
850
- | `HarnessFactory<H>` | Construct a harness of type `H` bound to a storage root. Declares `harnessId` and `protocolVersion`. Default `H = AgentHarness`. |
851
- | `AgentHarness` | Runtime contract: agent / thread / stream / tool / message lifecycle. Declares its own `harnessId` and `protocolVersion`. |
852
- | `SUPPORTED_PROTOCOL_VERSIONS` | Readonly list of harness protocol versions this SDK accepts. `createAgentManager` checks both the factory and the constructed harness. |
853
- | `HarnessBusOwner` | Composition helper owning telemetry + log buses with `dispose()` semantics. Reuse it instead of reimplementing bus plumbing. |
854
- | `lowerStreamInput` | Validates a `MessagePart[]` and lowers each input part to your runtime's content-block shape. Use it in `stream()` so multimodal caps and `MULTIMODAL_NOT_SUPPORTED` / `INVALID_MESSAGE_CONTENT` semantics match every other harness. |
1037
+ ### Two import surfaces
1038
+
1039
+ This package publishes two ESM entry points:
1040
+
1041
+ - **`@salesforce/sfdx-agent-sdk`** consumer surface. Everything an application calling `createAgentManager` needs:
1042
+ `Agent`, `ChatSession`, `ChatEvent`, `MessagePart`, `AgentConfig`, `AgentSDKError`, etc. The harness contract
1043
+ interfaces (`AgentHarness`, `HarnessFactory`, `WithAgentConfig`, `ConfigOf`) also appear here as **type-only**
1044
+ references so consumer code can write `AgentManager<H extends AgentHarness>` without reaching into the
1045
+ harness-implementation surface.
1046
+ - **`@salesforce/sfdx-agent-sdk/harness`** — harness-implementation surface. Harness authors import the contract types
1047
+ and the helpers from here. Consumer applications cannot reach these symbols from the bare specifier — the bundler / TS
1048
+ resolver errors at the import site, which is the right outcome for any consumer who reaches for a primitive they don't
1049
+ need.
1050
+
1051
+ > **Build-tool requirement:** the `/harness` subpath is resolved via the `package.json` `exports` field, which requires
1052
+ > `moduleResolution: "node16" | "nodenext" | "bundler"` in `tsconfig.json`. Legacy `"node"` resolution silently won't
1053
+ > see the subpath. Modern bundlers (Vite, esbuild, Webpack 5+, tsup, Rollup with `@rollup/plugin-node-resolve` v15+)
1054
+ > resolve it natively. This is a harness-author concern only; consumer applications never touch the subpath.
1055
+
1056
+ | Export | Surface | Role |
1057
+ | ----------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1058
+ | `HarnessFactory<H>` | Type only on bare; value+type on `/harness` | Construct a harness of type `H` bound to a storage root. Declares `harnessId` and `protocolVersion`. Default `H = AgentHarness`. |
1059
+ | `AgentHarness` | Type only on bare; type on `/harness` | Runtime contract: agent / thread / stream / tool / message lifecycle. Declares its own `harnessId` and `protocolVersion`. |
1060
+ | `SUPPORTED_PROTOCOL_VERSIONS` | `/harness` only | Readonly list of harness protocol versions this SDK accepts. `createAgentManager` checks both the factory and the constructed harness. |
1061
+ | `HarnessBusOwner` | `/harness` only | Composition helper owning telemetry + log buses with `dispose()` semantics. Reuse it instead of reimplementing bus plumbing. |
1062
+ | `lowerStreamInput` | `/harness` only | Validates a `MessagePart[]` and lowers each input part to your runtime's content-block shape. Use it in `stream()` so multimodal caps and `MULTIMODAL_NOT_SUPPORTED` / `INVALID_MESSAGE_CONTENT` semantics match every other harness. |
1063
+ | `GenSink<T>` | `/harness` only | Buffered async-generator wrapper for routing `ChatEvent`s to a consumer's `ChatStreamResult.eventStream`. Single-iteration: calling `generator()` twice throws — sinks have one waiter slot and one buffer, two iterators race on both. |
1064
+ | `mcpServerConfigEqual` | Bare specifier and `/harness` | Structural deep-equality predicate over `MCPServerConfig`. Use inside `updateAgent` to decide which servers to preserve vs. cycle. Treats `enabled: undefined` and `enabled: true` as equal; compares URLs via `String(url)` (so `URL` instances and strings round-trip); `headers` and `env` are key-order-insensitive; `reconnectionOptions` compares field-wise. |
1065
+ | `AlwaysActiveEntry` | `/harness` only | Entry shape consumed by per-harness `toolSearch.alwaysActive` extension fields. Three matching patterns: `{ serverName }` (server-wide), `{ serverName, toolName }` (precise), `{ toolName }` (cross-source). At least one of `serverName` / `toolName` must be present. |
1066
+ | `matchesAlwaysActive` | `/harness` only | Predicate `(entries, serverName, toolName) → boolean` consulted per-tool when stamping always-load metadata or partitioning a tool-search pool. Use this instead of pattern-matching entries by hand so harness behavior stays uniform. |
1067
+ | `validateAlwaysActiveEntry` | `/harness` only | Throws on a malformed entry (`{}`, both fields empty). Call once per entry at the harness boundary so a typo fails loud at config time rather than silently dropping the entry on every `stream()`. |
855
1068
 
856
1069
  Minimal skeleton:
857
1070
 
@@ -861,7 +1074,7 @@ import {
861
1074
  type HarnessFactory,
862
1075
  HarnessBusOwner,
863
1076
  SUPPORTED_PROTOCOL_VERSIONS,
864
- } from '@salesforce/sfdx-agent-sdk';
1077
+ } from '@salesforce/sfdx-agent-sdk/harness';
865
1078
 
866
1079
  class MyHarness implements AgentHarness {
867
1080
  readonly harnessId = 'my-harness';