@salesforce/sfdx-agent-harness-mastra 0.15.0 → 0.16.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,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.16.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-claude,harness-mastra**: align event/log payload shapes ([#562](https://github.com/forcedotcom/agentic-dx/pull/562))
17
+ - **harness-mastra**: honor MCPServerConfig.alwaysLoad when toolSearch is set ([#558](https://github.com/forcedotcom/agentic-dx/pull/558))
18
+
19
+ ### Chores
20
+ - **harness-claude,harness-mastra**: re-enable monorepoLintPack at four offender sites ([#561](https://github.com/forcedotcom/agentic-dx/pull/561))
21
+ - **deps-dev**: bump the eslint group across 1 directory with 2 updates ([#553](https://github.com/forcedotcom/agentic-dx/pull/553))
22
+ - **deps**: bump the mastra group across 1 directory with 3 updates ([#539](https://github.com/forcedotcom/agentic-dx/pull/539))
23
+ - **deps-dev**: bump the dev-dependencies group across 1 directory with 4 updates ([#536](https://github.com/forcedotcom/agentic-dx/pull/536))
24
+ - **deps-dev**: bump the vitest group across 1 directory with 3 updates ([#552](https://github.com/forcedotcom/agentic-dx/pull/552))
25
+
package/README.md CHANGED
@@ -62,13 +62,14 @@ These types let consumers reach Mastra-specific features through the SDK's typed
62
62
  `MastraAgentHarness` from `MastraHarnessFactory.create()`, so consumers usually don't reference these types explicitly —
63
63
  they show up via inference at the call site.
64
64
 
65
- | Export | Kind | Description |
66
- | ------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
67
- | `MastraAgentHarness` | type | Branded `AgentHarness` subtype; carries the Mastra-specific `extensions` namespace and the inferred config type. |
68
- | `MastraAgentConfig` | type | `AgentConfig` extended with Mastra-only fields (currently `toolSearch`). Inferred at `manager.createAgent` automatically — annotation rarely needed. |
69
- | `MastraToolSearchConfig` | type | Shape of `MastraAgentConfig.toolSearch` (`pool`, `alwaysActive`, `topK`, `ttl`). |
70
- | `ToolSearchHandle` | type | Stable interface for the tool-search runtime: `id`, `getStateStats`, `clearState`, `clearAllState`, `cleanupNow`. Wraps Mastra's internal processor. |
71
- | `SkillSearchHandle` | type | Stable interface for the skill-search runtime: `id` (Mastra's processor has no other public methods today; the harness owns lifecycle). |
65
+ | Export | Kind | Description |
66
+ | ------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
67
+ | `MastraAgentHarness` | type | Branded `AgentHarness` subtype; carries the Mastra-specific `extensions` namespace and the inferred config type. |
68
+ | `MastraAgentConfig` | type | `AgentConfig` extended with Mastra-only fields (`toolSearch`, `skillSearch`). Inferred at `manager.createAgent` automatically — annotation rarely needed. |
69
+ | `MastraToolSearchConfig` | type | Shape of `MastraAgentConfig.toolSearch` (`pool`, `alwaysActive`, `topK`, `ttl`). |
70
+ | `MastraSkillSearchConfig` | type | Shape of `MastraAgentConfig.skillSearch` (`topK`). Opts into Mastra's `SkillSearchProcessor`. |
71
+ | `ToolSearchHandle` | type | Stable interface for the tool-search runtime: `id`, `getStateStats`, `clearState`, `clearAllState`, `cleanupNow`. Wraps Mastra's internal processor. |
72
+ | `SkillSearchHandle` | type | Stable interface for the skill-search runtime: `id` (Mastra's processor has no other public methods today; the harness owns lifecycle). |
72
73
 
73
74
  The Mastra-specific accessors live on `manager.extensions.mastra` (`getToolSearchProcessor(agentId)`,
74
75
  `getSkillSearchProcessor(agentId)`, `formatMcpToolName(serverName, toolName)`) and require no separate import.
@@ -90,12 +91,13 @@ const agent = await manager.createAgent(projectRoot, {
90
91
  toolSearch: {
91
92
  pool: '*', // or an explicit name list
92
93
  alwaysActive: [
93
- 'lookup_account',
94
- // Build the Mastra-specific namespaced name for an MCP tool that hasn't
95
- // been discovered yet. The output format is harness-specific
96
- // (Mastra: `${server}_${tool}`); for cross-harness tool identification
97
- // anywhere else, prefer the `(serverName, toolName)` pair on `McpToolInfo`.
98
- manager.extensions.mastra.formatMcpToolName('sfdx', 'deploy_metadata'),
94
+ // Every tool from a server (post-discovery expansion).
95
+ { serverName: 'sfdx' },
96
+ // Exactly one tool on a specific server.
97
+ { serverName: 'other', toolName: 'lookup_account' },
98
+ // Any tool by this name, regardless of source — built-ins, workspace
99
+ // tools, consumer-declared tools, AND any MCP server's tool surface.
100
+ { toolName: 'resume_tool_operation' },
99
101
  ],
100
102
  topK: 5, // advisory; defaults to Mastra's 5
101
103
  ttl: 3_600_000, // ms; advisory; defaults to Mastra's 1h
@@ -105,16 +107,35 @@ const agent = await manager.createAgent(projectRoot, {
105
107
 
106
108
  When `toolSearch` is set, `createAgent` blocks on MCP discovery so MCP tools enter the searchable pool. Without
107
109
  `toolSearch`, MCP discovery stays non-blocking (existing behavior). `alwaysActive` tools are visible directly to the
108
- model; everything else lives in the searchable pool. Unknown `alwaysActive` names are kept (for late MCP discovery);
109
- unknown `pool` names are dropped.
110
+ model; everything else lives in the searchable pool.
111
+
112
+ Each `alwaysActive` entry is an `AlwaysActiveEntry` (re-exported from `@salesforce/sfdx-agent-sdk/harness` and shared
113
+ with `ClaudeAgentConfig.toolSearch.alwaysActive`). At least one of `serverName` or `toolName` must be present — `{}`
114
+ rejects at config time. Unknown `pool` names are dropped; unmatched `{ toolName }`-only entries are kept for late MCP
115
+ discovery.
110
116
 
111
117
  `MastraToolSearchConfig.topK` and `ttl` are advisory — Mastra applies its own defaults if omitted.
112
118
 
113
119
  #### Skill search
114
120
 
115
- Skills (`AgentConfig.skills`) load eagerly by default. When `toolSearch` is also set, skills load on demand via
116
- harness-injected `search_skills` / `load_skill` meta-tools (Mastra's `SkillSearchProcessor`). The gating is deliberate —
117
- consumers who opt into `toolSearch` get the same on-demand pattern across tools and skills.
121
+ Skills (`AgentConfig.skills`) load eagerly by default. Set `MastraAgentConfig.skillSearch` to switch to on-demand
122
+ loading via harness-injected `search_skills` / `load_skill` meta-tools (Mastra's `SkillSearchProcessor`):
123
+
124
+ ```ts
125
+ const agent = await manager.createAgent(projectRoot, {
126
+ instructions: '...',
127
+ skills: ['/path/to/skills'],
128
+ skillSearch: { topK: 5 }, // ranked, on-demand discovery; eager catalog stays out of the prompt
129
+ });
130
+ ```
131
+
132
+ `skillSearch.topK` is advisory; Mastra defaults to 5 if omitted. The same `skillSearch` field exists on
133
+ `ClaudeAgentConfig`, so cross-harness consumers can opt into on-demand skill discovery on either harness with the same
134
+ shape.
135
+
136
+ > **Breaking change (#468).** Pre-#468, on-demand skill search was implicitly gated on `skills && toolSearch`. That
137
+ > coupling has been removed: `skillSearch` is now the explicit opt-in, independent of `toolSearch`. Consumers who relied
138
+ > on the coupling must add `skillSearch: {}` to their config to keep the previous behavior.
118
139
 
119
140
  #### Operational handles
120
141
 
@@ -198,6 +219,20 @@ for await (const event of result.eventStream) {
198
219
  }
199
220
  ```
200
221
 
222
+ ### Tool-result redaction
223
+
224
+ Tool-result redaction is configured at the manager layer, not the factory. Pass a `hooksForAgent` callback to
225
+ `createAgentManager`; the SDK threads the resolved `AgentHooks` bag through `createAgent`'s `options.hooks`, and the
226
+ Mastra harness registers a `ToolResultRedactionProcessor` on the agent's `inputProcessors` chain whose
227
+ `processInputStep` walks new tool-result rows on the MessageList and replaces their content in place. The same
228
+ `ToolResultRedactor` works on the Claude harness. See the SDK README → "Tool-Result Redaction" for the full pattern
229
+ (including a `failClosed(...)` wrapper consumers can use to substitute a safe stub on throw).
230
+
231
+ The processor composes with existing harness-internal processors (`ToolSearchProcessor`, `SkillSearchProcessor`); no
232
+ consumer-supplied processor passthrough is needed. Throws propagate from `processInputStep` and the harness's approval
233
+ coordinator surfaces them as an `error` ChatEvent on the consumer's eventStream — see ARCHITECTURE.md for the full
234
+ contract.
235
+
201
236
  ### Using LLM Gateway Express
202
237
 
203
238
  ```ts
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { MastraHarnessFactory, type MastraHarnessFactoryConfig } from './mastra-harness-factory.js';
2
2
  export type { MastraAgentHarness } from './mastra-harness.js';
3
- export { type MastraAgentConfig, type MastraToolSearchConfig, type SkillSearchHandle, type ToolSearchHandle, } from './tool-search.js';
3
+ export { type MastraAgentConfig, type MastraSkillSearchConfig, type MastraToolSearchConfig, type SkillSearchHandle, type ToolSearchHandle, } from './tool-search.js';
4
4
  export type { MastraModelConfigFactory, ResolvedModelConfig } from './model-config-factory.js';
5
5
  export { DefaultMastraModelConfigFactory } from './default-model-config-factory.js';
6
6
  export { LLMGExpressMastraModelConfigFactory, type LLMGExpressConfig } from './llmg-express-model-config-factory.js';
@@ -2,6 +2,7 @@ import { Agent } from '@mastra/core/agent';
2
2
  import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
3
3
  import { MCPClient } from '@mastra/mcp';
4
4
  import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type MessagePart, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo } from '@salesforce/sfdx-agent-sdk';
5
+ import { type AgentHooks } from '@salesforce/sfdx-agent-sdk/harness';
5
6
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
6
7
  import type { MastraModelConfigFactory } from './model-config-factory.js';
7
8
  import { type MastraAgentConfig, type SkillSearchHandle, type ToolSearchHandle } from './tool-search.js';
@@ -44,6 +45,12 @@ interface McpServerState {
44
45
  errorDetail?: McpServerErrorDetail;
45
46
  ready: Promise<void>;
46
47
  settled: boolean;
48
+ /**
49
+ * Discovered tools for this server, keyed by the harness's namespaced form
50
+ * `${serverName}_${toolName}`. The namespacing is applied at discovery time
51
+ * by `runMcpDiscovery` and is the contract every reader of this map relies
52
+ * on (tool merging, alwaysActive expansion, MCP-tool metadata lookup).
53
+ */
47
54
  tools: ToolsInput;
48
55
  }
49
56
  /**
@@ -51,6 +58,13 @@ interface McpServerState {
51
58
  * generic API never sees them.
52
59
  */
53
60
  interface MastraAgentState {
61
+ /**
62
+ * Project folder this agent operates within. Captured at `createAgent`
63
+ * time and reused by `updateAgent` to rebuild the `Workspace`
64
+ * (`projectRoot` is immutable post-create — `Agent.updateAgentConfig`
65
+ * does not allow changing it).
66
+ */
67
+ projectRoot: string;
54
68
  /** Mastra Agent instance. */
55
69
  agent: Agent;
56
70
  /**
@@ -99,6 +113,15 @@ interface MastraAgentState {
99
113
  toolSearchProcessor?: ToolSearchProcessor;
100
114
  /** Skill-search processor instance, when both `skills` and `toolSearch` are configured. */
101
115
  skillSearchProcessor?: SkillSearchProcessor;
116
+ /**
117
+ * Per-agent hook bag the SDK passed at `createAgent` time. The SDK
118
+ * resolves the bag once per install / restore / `updateAgentConfig` via
119
+ * `createAgentManager`'s `hooksForAgent` callback. The harness reads
120
+ * `hooks.onToolResult` at processor construction time and ignores
121
+ * unknown hook fields (forward-compat). Defaults to `{}` when the
122
+ * consumer didn't pass a `hooksForAgent`.
123
+ */
124
+ hooks: AgentHooks;
102
125
  }
103
126
  /**
104
127
  * Branded subtype consumers parameterize over to reach Mastra-specific
@@ -135,9 +158,9 @@ export type MastraAgentHarness = WithAgentConfig<AgentHarness & {
135
158
  getToolSearchProcessor(agentId: string): ToolSearchHandle | undefined;
136
159
  /**
137
160
  * Returns a {@link SkillSearchHandle} for an agent, or `undefined`
138
- * if the agent doesn't have both `skills` and `toolSearch` configured
139
- * (skill search is only enabled when both are set; without `toolSearch`,
140
- * Mastra falls back to eager skill loading).
161
+ * if the agent doesn't have both `skills` and `skillSearch` configured
162
+ * (skill search is only enabled when both are set; without `skillSearch`,
163
+ * Mastra falls back to eager skill loading via `SkillsProcessor`).
141
164
  */
142
165
  getSkillSearchProcessor(agentId: string): SkillSearchHandle | undefined;
143
166
  /**
@@ -238,6 +261,7 @@ export declare class MastraHarness implements MastraAgentHarness {
238
261
  */
239
262
  createAgent(agentId: string, projectRoot: string, llmGatewayClient: LLMGatewayClient, config?: HarnessAgentConfig, options?: {
240
263
  abortSignal?: AbortSignal;
264
+ hooks?: AgentHooks;
241
265
  }): Promise<void>;
242
266
  /**
243
267
  * @requirements
@@ -264,6 +288,28 @@ export declare class MastraHarness implements MastraAgentHarness {
264
288
  * telemetry pair fires; the returned promise still resolves.
265
289
  */
266
290
  reconnectMcpServer(agentId: string, serverName: string): Promise<void>;
291
+ /**
292
+ * Implementation of {@link AgentHarness.updateAgent}. Diffs the next config
293
+ * against the agent's currently-applied state, preserves any MCP client
294
+ * whose `MCPServerConfig` is structurally equal to the previously-applied
295
+ * one, and rebuilds the Mastra `Agent` so non-MCP changes (instructions,
296
+ * model, tools, skills, rules, toolSearch) take effect.
297
+ *
298
+ * The Mastra `Agent`'s `tools: () => …` closure captures `state.mcpServers`
299
+ * by reference, so the new `Agent` constructed here observes the same Map
300
+ * the diff just mutated — preserved clients keep their discovered tool
301
+ * catalogs without a re-listing.
302
+ *
303
+ * State writes are per-server-incremental: each disconnect/connect mutates
304
+ * `state.mcpServers` synchronously after the corresponding harness work
305
+ * finishes. A re-entrant `updateAgent` call (e.g. SDK rollback against
306
+ * `previousConfig`) sees the harness's current truth and reverts only the
307
+ * actual deltas.
308
+ */
309
+ updateAgent(agentId: string, llmGatewayClient: LLMGatewayClient, config?: HarnessAgentConfig, options?: {
310
+ abortSignal?: AbortSignal;
311
+ hooks?: AgentHooks;
312
+ }): Promise<void>;
267
313
  /**
268
314
  * @requirements
269
315
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
@@ -13,7 +13,7 @@ import { LibSQLStore } from '@mastra/libsql';
13
13
  import { createClient } from '@libsql/client';
14
14
  import { MCPClient } from '@mastra/mcp';
15
15
  import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, McpServerStatus, resolveToolApprovalMode, } from '@salesforce/sfdx-agent-sdk';
16
- import { HarnessBusOwner, lowerStreamInput } from '@salesforce/sfdx-agent-sdk/harness';
16
+ import { HarnessBusOwner, lowerStreamInput, mcpServerConfigEqual, } from '@salesforce/sfdx-agent-sdk/harness';
17
17
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
18
18
  import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
19
19
  import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
@@ -21,8 +21,9 @@ import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
21
21
  import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
22
22
  import { buildMcpErrorDetail, sanitizeMcpErrorMessage } from './mcp-error-classifier.js';
23
23
  import { DefaultMastraModelConfigFactory } from './default-model-config-factory.js';
24
- import { partitionPool, } from './tool-search.js';
24
+ import { partitionPool, resolveAlwaysActive, } from './tool-search.js';
25
25
  import {} from '@salesforce/llm-gateway-sdk';
26
+ import { ToolResultRedactionProcessor } from './mastra-tool-result-redaction-processor.js';
26
27
  import { backfillCreatedAt, buildSummaryPrompt, Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
27
28
  import { makeAbortable } from './utils.js';
28
29
  import { Workspace, LocalFilesystem, LocalSandbox, WORKSPACE_TOOLS, WORKSPACE_TOOLS_PREFIX, } from '@mastra/core/workspace';
@@ -126,6 +127,33 @@ const MAX_TRANSCRIPT_CHARS = 400_000;
126
127
  * like deploys. Per-server `timeout` in MCPServerConfig still overrides this.
127
128
  */
128
129
  const DEFAULT_MCP_TIMEOUT_MS = 600_000;
130
+ /**
131
+ * Build the `Map<namespacedName, ToolSource>` index `resolveAlwaysActive`
132
+ * needs to match `{ serverName }` / `{ serverName, toolName }` / `{ toolName }`
133
+ * entries against the merged custom-tools + MCP catalog.
134
+ *
135
+ * Custom tools key on their declared name with `serverName: undefined`
136
+ * (matching the cross-harness `tool-call` ChatEvent shape for non-MCP tools).
137
+ * MCP tools key on `${serverName}_${toolName}` with the un-namespaced
138
+ * `(serverName, toolName)` pair as the source — recovered by stripping the
139
+ * `${serverName}_` prefix from the namespaced key, the same way every reader
140
+ * of `MastraAgentState.mcpServers` handles namespacing.
141
+ */
142
+ function buildToolSources(customTools, mcpServers) {
143
+ const sources = new Map();
144
+ for (const toolName of Object.keys(customTools)) {
145
+ sources.set(toolName, { serverName: undefined, toolName });
146
+ }
147
+ for (const [serverName, serverState] of mcpServers) {
148
+ const prefix = `${serverName}_`;
149
+ for (const namespaced of Object.keys(serverState.tools)) {
150
+ if (!namespaced.startsWith(prefix))
151
+ continue;
152
+ sources.set(namespaced, { serverName, toolName: namespaced.slice(prefix.length) });
153
+ }
154
+ }
155
+ return sources;
156
+ }
129
157
  /** Loads `config.rules` into a single composed `instructions` string. See ARCHITECTURE.md → "Rule Composition". */
130
158
  async function composeInstructionsWithRules(baseInstructions, rulePaths) {
131
159
  const base = baseInstructions ?? '';
@@ -206,9 +234,9 @@ async function listMarkdownFilesInDirectory(dir) {
206
234
  * `Map<namespacedToolName, { serverName, McpToolInfo }>` index keyed off
207
235
  * discovery completion.
208
236
  */
209
- function lookupMcpToolMetadata(agentState, namespacedToolName) {
237
+ function lookupMcpToolMetadata(mcpServers, namespacedToolName) {
210
238
  let best;
211
- for (const [serverName, serverState] of agentState.mcpServers) {
239
+ for (const [serverName, serverState] of mcpServers) {
212
240
  if (!namespacedToolName.startsWith(`${serverName}_`))
213
241
  continue;
214
242
  if (!(namespacedToolName in serverState.tools))
@@ -519,10 +547,11 @@ export class MastraHarness {
519
547
  let toolSearchProcessor;
520
548
  let toolSearchAlwaysActive;
521
549
  let skillSearchProcessor;
522
- // `toolSearch` rides through on the consumer's `MastraAgentConfig`
523
- // even though `HarnessAgentConfig` itself doesn't declare it —
524
- // `toHarnessConfig` preserves extra fields via spread.
550
+ // `toolSearch` and `skillSearch` ride through on the consumer's
551
+ // `MastraAgentConfig` even though `HarnessAgentConfig` itself doesn't
552
+ // declare them — `toHarnessConfig` preserves extra fields via spread.
525
553
  const toolSearchConfig = config.toolSearch;
554
+ const skillSearchConfig = config.skillSearch;
526
555
  try {
527
556
  const allowedPaths = (config.skills ?? []).filter((s) => path.isAbsolute(s));
528
557
  const localFs = new LocalFilesystem({ basePath: projectRoot, allowedPaths });
@@ -546,11 +575,11 @@ export class MastraHarness {
546
575
  ])),
547
576
  });
548
577
  await mastraWorkspace.init();
549
- // On-demand skill search: only when both `skills` and `toolSearch`
550
- // are configured. Without `toolSearch`, Mastra's built-in eager
551
- // skill loading is used (existing behavior).
552
- if (mastraWorkspace.skills && toolSearchConfig) {
553
- skillSearchProcessor = new SkillSearchProcessor({ workspace: mastraWorkspace });
578
+ if (mastraWorkspace.skills && skillSearchConfig) {
579
+ skillSearchProcessor = new SkillSearchProcessor({
580
+ workspace: mastraWorkspace,
581
+ ...(skillSearchConfig.topK !== undefined && { search: { topK: skillSearchConfig.topK } }),
582
+ });
554
583
  }
555
584
  // When toolSearch is configured, await MCP discovery so MCP tools
556
585
  // can enter the searchable pool. Without it, MCP discovery stays
@@ -562,7 +591,16 @@ export class MastraHarness {
562
591
  const mcpTools = [...mcpServerStates.values()].reduce((acc, s) => ({ ...acc, ...s.tools }), {});
563
592
  const allTools = { ...customTools, ...mcpTools };
564
593
  const knownNames = new Set(Object.keys(allTools));
565
- const partition = partitionPool(toolSearchConfig.pool, toolSearchConfig.alwaysActive, knownNames);
594
+ // Resolve `toolSearch.alwaysActive` entries against the
595
+ // merged custom-tools + MCP catalog. Each entry covers
596
+ // `{ serverName }` (server-wide), `{ serverName, toolName }`
597
+ // (precise), or `{ toolName }` (cross-source) — see
598
+ // `tool-search.ts` resolveAlwaysActive for the full rules.
599
+ // Unresolved `{ toolName }`-only entries flow through to
600
+ // `partitionPool`'s late-discovery hatch.
601
+ const sources = buildToolSources(customTools, mcpServerStates);
602
+ const { resolved, unresolvedToolNames } = resolveAlwaysActive(toolSearchConfig.alwaysActive, sources);
603
+ const partition = partitionPool(toolSearchConfig.pool, resolved, unresolvedToolNames, knownNames);
566
604
  toolSearchAlwaysActive = new Set(partition.alwaysActive);
567
605
  const poolTools = {};
568
606
  for (const name of partition.pool) {
@@ -576,7 +614,24 @@ export class MastraHarness {
576
614
  ttl: toolSearchConfig.ttl,
577
615
  });
578
616
  }
579
- const inputProcessorsList = [skillSearchProcessor, toolSearchProcessor].filter((p) => p !== undefined);
617
+ // Tool-result redaction processor — registered LAST in the
618
+ // inputProcessors chain so it sees the final messageList state
619
+ // after any earlier processor mutations. Reads the redactor
620
+ // from the per-agent hooks bag the SDK passed at createAgent
621
+ // time (resolved by `createAgentManager`'s `hooksForAgent`).
622
+ // Closes over the live mcpServerStates map (same reference the
623
+ // agent state will hold) so MCP `serverName` enrichment on
624
+ // `ToolResultRedactionInput` matches the cross-harness
625
+ // `tool-result` ChatEvent shape.
626
+ const onToolResult = options?.hooks?.onToolResult;
627
+ const toolResultRedactionProcessor = onToolResult
628
+ ? new ToolResultRedactionProcessor(agentId, onToolResult, (namespacedToolName) => lookupMcpToolMetadata(mcpServerStates, namespacedToolName))
629
+ : undefined;
630
+ const inputProcessorsList = [
631
+ skillSearchProcessor,
632
+ toolSearchProcessor,
633
+ toolResultRedactionProcessor,
634
+ ].filter((p) => p !== undefined);
580
635
  const inputProcessors = inputProcessorsList.length > 0 ? inputProcessorsList : undefined;
581
636
  agent = new Agent({
582
637
  id: agentId,
@@ -605,6 +660,7 @@ export class MastraHarness {
605
660
  this.mastra.addAgent(agent);
606
661
  mastraAgentAdded = true;
607
662
  this.activeAgents.set(agentId, {
663
+ projectRoot,
608
664
  agent,
609
665
  composedInstructions,
610
666
  validateFiles,
@@ -615,6 +671,7 @@ export class MastraHarness {
615
671
  consumerToolNames,
616
672
  toolSearchProcessor,
617
673
  skillSearchProcessor,
674
+ hooks: options?.hooks ?? {},
618
675
  });
619
676
  }
620
677
  catch (error) {
@@ -769,6 +826,7 @@ export class MastraHarness {
769
826
  serverState.settled = true;
770
827
  const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, serverState, McpServerStatus.Error, message);
771
828
  this.buses.getLogBus()?.warn('MCP server reconnect failed', {
829
+ agentId,
772
830
  serverName,
773
831
  error: message,
774
832
  });
@@ -797,6 +855,222 @@ export class MastraHarness {
797
855
  serverState.ready = this.runMcpDiscovery(agentId, serverName, serverState.client, state.mcpServers, epoch, undefined, startTime);
798
856
  await serverState.ready;
799
857
  }
858
+ /**
859
+ * Implementation of {@link AgentHarness.updateAgent}. Diffs the next config
860
+ * against the agent's currently-applied state, preserves any MCP client
861
+ * whose `MCPServerConfig` is structurally equal to the previously-applied
862
+ * one, and rebuilds the Mastra `Agent` so non-MCP changes (instructions,
863
+ * model, tools, skills, rules, toolSearch) take effect.
864
+ *
865
+ * The Mastra `Agent`'s `tools: () => …` closure captures `state.mcpServers`
866
+ * by reference, so the new `Agent` constructed here observes the same Map
867
+ * the diff just mutated — preserved clients keep their discovered tool
868
+ * catalogs without a re-listing.
869
+ *
870
+ * State writes are per-server-incremental: each disconnect/connect mutates
871
+ * `state.mcpServers` synchronously after the corresponding harness work
872
+ * finishes. A re-entrant `updateAgent` call (e.g. SDK rollback against
873
+ * `previousConfig`) sees the harness's current truth and reverts only the
874
+ * actual deltas.
875
+ */
876
+ async updateAgent(agentId, llmGatewayClient, config = {}, options) {
877
+ const state = this.getAgentStateOrThrow(agentId);
878
+ options?.abortSignal?.throwIfAborted();
879
+ // ── Phase 0: validate non-MCP inputs before any MCP client is touched.
880
+ // A bad rule path or skill path rejects updateAgent here so
881
+ // half-applied state from non-MCP failures is impossible.
882
+ // Mirrors Claude's posture; closes a Mastra atomicity gap
883
+ // where Phase 1 ran first and a Phase-2 throw left MCP
884
+ // servers already half-cycled.
885
+ const composedInstructions = await composeInstructionsWithRules(config.instructions, config.rules);
886
+ const allowedPaths = (config.skills ?? []).filter((s) => path.isAbsolute(s));
887
+ // projectRoot is immutable post-create — captured on agent state at
888
+ // createAgent time. Reuse it rather than threading it through every
889
+ // updateAgent call.
890
+ const projectRoot = state.projectRoot;
891
+ const localFs = new LocalFilesystem({ basePath: projectRoot, allowedPaths });
892
+ const sandbox = new LocalSandbox({
893
+ workingDirectory: projectRoot,
894
+ env: process.env,
895
+ });
896
+ const mastraWorkspace = new Workspace({
897
+ filesystem: localFs,
898
+ skills: config.skills,
899
+ sandbox,
900
+ tools: Object.fromEntries(ALL_MASTRA_WORKSPACE_TOOL_NAMES.map((nativeName) => [
901
+ nativeName,
902
+ { name: stripMastraWorkspacePrefix(nativeName) },
903
+ ])),
904
+ });
905
+ try {
906
+ await mastraWorkspace.init();
907
+ }
908
+ catch (error) {
909
+ await mastraWorkspace.destroy().catch(() => undefined);
910
+ throw error;
911
+ }
912
+ // ── Phase 1: MCP diff against state.mcpConfig ─────────────────────
913
+ const prevConfig = state.mcpConfig ?? {};
914
+ const nextConfig = config.mcpServers ?? {};
915
+ const allNames = new Set([...Object.keys(prevConfig), ...Object.keys(nextConfig)]);
916
+ const nextEnabledServers = hasEnabledServers(nextConfig)
917
+ ? mapToMastraMCPServers(nextConfig, config.orgJwt)
918
+ : {};
919
+ for (const name of allNames) {
920
+ const prev = prevConfig[name];
921
+ const next = nextConfig[name];
922
+ if (mcpServerConfigEqual(prev, next))
923
+ continue;
924
+ // Tear down whatever was previously attached for this name. Both
925
+ // "removed" and "changed" go through this branch; "added" hits no
926
+ // previous entry so the disconnect is a no-op.
927
+ const prevEntry = state.mcpServers.get(name);
928
+ if (prevEntry) {
929
+ state.mcpServers.delete(name);
930
+ await prevEntry.ready.catch(() => undefined);
931
+ await prevEntry.client.disconnect().catch(() => undefined);
932
+ }
933
+ // Connect the new one if the next config has it enabled.
934
+ if (next && next.enabled !== false) {
935
+ const serverDef = nextEnabledServers[name];
936
+ if (serverDef) {
937
+ const client = new MCPClient({
938
+ id: `${agentId}:${name}`,
939
+ servers: { [name]: serverDef },
940
+ timeout: DEFAULT_MCP_TIMEOUT_MS,
941
+ });
942
+ const entry = {
943
+ client,
944
+ currentStatus: McpServerStatus.Connecting,
945
+ epoch: 0,
946
+ ready: Promise.resolve(),
947
+ settled: false,
948
+ tools: {},
949
+ };
950
+ state.mcpServers.set(name, entry);
951
+ entry.ready = this.runMcpDiscovery(agentId, name, client, state.mcpServers, 0, options?.abortSignal);
952
+ }
953
+ }
954
+ }
955
+ // ── Phase 2: non-MCP rebuild ──────────────────────────────────────
956
+ const consumerToolNames = new Set((config.tools ?? []).map((t) => t.name));
957
+ const parkerLookup = (threadId) => this.activeAgents.get(agentId)?.coordinators.get(threadId)?.parkConsumerToolCall;
958
+ const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools, parkerLookup) : {};
959
+ const { model: resolvedModel, validateFiles } = this.modelConfigFactory.create(llmGatewayClient, this.buses.getLogBus());
960
+ // Capture the live mcpServers map by reference — the new Agent's
961
+ // tools() closure will read it on every step, so preserved clients'
962
+ // discovered tools stay visible without a re-listing.
963
+ const mcpServers = state.mcpServers;
964
+ const toolSearchConfig = config.toolSearch;
965
+ const skillSearchConfig = config.skillSearch;
966
+ let toolSearchProcessor;
967
+ let toolSearchAlwaysActive;
968
+ let skillSearchProcessor;
969
+ try {
970
+ if (mastraWorkspace.skills && skillSearchConfig) {
971
+ skillSearchProcessor = new SkillSearchProcessor({
972
+ workspace: mastraWorkspace,
973
+ ...(skillSearchConfig.topK !== undefined && { search: { topK: skillSearchConfig.topK } }),
974
+ });
975
+ }
976
+ if (toolSearchConfig) {
977
+ if (mcpServers.size > 0) {
978
+ await Promise.allSettled([...mcpServers.values()].map((s) => s.ready));
979
+ }
980
+ const mcpTools = [...mcpServers.values()].reduce((acc, s) => ({ ...acc, ...s.tools }), {});
981
+ const allTools = { ...customTools, ...mcpTools };
982
+ const knownNames = new Set(Object.keys(allTools));
983
+ const sources = buildToolSources(customTools, mcpServers);
984
+ const { resolved, unresolvedToolNames } = resolveAlwaysActive(toolSearchConfig.alwaysActive, sources);
985
+ const partition = partitionPool(toolSearchConfig.pool, resolved, unresolvedToolNames, knownNames);
986
+ toolSearchAlwaysActive = new Set(partition.alwaysActive);
987
+ const poolTools = {};
988
+ for (const name of partition.pool) {
989
+ if (allTools[name]) {
990
+ poolTools[name] = allTools[name];
991
+ }
992
+ }
993
+ toolSearchProcessor = new ToolSearchProcessor({
994
+ tools: poolTools,
995
+ search: toolSearchConfig.topK !== undefined ? { topK: toolSearchConfig.topK } : undefined,
996
+ ttl: toolSearchConfig.ttl,
997
+ });
998
+ }
999
+ // Tool-result redaction processor — built off the per-agent hook
1000
+ // bag the SDK passed at updateAgent time. Same wiring as createAgent
1001
+ // (registered LAST in the chain so it sees the post-mutation
1002
+ // messageList state); closes over the live state.mcpServers map so
1003
+ // MCP `serverName` enrichment matches the cross-harness shape.
1004
+ const onToolResult = options?.hooks?.onToolResult;
1005
+ const toolResultRedactionProcessor = onToolResult
1006
+ ? new ToolResultRedactionProcessor(agentId, onToolResult, (namespacedToolName) => lookupMcpToolMetadata(mcpServers, namespacedToolName))
1007
+ : undefined;
1008
+ const inputProcessorsList = [
1009
+ skillSearchProcessor,
1010
+ toolSearchProcessor,
1011
+ toolResultRedactionProcessor,
1012
+ ].filter((p) => p !== undefined);
1013
+ const inputProcessors = inputProcessorsList.length > 0 ? inputProcessorsList : undefined;
1014
+ const newAgent = new Agent({
1015
+ id: agentId,
1016
+ name: config.name || agentId,
1017
+ instructions: composedInstructions,
1018
+ model: resolvedModel,
1019
+ memory: this.memory,
1020
+ tools: () => {
1021
+ const mcpTools = [...mcpServers.values()].reduce((acc, s) => ({ ...acc, ...s.tools }), {});
1022
+ const merged = { ...customTools, ...mcpTools };
1023
+ if (!toolSearchAlwaysActive) {
1024
+ return merged;
1025
+ }
1026
+ const active = {};
1027
+ for (const [name, tool] of Object.entries(merged)) {
1028
+ if (toolSearchAlwaysActive.has(name)) {
1029
+ active[name] = tool;
1030
+ }
1031
+ }
1032
+ return active;
1033
+ },
1034
+ workspace: mastraWorkspace,
1035
+ ...(inputProcessors ? { inputProcessors } : {}),
1036
+ });
1037
+ // Mastra's addAgent silently no-ops on a duplicate key, so we must
1038
+ // remove first. The previous Agent instance is still referenced by
1039
+ // any in-flight stream's coordinator (captured at stream-start) and
1040
+ // continues to run against its bound model. The next stream() will
1041
+ // resolve newAgent from activeAgents.
1042
+ this.mastra.removeAgent(agentId);
1043
+ this.mastra.addAgent(newAgent);
1044
+ // Dispose the previous workspace and skill-search processor only
1045
+ // after the new ones are wired in, so any synchronous reader sees
1046
+ // a coherent state. Coordinators are intentionally NOT disposed —
1047
+ // they hold references to the old Agent and finish their turns
1048
+ // against it.
1049
+ const previousWorkspace = state.mastraWorkspace;
1050
+ const previousSkillSearchProcessor = state.skillSearchProcessor;
1051
+ state.agent = newAgent;
1052
+ state.composedInstructions = composedInstructions;
1053
+ state.validateFiles = validateFiles;
1054
+ state.mcpConfig = config.mcpServers;
1055
+ state.mastraWorkspace = mastraWorkspace;
1056
+ state.consumerToolNames = consumerToolNames;
1057
+ state.toolSearchProcessor = toolSearchProcessor;
1058
+ state.skillSearchProcessor = skillSearchProcessor;
1059
+ state.hooks = options?.hooks ?? {};
1060
+ previousSkillSearchProcessor?.dispose();
1061
+ await previousWorkspace?.destroy().catch(() => undefined);
1062
+ }
1063
+ catch (error) {
1064
+ // Phase 2 failed after Phase 1 mutated state.mcpServers. We don't
1065
+ // try to revert Phase 1 here — the SDK's rollback path will call
1066
+ // updateAgent(previousConfig) which re-diffs against the now-
1067
+ // current state and reverts the actual deltas. Phase 2 cleanup is
1068
+ // local: dispose anything we constructed.
1069
+ skillSearchProcessor?.dispose();
1070
+ await mastraWorkspace.destroy().catch(() => undefined);
1071
+ throw error;
1072
+ }
1073
+ }
800
1074
  // ── Thread (Chat Session) Lifecycle ──────────────────────────────
801
1075
  /**
802
1076
  * @requirements
@@ -915,7 +1189,7 @@ export class MastraHarness {
915
1189
  ]);
916
1190
  }
917
1191
  catch (err) {
918
- throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${err instanceof Error ? err.message : String(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1192
+ throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
919
1193
  }
920
1194
  // Source-thread cleanup is best-effort. The user's data is already
921
1195
  // safely seeded into newThreadId; a failed delete leaves an orphan
@@ -1039,7 +1313,7 @@ export class MastraHarness {
1039
1313
  toolApprovalTimeoutMs: this.toolApprovalTimeoutMs,
1040
1314
  mode: approvalMode,
1041
1315
  consumerToolNames: agentState.consumerToolNames,
1042
- lookupMcpToolMetadata: (name) => lookupMcpToolMetadata(agentState, name),
1316
+ lookupMcpToolMetadata: (name) => lookupMcpToolMetadata(agentState.mcpServers, name),
1043
1317
  logBus: this.buses.getLogBus(),
1044
1318
  });
1045
1319
  agentState.coordinators.set(threadId, coordinator);
@@ -1413,6 +1687,7 @@ export class MastraHarness {
1413
1687
  s.settled = true;
1414
1688
  const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, s, McpServerStatus.Error, errorMessage);
1415
1689
  this.buses.getLogBus()?.warn('MCP server discovery failed', {
1690
+ agentId,
1416
1691
  serverName,
1417
1692
  error: errorMessage,
1418
1693
  });
@@ -0,0 +1,54 @@
1
+ import type { ProcessInputStepArgs, Processor } from '@mastra/core/processors';
2
+ import { type McpToolAnnotations, type ToolResultRedactor } from '@salesforce/sfdx-agent-sdk';
3
+ /**
4
+ * Internal MCP-catalog lookup the harness passes the processor at construction
5
+ * time, closed over the agent's `mcpServers` map. Returns the originating
6
+ * server name (and optionally annotations) for a Mastra-namespaced tool name
7
+ * (`<serverName>_<toolName>`); `undefined` for consumer-declared / workspace
8
+ * / built-in tools. Mirrors `MastraHarness#lookupMcpToolMetadata` so the
9
+ * `ToolResultRedactionInput.serverName` enrichment matches the cross-harness
10
+ * shape on the `tool-result` ChatEvent.
11
+ */
12
+ export type McpServerLookup = (namespacedToolName: string) => {
13
+ serverName: string;
14
+ annotations?: McpToolAnnotations;
15
+ } | undefined;
16
+ /**
17
+ * Mastra `Processor` that runs a consumer-supplied {@link ToolResultRedactor}
18
+ * over every new tool-result message before the next LLM call sees it.
19
+ *
20
+ * ### Why `processInputStep` and not `processInput`
21
+ *
22
+ * `processInput` fires once at the start of a stream. Tool results arrive
23
+ * mid-stream (the model emits a tool-call, the tool runs, the result row is
24
+ * appended to the MessageList, then the loop pumps another LLM call). Only
25
+ * `processInputStep` fires before EVERY step's LLM call — so it sees newly
26
+ * appended tool-result rows on the second and subsequent steps and gets a
27
+ * chance to redact them before the model is invoked.
28
+ *
29
+ * ### Per-call dedup
30
+ *
31
+ * The same MessageList survives across steps within a single
32
+ * `agent.stream()` invocation. To avoid re-redacting on every step (which
33
+ * would be quadratic *and* would mean the redactor sees its own previous
34
+ * replacements), the processor records every `toolCallId` it has redacted
35
+ * in `args.state`, scoped per-stream by Mastra's processor-state contract.
36
+ *
37
+ * ### Throw policy
38
+ *
39
+ * The processor does NOT catch redactor throws. An exception propagates
40
+ * through `processInputStep`, which the Mastra approval coordinator's pump
41
+ * loop treats as `pump-error`: the consumer sees a synthetic `error` +
42
+ * `finish('error')` ChatEvent pair on the eventStream and the run aborts.
43
+ * The original tool output never reaches the model. Consumers requiring a
44
+ * richer fail-closed substitute wrap their redactor's body in `try`/`catch`
45
+ * themselves — see SDK README → "Tool-Result Redaction".
46
+ */
47
+ export declare class ToolResultRedactionProcessor implements Processor<'sfdx-tool-result-redaction'> {
48
+ private readonly agentId;
49
+ private readonly redactor;
50
+ private readonly lookupMcpToolMetadata;
51
+ readonly id: "sfdx-tool-result-redaction";
52
+ constructor(agentId: string, redactor: ToolResultRedactor, lookupMcpToolMetadata: McpServerLookup);
53
+ processInputStep(args: ProcessInputStepArgs): Promise<void>;
54
+ }
@@ -0,0 +1,136 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { MASTRA_THREAD_ID_KEY } from '@mastra/core/request-context';
6
+ import {} from '@salesforce/sfdx-agent-sdk';
7
+ /**
8
+ * Mastra `Processor` that runs a consumer-supplied {@link ToolResultRedactor}
9
+ * over every new tool-result message before the next LLM call sees it.
10
+ *
11
+ * ### Why `processInputStep` and not `processInput`
12
+ *
13
+ * `processInput` fires once at the start of a stream. Tool results arrive
14
+ * mid-stream (the model emits a tool-call, the tool runs, the result row is
15
+ * appended to the MessageList, then the loop pumps another LLM call). Only
16
+ * `processInputStep` fires before EVERY step's LLM call — so it sees newly
17
+ * appended tool-result rows on the second and subsequent steps and gets a
18
+ * chance to redact them before the model is invoked.
19
+ *
20
+ * ### Per-call dedup
21
+ *
22
+ * The same MessageList survives across steps within a single
23
+ * `agent.stream()` invocation. To avoid re-redacting on every step (which
24
+ * would be quadratic *and* would mean the redactor sees its own previous
25
+ * replacements), the processor records every `toolCallId` it has redacted
26
+ * in `args.state`, scoped per-stream by Mastra's processor-state contract.
27
+ *
28
+ * ### Throw policy
29
+ *
30
+ * The processor does NOT catch redactor throws. An exception propagates
31
+ * through `processInputStep`, which the Mastra approval coordinator's pump
32
+ * loop treats as `pump-error`: the consumer sees a synthetic `error` +
33
+ * `finish('error')` ChatEvent pair on the eventStream and the run aborts.
34
+ * The original tool output never reaches the model. Consumers requiring a
35
+ * richer fail-closed substitute wrap their redactor's body in `try`/`catch`
36
+ * themselves — see SDK README → "Tool-Result Redaction".
37
+ */
38
+ export class ToolResultRedactionProcessor {
39
+ agentId;
40
+ redactor;
41
+ lookupMcpToolMetadata;
42
+ id = 'sfdx-tool-result-redaction';
43
+ constructor(agentId, redactor, lookupMcpToolMetadata) {
44
+ this.agentId = agentId;
45
+ this.redactor = redactor;
46
+ this.lookupMcpToolMetadata = lookupMcpToolMetadata;
47
+ }
48
+ async processInputStep(args) {
49
+ const seen = readSeenSet(args.state);
50
+ // The harness sets MASTRA_THREAD_ID_KEY on every thread-bound entry
51
+ // point's RequestContext. Thread-less paths (e.g. compactThread's
52
+ // `summarize()` helper) fall back to '' — the redactor uses threadId
53
+ // for identification only, not routing.
54
+ const ctx = args.requestContext;
55
+ const threadIdValue = ctx?.get?.(MASTRA_THREAD_ID_KEY);
56
+ const threadId = typeof threadIdValue === 'string' ? threadIdValue : '';
57
+ for (const message of args.messages) {
58
+ await redactMessageInPlace({
59
+ message,
60
+ seen,
61
+ redactor: this.redactor,
62
+ lookup: this.lookupMcpToolMetadata,
63
+ agentId: this.agentId,
64
+ threadId,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ const SEEN_KEY = '__sfdx-tool-result-redaction:seen-toolCallIds';
70
+ function readSeenSet(state) {
71
+ const existing = state[SEEN_KEY];
72
+ if (existing instanceof Set)
73
+ return existing;
74
+ const fresh = new Set();
75
+ state[SEEN_KEY] = fresh;
76
+ return fresh;
77
+ }
78
+ /**
79
+ * Walk one DB-shaped message's parts in place. `MastraToolInvocationPart`s
80
+ * carry the tool result on `toolInvocation.result` (success) or surface
81
+ * errors via `state === 'output-error'` with `errorText`. Both paths are
82
+ * funneled through the redactor so consumers don't have to special-case the
83
+ * error variant.
84
+ */
85
+ async function redactMessageInPlace(opts) {
86
+ const { message, seen, redactor, lookup, agentId, threadId } = opts;
87
+ const parts = message.content.parts;
88
+ if (!Array.isArray(parts))
89
+ return;
90
+ for (const rawPart of parts) {
91
+ if (!isToolInvocationResultPart(rawPart))
92
+ continue;
93
+ const part = rawPart;
94
+ const inv = part.toolInvocation;
95
+ const toolCallId = inv.toolCallId;
96
+ if (seen.has(toolCallId))
97
+ continue;
98
+ seen.add(toolCallId);
99
+ const isError = inv.state === 'output-error' || inv.state === 'output-denied';
100
+ const originalOutput = isError && inv.errorText !== undefined ? inv.errorText : inv.result;
101
+ const metadata = lookup(inv.toolName);
102
+ const result = await redactor({
103
+ agentId,
104
+ threadId,
105
+ toolCallId,
106
+ toolName: inv.toolName,
107
+ ...(metadata?.serverName ? { serverName: metadata.serverName } : {}),
108
+ output: originalOutput,
109
+ isError,
110
+ });
111
+ if (!result || typeof result !== 'object' || !('output' in result))
112
+ continue;
113
+ // Both success and error paths replace `result` so the model sees
114
+ // the redacted shape. `errorText` is left intact for non-error
115
+ // success paths (untouched) and overwritten with the redaction
116
+ // string when the original was an error so downstream UI keeps a
117
+ // consistent text projection.
118
+ inv.result = result.output;
119
+ if (isError && typeof result.output === 'string') {
120
+ inv.errorText = result.output;
121
+ }
122
+ }
123
+ }
124
+ function isToolInvocationResultPart(part) {
125
+ if (part.type !== 'tool-invocation')
126
+ return false;
127
+ const inv = part.toolInvocation;
128
+ if (!inv || typeof inv.toolCallId !== 'string' || typeof inv.toolName !== 'string')
129
+ return false;
130
+ // Only redact result-bearing states. `partial-call` / `call` /
131
+ // `approval-requested` / `approval-responded` haven't produced an output
132
+ // yet; redacting them would either no-op (result is undefined) or get
133
+ // overwritten on the next state transition.
134
+ return inv.state === 'result' || inv.state === 'output-error' || inv.state === 'output-denied';
135
+ }
136
+ //# sourceMappingURL=mastra-tool-result-redaction-processor.js.map
@@ -2,7 +2,7 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
- import { isAbortError } from '@salesforce/agentic-common';
5
+ import { getErrorMessage, isAbortError } from '@salesforce/agentic-common';
6
6
  /**
7
7
  * Structural duck-type for MCP-shaped errors. Matches the upstream
8
8
  * `@modelcontextprotocol/sdk` `McpError` class (`extends Error`, numeric
@@ -35,7 +35,7 @@ function isMcpErrorLike(err) {
35
35
  * through `String(...)`.
36
36
  */
37
37
  export function sanitizeMcpErrorMessage(err) {
38
- const raw = err instanceof Error ? err.message : String(err);
38
+ const raw = getErrorMessage(err);
39
39
  const lines = raw.split(/\r?\n/);
40
40
  // Keep the first line that is NOT a V8 stack frame. V8 frames start with
41
41
  // arbitrary leading whitespace followed by "at " (matched at line start).
@@ -1,4 +1,5 @@
1
1
  import type { AgentConfig } from '@salesforce/sfdx-agent-sdk';
2
+ import { type AlwaysActiveEntry } from '@salesforce/sfdx-agent-sdk/harness';
2
3
  /**
3
4
  * Configuration for hiding a large tool catalog behind Mastra's
4
5
  * search/load workflow. When present on `MastraAgentConfig.toolSearch`,
@@ -9,44 +10,78 @@ import type { AgentConfig } from '@salesforce/sfdx-agent-sdk';
9
10
  export type MastraToolSearchConfig = {
10
11
  /**
11
12
  * Tools eligible for search/load. `'*'` means every searchable tool;
12
- * an explicit array limits the pool to the listed names.
13
+ * an explicit array limits the pool to the listed names. Pool entries
14
+ * are bare tool names (un-namespaced) — the harness resolves them
15
+ * against the merged custom-tools + MCP catalog.
13
16
  */
14
17
  pool: string[] | '*';
15
18
  /**
16
19
  * Tools that bypass search and are always visible to the model.
17
- * Overrides `pool` on overlap. MCP tool names use the harness namespace
18
- * prefix, e.g. `sfdx_deploy_metadata` for the `deploy_metadata` tool on
19
- * the `sfdx` server.
20
+ *
21
+ * Each entry covers one of three patterns:
22
+ * - `{ serverName: 'X' }` — every tool from server `X`.
23
+ * - `{ serverName: 'X', toolName: 'Y' }` — exactly tool `Y` on server `X`.
24
+ * - `{ toolName: 'Y' }` — any tool named `Y`, regardless of source.
25
+ *
26
+ * Overrides `pool` on overlap.
27
+ *
28
+ * Mirrors `ClaudeAgentConfig.toolSearch.alwaysActive`. See
29
+ * {@link AlwaysActiveEntry} on `@salesforce/sfdx-agent-sdk/harness`
30
+ * for the full matching semantics.
20
31
  */
21
- alwaysActive?: string[];
32
+ alwaysActive?: AlwaysActiveEntry[];
22
33
  /** Max tools returned per search (advisory). */
23
34
  topK?: number;
24
35
  /** Time-to-live in ms for a granted tool in the active set (advisory). */
25
36
  ttl?: number;
26
37
  };
38
+ /**
39
+ * Configuration for hiding a large skill catalog behind Mastra's
40
+ * on-demand `search_skills` / `load_skill` meta-tools. When present
41
+ * on `MastraAgentConfig.skillSearch`, the harness wires
42
+ * `SkillSearchProcessor` so the model discovers skills via search
43
+ * rather than seeing the full catalog injected into the system
44
+ * prompt. When absent, the default `SkillsProcessor` (eager catalog)
45
+ * is used.
46
+ *
47
+ * Mirrors the cross-harness shape — Claude's `ClaudeAgentConfig.skillSearch`
48
+ * has the same field.
49
+ */
50
+ export type MastraSkillSearchConfig = {
51
+ /**
52
+ * Max number of results returned per `search_skills` call (advisory).
53
+ * Defaults to 5 — same as Mastra's `SkillSearchProcessor` default and
54
+ * Claude's `ClaudeSkillSearchConfig.topK` default.
55
+ */
56
+ topK?: number;
57
+ };
27
58
  /**
28
59
  * Mastra-flavored extension of {@link AgentConfig}, adding the Mastra-only
29
- * `toolSearch` field. Consumers do not import or pass this type explicitly —
30
- * `MastraAgentHarness` brands itself with `MastraAgentConfig` via
31
- * `WithAgentConfig`, so `manager.createAgent`'s config parameter resolves to
32
- * this shape automatically and `toolSearch` autocompletes on the object
33
- * literal.
60
+ * `toolSearch` and `skillSearch` fields. Consumers do not import or pass
61
+ * this type explicitly — `MastraAgentHarness` brands itself with
62
+ * `MastraAgentConfig` via `WithAgentConfig`, so `manager.createAgent`'s
63
+ * config parameter resolves to this shape automatically and the extra
64
+ * fields autocomplete on the object literal.
34
65
  *
35
66
  * @example
36
67
  * ```ts
37
68
  * const agent = await manager.createAgent(projectRoot, {
38
69
  * instructions: '...',
39
70
  * tools,
40
- * toolSearch: { pool: '*', alwaysActive: ['lookup_account'] },
71
+ * toolSearch: { pool: '*', alwaysActive: [{ toolName: 'lookup_account' }] },
72
+ * skills: ['/path/to/skills'],
73
+ * skillSearch: { topK: 5 },
41
74
  * });
42
75
  * ```
43
76
  *
44
77
  * Extra fields ride through `toHarnessConfig`'s `...rest` spread to the
45
- * harness, which reads `toolSearch` at its boundary via runtime narrowing.
78
+ * harness, which reads them at its boundary via runtime narrowing.
46
79
  */
47
80
  export type MastraAgentConfig = AgentConfig & {
48
81
  /** Hide tools behind Mastra's search/load workflow. */
49
82
  toolSearch?: MastraToolSearchConfig;
83
+ /** Hide skills behind Mastra's `search_skills` / `load_skill` meta-tools. */
84
+ skillSearch?: MastraSkillSearchConfig;
50
85
  };
51
86
  /**
52
87
  * Opaque handle to the tool-search runtime state, exposed via
@@ -97,11 +132,47 @@ export interface SkillSearchHandle {
97
132
  readonly id: string;
98
133
  }
99
134
  /**
100
- * Resolve `pool` / `alwaysActive` against known tools. `'*'` expands to all
101
- * known names; unknown pool names are dropped; unknown alwaysActive names are
102
- * kept for late MCP discovery; alwaysActive overrides pool on overlap.
135
+ * Tool sourcing context fed to {@link resolveAlwaysActive}. The harness has
136
+ * already merged consumer custom tools with discovered MCP tools by the
137
+ * time it asks tool-search to partition; this map keeps each tool's origin
138
+ * recoverable so `{ serverName }` and `{ toolName }` entries match correctly.
139
+ */
140
+ export type ToolSource = {
141
+ /** Logical MCP server name (the key in `AgentConfig.mcpServers`), or `undefined` for non-MCP tools (custom tools, workspace tools, built-ins). */
142
+ serverName: string | undefined;
143
+ /** Bare tool name from the upstream MCP server's `tools/list`, or the consumer-declared name for non-MCP tools. */
144
+ toolName: string;
145
+ };
146
+ /**
147
+ * Resolve {@link MastraToolSearchConfig.alwaysActive} entries to a set of
148
+ * Mastra-namespaced tool names that should bypass tool-search deferral.
149
+ *
150
+ * Each entry is matched against `knownToolSources` (a map keyed by the
151
+ * Mastra-namespaced name `{customName}` for custom tools or
152
+ * `${serverName}_${toolName}` for MCP tools, with the un-namespaced
153
+ * `(serverName, toolName)` pair as the value) using the cross-harness
154
+ * {@link matchesAlwaysActive} predicate. Unmatched entries are kept in the
155
+ * returned `unresolved` list so the harness can fold them into the
156
+ * tool-search pool's "kept for late MCP discovery" hatch — same posture
157
+ * as the previous `partitionPool`'s string-based behavior.
158
+ *
159
+ * Pure / deterministic.
160
+ */
161
+ export declare function resolveAlwaysActive(entries: AlwaysActiveEntry[] | undefined, knownToolSources: ReadonlyMap<string, ToolSource>): {
162
+ resolved: Set<string>;
163
+ unresolvedToolNames: string[];
164
+ };
165
+ /**
166
+ * Resolve `pool` / `alwaysActive` against known tools.
167
+ *
168
+ * - `pool: '*'` expands to all known namespaced names; unknown pool names
169
+ * in an explicit array are dropped.
170
+ * - `alwaysActive` is the resolved-and-namespaced set from
171
+ * {@link resolveAlwaysActive}, plus any unresolved `{ toolName }`-only
172
+ * entries kept for late MCP discovery.
173
+ * - alwaysActive overrides pool on overlap.
103
174
  */
104
- export declare function partitionPool(poolSpec: string[] | '*', alwaysActive: string[] | undefined, allKnown: ReadonlySet<string>): {
175
+ export declare function partitionPool(poolSpec: string[] | '*', alwaysActiveResolved: Set<string>, unresolvedToolNames: readonly string[], allKnown: ReadonlySet<string>): {
105
176
  pool: string[];
106
177
  alwaysActive: string[];
107
178
  };
@@ -2,16 +2,67 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
+ import { matchesAlwaysActive, validateAlwaysActiveEntry, } from '@salesforce/sfdx-agent-sdk/harness';
5
6
  /**
6
- * Resolve `pool` / `alwaysActive` against known tools. `'*'` expands to all
7
- * known names; unknown pool names are dropped; unknown alwaysActive names are
8
- * kept for late MCP discovery; alwaysActive overrides pool on overlap.
7
+ * Resolve {@link MastraToolSearchConfig.alwaysActive} entries to a set of
8
+ * Mastra-namespaced tool names that should bypass tool-search deferral.
9
+ *
10
+ * Each entry is matched against `knownToolSources` (a map keyed by the
11
+ * Mastra-namespaced name `{customName}` for custom tools or
12
+ * `${serverName}_${toolName}` for MCP tools, with the un-namespaced
13
+ * `(serverName, toolName)` pair as the value) using the cross-harness
14
+ * {@link matchesAlwaysActive} predicate. Unmatched entries are kept in the
15
+ * returned `unresolved` list so the harness can fold them into the
16
+ * tool-search pool's "kept for late MCP discovery" hatch — same posture
17
+ * as the previous `partitionPool`'s string-based behavior.
18
+ *
19
+ * Pure / deterministic.
9
20
  */
10
- export function partitionPool(poolSpec, alwaysActive, allKnown) {
11
- const resolvedActive = alwaysActive ?? [];
12
- const activeSet = new Set(resolvedActive);
21
+ export function resolveAlwaysActive(entries, knownToolSources) {
22
+ const resolved = new Set();
23
+ const unresolvedToolNames = [];
24
+ if (!entries || entries.length === 0)
25
+ return { resolved, unresolvedToolNames };
26
+ for (const entry of entries) {
27
+ validateAlwaysActiveEntry(entry);
28
+ let matched = false;
29
+ for (const [namespaced, source] of knownToolSources) {
30
+ if (matchesAlwaysActive([entry], source.serverName, source.toolName)) {
31
+ resolved.add(namespaced);
32
+ matched = true;
33
+ }
34
+ }
35
+ if (!matched && entry.toolName !== undefined && entry.serverName === undefined) {
36
+ // Late-discovery hatch: a `{ toolName }`-only entry that doesn't
37
+ // resolve today might match a yet-to-be-discovered MCP tool by the
38
+ // same name. Carry it forward so the partition function can keep
39
+ // it visible. Server-scoped misses are NOT carried — those are
40
+ // typos (configured server name we know of, no matching tool) and
41
+ // surfacing them would mask the typo.
42
+ unresolvedToolNames.push(entry.toolName);
43
+ }
44
+ }
45
+ return { resolved, unresolvedToolNames };
46
+ }
47
+ /**
48
+ * Resolve `pool` / `alwaysActive` against known tools.
49
+ *
50
+ * - `pool: '*'` expands to all known namespaced names; unknown pool names
51
+ * in an explicit array are dropped.
52
+ * - `alwaysActive` is the resolved-and-namespaced set from
53
+ * {@link resolveAlwaysActive}, plus any unresolved `{ toolName }`-only
54
+ * entries kept for late MCP discovery.
55
+ * - alwaysActive overrides pool on overlap.
56
+ */
57
+ export function partitionPool(poolSpec, alwaysActiveResolved, unresolvedToolNames, allKnown) {
58
+ // Late-discovery names appended verbatim — the partition's consumer (the
59
+ // ToolSearchProcessor) treats `alwaysActive` as a name-list it filters
60
+ // against the live tool catalog, so unmatched names are harmless until
61
+ // they resolve.
62
+ const merged = [...alwaysActiveResolved, ...unresolvedToolNames];
63
+ const activeSet = new Set(merged);
13
64
  const resolvedPoolBase = poolSpec === '*' ? Array.from(allKnown) : poolSpec.filter((n) => allKnown.has(n));
14
65
  const pool = resolvedPoolBase.filter((n) => !activeSet.has(n));
15
- return { pool, alwaysActive: resolvedActive };
66
+ return { pool, alwaysActive: merged };
16
67
  }
17
68
  //# sourceMappingURL=tool-search.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,40 +24,41 @@
24
24
  "dist",
25
25
  "!dist/**/*.map",
26
26
  "!dist/test",
27
+ "CHANGELOG.md",
27
28
  "LICENSE.txt"
28
29
  ],
29
30
  "dependencies": {
30
31
  "@ai-sdk/provider": "^3.0.10",
31
32
  "@libsql/client": "^0.15.15",
32
- "@mastra/core": "^1.36.0",
33
+ "@mastra/core": "^1.37.1",
33
34
  "@mastra/libsql": "^1.11.1",
34
- "@mastra/mcp": "^1.8.0",
35
- "@mastra/memory": "^1.19.0",
35
+ "@mastra/mcp": "^1.8.1",
36
+ "@mastra/memory": "^1.20.0",
36
37
  "@mastra/schema-compat": "^1.2.10",
37
- "@salesforce/agentic-common": "0.8.0",
38
- "@salesforce/llm-gateway-sdk": "0.12.0"
38
+ "@salesforce/agentic-common": "0.9.0",
39
+ "@salesforce/llm-gateway-sdk": "0.13.0"
39
40
  },
40
41
  "peerDependencies": {
41
- "@salesforce/sfdx-agent-sdk": "0.16.0"
42
+ "@salesforce/sfdx-agent-sdk": "0.17.0"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@eslint/js": "^10.0.1",
45
- "@salesforce/sfdx-agent-sdk": "0.16.0",
46
- "@types/node": "^22.19.17",
47
- "@vitest/coverage-istanbul": "^4.1.7",
48
- "@vitest/eslint-plugin": "^1.6.17",
49
- "eslint": "^10.4.0",
46
+ "@salesforce/sfdx-agent-sdk": "0.17.0",
47
+ "@types/node": "^22.19.19",
48
+ "@vitest/coverage-istanbul": "^4.1.8",
49
+ "@vitest/eslint-plugin": "^1.6.19",
50
+ "eslint": "^10.4.1",
50
51
  "eslint-config-prettier": "^10.1.8",
51
- "eslint-import-resolver-typescript": "^4.4.4",
52
+ "eslint-import-resolver-typescript": "^4.4.5",
52
53
  "eslint-plugin-import": "^2.32.0",
53
54
  "eslint-plugin-n": "^18.0.1",
54
55
  "globals": "^17.6.0",
55
- "lint-staged": "^17.0.5",
56
+ "lint-staged": "^17.0.7",
56
57
  "prettier": "^3.8.3",
57
58
  "rimraf": "^6.1.3",
58
- "tsx": "^4.22.3",
59
+ "tsx": "^4.22.4",
59
60
  "typescript": "^6.0.3",
60
- "typescript-eslint": "^8.59.4",
61
+ "typescript-eslint": "^8.60.1",
61
62
  "vitest": "^4.1.7"
62
63
  },
63
64
  "engines": {