@salesforce/sfdx-agent-harness-claude 0.11.0 → 0.13.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 +24 -0
- package/README.md +147 -52
- package/dist/claude-approval-coordinator.d.ts +205 -0
- package/dist/claude-approval-coordinator.js +533 -0
- package/dist/claude-approval-coordinator.js.map +1 -0
- package/dist/claude-config.d.ts +94 -0
- package/dist/claude-config.js +6 -0
- package/dist/claude-config.js.map +1 -0
- package/dist/claude-consumer-tool-bridge.js +1 -1
- package/dist/claude-harness-factory.d.ts +4 -3
- package/dist/claude-harness-factory.js +6 -1
- package/dist/claude-harness-factory.js.map +1 -1
- package/dist/claude-harness.d.ts +88 -16
- package/dist/claude-harness.js +310 -155
- package/dist/claude-harness.js.map +1 -1
- package/dist/claude-mcp-bridge.d.ts +11 -8
- package/dist/claude-mcp-bridge.js +21 -16
- package/dist/claude-mcp-bridge.js.map +1 -1
- package/dist/claude-query-options-builder.d.ts +2 -2
- package/dist/claude-query-options-builder.js +85 -38
- package/dist/claude-query-options-builder.js.map +1 -1
- package/dist/claude-skill-mcp-server.d.ts +32 -12
- package/dist/claude-skill-mcp-server.js +146 -66
- package/dist/claude-skill-mcp-server.js.map +1 -1
- package/dist/gen-sink.d.ts +6 -19
- package/dist/gen-sink.js +6 -47
- package/dist/gen-sink.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-error-classifier.js +2 -2
- package/dist/mcp-error-classifier.js.map +1 -1
- package/dist/skill-ranker.d.ts +35 -0
- package/dist/skill-ranker.js +106 -0
- package/dist/skill-ranker.js.map +1 -0
- package/dist/test/tsconfig.tsbuildinfo +1 -1
- package/dist/text-stream.d.ts +1 -1
- package/dist/text-stream.js +1 -1
- package/package.json +15 -14
- package/dist/claude-stream-broker.d.ts +0 -274
- package/dist/claude-stream-broker.js +0 -580
- package/dist/claude-stream-broker.js.map +0 -1
- package/dist/consumer-tool-broker.d.ts +0 -200
- package/dist/consumer-tool-broker.js +0 -369
- package/dist/consumer-tool-broker.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@salesforce/sfdx-agent-harness-claude` are documented in this file.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [0.13.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
|
+
|
|
18
|
+
### Chores
|
|
19
|
+
- **harness-claude,harness-mastra**: re-enable monorepoLintPack at four offender sites ([#561](https://github.com/forcedotcom/agentic-dx/pull/561))
|
|
20
|
+
- **deps-dev**: bump the eslint group across 1 directory with 2 updates ([#553](https://github.com/forcedotcom/agentic-dx/pull/553))
|
|
21
|
+
- **deps-dev**: bump the dev-dependencies group across 1 directory with 4 updates ([#536](https://github.com/forcedotcom/agentic-dx/pull/536))
|
|
22
|
+
- **deps-dev**: bump the vitest group across 1 directory with 3 updates ([#552](https://github.com/forcedotcom/agentic-dx/pull/552))
|
|
23
|
+
- **deps**: bump @anthropic-ai/claude-agent-sdk from 0.3.148 to 0.3.160 ([#540](https://github.com/forcedotcom/agentic-dx/pull/540))
|
|
24
|
+
|
package/README.md
CHANGED
|
@@ -36,6 +36,10 @@ internal architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
|
36
36
|
| `ClaudeHarnessFactory` | class | `HarnessFactory` that produces a Claude-backed `AgentHarness` bound to a storage root. |
|
|
37
37
|
| `ClaudeHarnessFactoryConfig` | interface | Configuration for `ClaudeHarnessFactory` (`resolver`, `permissionMode`, optional `queryDefaults`, `toolApprovalTimeoutMs`, `bypassApprovalTools`). |
|
|
38
38
|
| `ClaudeHarnessPermissionMode` | type | Narrowed permission mode: `'bypassPermissions' \| 'acceptEdits' \| 'plan'`. |
|
|
39
|
+
| `ClaudeAgentHarness` | type | Branded `AgentHarness` subtype carrying the Claude-specific config inference. Inferred at `manager.createAgent` automatically — annotation rarely needed. |
|
|
40
|
+
| `ClaudeAgentConfig` | type | `AgentConfig` extended with Claude-only fields (`skillSearch`, `toolSearch`). |
|
|
41
|
+
| `ClaudeSkillSearchConfig` | type | Shape of `ClaudeAgentConfig.skillSearch` (`topK`). Opts the in-process `skill_bridge` MCP server into search-on-demand mode. |
|
|
42
|
+
| `ClaudeToolSearchConfig` | type | Shape of `ClaudeAgentConfig.toolSearch` (`alwaysActive`). Stamps `_meta['anthropic/alwaysLoad']` on matching tools so they bypass Claude tool-search deferral. |
|
|
39
43
|
| `ClaudeTransportResolver` | interface | Strategy for resolving transport env, auth, and native model ID for the subprocess. |
|
|
40
44
|
| `ClaudeTransportResolverResult` | type | Return type of `ClaudeTransportResolver.resolve()` (`env` + `nativeModelId`). |
|
|
41
45
|
| `ClaudeQueryDefaults` | type | Consumer-overridable defaults for the Claude `query()` call (excludes `model`). |
|
|
@@ -57,6 +61,74 @@ provide. For human-in-the-loop gating, set `StreamOptions.requireToolApproval: t
|
|
|
57
61
|
installs a `PreToolUse` SDK hook for that turn that gates every tool call through the SDK's `tool-approval-request` flow
|
|
58
62
|
regardless of the factory-pinned permission mode.
|
|
59
63
|
|
|
64
|
+
### Skill search
|
|
65
|
+
|
|
66
|
+
`AgentConfig.skills` is a cross-harness field; both Mastra and Claude surface configured skills to the model. By default
|
|
67
|
+
the Claude harness exposes the catalog via the in-process `skill_bridge` MCP server's `load_skill` tool, whose
|
|
68
|
+
description enumerates skill names and descriptions so the model can pick directly. That shape is the right call at
|
|
69
|
+
small N (no extra round-trip).
|
|
70
|
+
|
|
71
|
+
For larger catalogs, set `ClaudeAgentConfig.skillSearch` to switch the same in-process server into a
|
|
72
|
+
`search_skills(query) → topK` + `load_skill(name)` shape. The catalog is dropped from `load_skill`'s description so it
|
|
73
|
+
never sits in the prompt at rest, and the model is forced to discover skills via search. The opt-in field mirrors
|
|
74
|
+
`MastraAgentConfig.skillSearch` so cross-harness consumers configure the on-demand path the same way on both harnesses.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const agent = await manager.createAgent(projectRoot, {
|
|
78
|
+
instructions: '...',
|
|
79
|
+
skills: ['/path/to/skills'],
|
|
80
|
+
skillSearch: { topK: 5 }, // ranked, on-demand discovery; defaults to 5 if omitted
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`skillSearch.topK` is advisory; defaults to 5. The ranker is an in-tree TF-style scorer with name-match boost — no
|
|
85
|
+
embedder dependency, no external service, deterministic across runs (alphabetical tie-break). The model sees both
|
|
86
|
+
`mcp__skill_bridge__search_skills` and `mcp__skill_bridge__load_skill` in its tool catalog and the existing always-load
|
|
87
|
+
meta keeps both tools exempt from Claude tool-search deferral.
|
|
88
|
+
|
|
89
|
+
When `skillSearch` is unset (the default), the existing description-enumeration behavior is preserved unchanged.
|
|
90
|
+
|
|
91
|
+
### Tool-exposure policy (`toolSearch.alwaysActive`)
|
|
92
|
+
|
|
93
|
+
The Claude Agent SDK enables tool-search deferral once the active tool surface crosses a threshold (≈ tens of tools);
|
|
94
|
+
once on, individual tools are hidden behind a `tool_search → load → invoke` round-trip until the model asks for them. On
|
|
95
|
+
the Salesforce LLM Gateway's Bedrock-Sonnet path that round-trip can stall the turn (see `ARCHITECTURE.md` →
|
|
96
|
+
`_meta['anthropic/alwaysLoad']`). To pin specific tools into the model's catalog regardless of how the surrounding
|
|
97
|
+
surface grows, list them on `ClaudeAgentConfig.toolSearch.alwaysActive`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const agent = await manager.createAgent(projectRoot, {
|
|
101
|
+
instructions: '...',
|
|
102
|
+
mcpServers: {
|
|
103
|
+
sfdx: { type: 'stdio', command: '...' },
|
|
104
|
+
other: { type: 'stdio', command: '...' },
|
|
105
|
+
},
|
|
106
|
+
toolSearch: {
|
|
107
|
+
alwaysActive: [
|
|
108
|
+
{ serverName: 'sfdx' }, // every tool from this server (post-discovery)
|
|
109
|
+
{ serverName: 'other', toolName: 'lookup_account' }, // exactly one tool
|
|
110
|
+
{ toolName: 'resume_tool_operation' }, // any tool by this name, regardless of source
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Each entry covers one of three patterns:
|
|
117
|
+
|
|
118
|
+
| Entry shape | Matches |
|
|
119
|
+
| ------------------------------------ | ---------------------------------------------------------------------- |
|
|
120
|
+
| `{ serverName: 'X' }` | every tool advertised by server `X` |
|
|
121
|
+
| `{ serverName: 'X', toolName: 'Y' }` | exactly tool `Y` on server `X` |
|
|
122
|
+
| `{ toolName: 'Y' }` | any tool named `Y` regardless of source (built-ins, workspace, MCP, …) |
|
|
123
|
+
|
|
124
|
+
At least one of `serverName` / `toolName` must be present — empty `{}` rejects at config time. Mirrors
|
|
125
|
+
`MastraAgentConfig.toolSearch.alwaysActive` so cross-harness consumers configure tool-exposure policy the same way on
|
|
126
|
+
both harnesses.
|
|
127
|
+
|
|
128
|
+
**Caveat — Claude built-ins:** the Claude SDK has no host-side hook for stamping `_meta['anthropic/alwaysLoad']` on
|
|
129
|
+
built-in tools (`Bash`, `Read`, `Edit`, …). A `{ toolName: 'Bash' }` entry today is a no-op on built-ins and only takes
|
|
130
|
+
effect when a tool by that name comes from a configured MCP server or consumer-declared `AgentConfig.tools`.
|
|
131
|
+
|
|
60
132
|
### Multimodal input
|
|
61
133
|
|
|
62
134
|
`chatSession.chat()` / harness `stream()` accept a `MessagePart[]` carrying `image` (PNG/JPEG) and `file` (PDF) parts
|
|
@@ -68,22 +140,24 @@ mirror the gateway SDK's conservative limits so a file behaves identically here
|
|
|
68
140
|
|
|
69
141
|
### Tool approval
|
|
70
142
|
|
|
71
|
-
When `StreamOptions.requireToolApproval: true` is set on `chatSession.
|
|
72
|
-
|
|
143
|
+
When `StreamOptions.requireToolApproval: true` is set on `chatSession.chat()`, the harness installs a `PreToolUse` hook
|
|
144
|
+
that:
|
|
73
145
|
|
|
74
|
-
1. Emits a `tool-approval-request` event on `
|
|
146
|
+
1. Emits a `tool-approval-request` event on the `chat()`-returned `eventStream` carrying
|
|
75
147
|
`{ toolCall: { toolCallId, toolName, args } }`.
|
|
76
|
-
2.
|
|
77
|
-
`chatSession.
|
|
78
|
-
3.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
148
|
+
2. Parks the SDK hook Promise until the consumer calls `chatSession.approveToolCall(toolCallId)` or
|
|
149
|
+
`chatSession.declineToolCall(toolCallId)`. Settle methods return `Promise<void>`.
|
|
150
|
+
3. Continues delivering events on the **same** `eventStream` after the consumer settles — the resulting `tool-result`,
|
|
151
|
+
the model's follow-up `text-delta` events, and the terminal `finish` all arrive on that one stream. The consumer
|
|
152
|
+
keeps iterating until it sees `finish`.
|
|
153
|
+
- On **approve**: the SDK's `tool_use` runs and the `tool-result` lands on the stream.
|
|
154
|
+
- On **decline**: the SDK synthesizes a `tool_result` with `isError: true`, the model receives the denial and emits
|
|
155
|
+
an acknowledgement, then `finish`. The decline does **not** abort the assistant turn. The decline message is
|
|
156
|
+
hard-coded to `'User declined the tool call.'`.
|
|
157
|
+
|
|
158
|
+
This is the post-#529 single-stream-per-turn contract — see the SDK README's "Tool Approval Flow" for consumer-facing
|
|
159
|
+
patterns. Single-tool approve / decline, parallel batches (`requireToolApproval: 'batch'`), approval-timeout
|
|
160
|
+
enforcement, `abortSignal` propagation, and shutdown / `destroyThread` cleanup all share that contract.
|
|
87
161
|
|
|
88
162
|
#### Abort signal
|
|
89
163
|
|
|
@@ -91,8 +165,7 @@ When `StreamOptions.abortSignal` fires during an active turn with `requireToolAp
|
|
|
91
165
|
|
|
92
166
|
- All pending permission Promises reject with an `AbortError`.
|
|
93
167
|
- The underlying Claude subprocess is killed (existing `AbortController` wiring).
|
|
94
|
-
-
|
|
95
|
-
`tool-result` hasn't arrived yet — ends with an `error` event (`code: 'aborted'`, `error.name === 'AbortError'`)
|
|
168
|
+
- The single per-turn `eventStream` ends with an `error` event (`code: 'aborted'`, `error.name === 'AbortError'`)
|
|
96
169
|
followed by a `finish` event (`finishReason: 'error'`). The `'error'` finish reason is used because the SDK's
|
|
97
170
|
`FinishReason` type has no `'aborted'` value; the preceding `error` event carries the abort identity.
|
|
98
171
|
|
|
@@ -101,12 +174,12 @@ If the signal is already aborted when `stream()` is called, `stream()` throws sy
|
|
|
101
174
|
|
|
102
175
|
#### Shutdown and thread teardown
|
|
103
176
|
|
|
104
|
-
Calling `harness.shutdown()` disposes every tracked
|
|
105
|
-
the parked Promise rejects with an `AgentSDKError` whose `type` is `'DISPOSED'`;
|
|
106
|
-
single `error` event (no `finish`) and the underlying Claude subprocess is killed. Calling
|
|
107
|
-
`destroyThread(agentId, threadId)` performs the same cleanup but only for the
|
|
108
|
-
`(agentId, threadId)` pair —
|
|
109
|
-
`shuttingDown` guard prevents double-dispose; the
|
|
177
|
+
Calling `harness.shutdown()` disposes every tracked coordinator (across all agents and threads). For each pending
|
|
178
|
+
approval, the parked Promise rejects with an `AgentSDKError` whose `type` is `'DISPOSED'`; the per-turn `eventStream`
|
|
179
|
+
ends with a single `error` event (no `finish`) and the underlying Claude subprocess is killed. Calling
|
|
180
|
+
`destroyThread(agentId, threadId)` performs the same cleanup but only for the coordinator associated with that
|
|
181
|
+
`(agentId, threadId)` pair — coordinators on other threads are untouched. Both entry points are idempotent (the
|
|
182
|
+
harness's `shuttingDown` guard prevents double-dispose; the coordinator's `disposed` flag does the same).
|
|
110
183
|
|
|
111
184
|
#### Approval timeout
|
|
112
185
|
|
|
@@ -115,21 +188,18 @@ Each pending approval is auto-denied after `ClaudeHarnessFactoryConfig.toolAppro
|
|
|
115
188
|
|
|
116
189
|
- The parked `PreToolUse` hook Promise resolves with `permissionDecision: 'deny'` plus `decision: 'block'` so the
|
|
117
190
|
agentic loop terminates instead of letting the model retry the timed-out call.
|
|
118
|
-
- An `error` event with `code: 'tool-approval-timeout'` is emitted on the consumer's currently-
|
|
191
|
+
- An `error` event with `code: 'tool-approval-timeout'` is emitted on the consumer's currently-iterating `eventStream`,
|
|
119
192
|
then that stream ends.
|
|
120
|
-
- Any subsequent `approveToolCall` / `declineToolCall` for the timed-out tool throws because the
|
|
121
|
-
|
|
193
|
+
- Any subsequent `approveToolCall` / `declineToolCall` for the timed-out tool throws because the coordinator marks every
|
|
194
|
+
emitted approval as auto-resolved during teardown.
|
|
122
195
|
|
|
123
196
|
Calling `approveToolCall` or `declineToolCall` before the timeout cancels the timer — no spurious error event surfaces
|
|
124
197
|
afterwards.
|
|
125
198
|
|
|
126
|
-
> **Consumer pattern:** to observe the timeout (or `abort` / `dispose`) error on the
|
|
127
|
-
>
|
|
128
|
-
>
|
|
129
|
-
>
|
|
130
|
-
> (`if (event.type === 'tool-approval-request') approval = event`) and let the loop exit when the stream ends. Consumers
|
|
131
|
-
> that hand off to a continuation sink via `approveToolCall` / `declineToolCall` can use either pattern — terminal
|
|
132
|
-
> events on the continuation flow normally.
|
|
199
|
+
> **Consumer pattern:** to observe the timeout (or `abort` / `dispose`) error on the `eventStream`, iterate the stream
|
|
200
|
+
> to its natural end rather than `break`ing out after the `tool-approval-request` event. Calling `iterator.return()`
|
|
201
|
+
> (which `break` does inside `for await`) finalizes the harness's source generator before the terminal event can reach
|
|
202
|
+
> you. Settle in-line and keep iterating; the loop exits naturally when the terminal `finish` arrives.
|
|
133
203
|
|
|
134
204
|
### Using the Salesforce Bedrock resolver
|
|
135
205
|
|
|
@@ -165,8 +235,8 @@ const manager = await createAgentManager('/path/to/storage', harnessFactory);
|
|
|
165
235
|
|
|
166
236
|
JWT refresh works the same as elsewhere in the harness: each `resolve()` call eagerly reads `orgJwt.getValue()` /
|
|
167
237
|
`orgJwt.getTenantKey()` and freezes the result into the static subprocess headers. `Agent.updateAgentConfig({})` runs
|
|
168
|
-
`harness.
|
|
169
|
-
|
|
238
|
+
`harness.updateAgent`, the resolver re-reads the fresh JWT on the next `stream()`, and the next subprocess spawns with
|
|
239
|
+
the new headers — connected MCP clients are preserved through the rotation.
|
|
170
240
|
|
|
171
241
|
### Using a custom resolver
|
|
172
242
|
|
|
@@ -271,9 +341,11 @@ const agent = await manager.createAgent(projectRoot, {
|
|
|
271
341
|
});
|
|
272
342
|
|
|
273
343
|
// In your event loop, answer the consumer tool's `tool-call` via
|
|
274
|
-
// `session.submitToolResult({ toolCallId, toolName, result })`.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
344
|
+
// `session.submitToolResult({ toolCallId, toolName, result })`. Consumer
|
|
345
|
+
// tools never reach the approval gate (the harness adds the consumer-tool
|
|
346
|
+
// wire prefix to `bypassApprovalTools` automatically — see #529 step 2b).
|
|
347
|
+
// They surface as a normal `tool-call` event with no preceding
|
|
348
|
+
// `tool-approval-request`, even when `requireToolApproval: true`.
|
|
277
349
|
```
|
|
278
350
|
|
|
279
351
|
This is the recommended pattern when the consumer wants identical behavior across Mastra and Claude. For Claude-only
|
|
@@ -281,39 +353,62 @@ consumers who prefer to drive the built-in directly, see "Bypassing the approval
|
|
|
281
353
|
|
|
282
354
|
### Bypassing the approval gate for specific tools
|
|
283
355
|
|
|
284
|
-
When `requireToolApproval: true` is set, every tool call reaches the
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
gate.
|
|
356
|
+
When `requireToolApproval: true` is set, every built-in / MCP tool call reaches the coordinator's `PreToolUse` hook by
|
|
357
|
+
default — including Claude built-ins like `AskUserQuestion`. (Consumer-declared tools from `AgentConfig.tools[i]`
|
|
358
|
+
already bypass automatically — see "Tool approval" above.) `ClaudeHarnessFactoryConfig.bypassApprovalTools` opts
|
|
359
|
+
additional tool names out of the gate.
|
|
288
360
|
|
|
289
361
|
```ts
|
|
290
362
|
const harnessFactory = new ClaudeHarnessFactory({
|
|
291
363
|
resolver: new SalesforceBedrockClaudeTransportResolver({ gatewayBaseUrl: '...' }),
|
|
292
364
|
permissionMode: 'bypassPermissions',
|
|
293
365
|
// Even when `requireToolApproval: true`, calls to these tools bypass the
|
|
294
|
-
//
|
|
295
|
-
// consumer can drive their UX off the `tool-call` directly.
|
|
296
|
-
bypassApprovalTools: [
|
|
366
|
+
// PreToolUse hook. They still emit `tool-call` / `tool-result` ChatEvents,
|
|
367
|
+
// so the consumer can drive their UX off the `tool-call` directly.
|
|
368
|
+
bypassApprovalTools: [
|
|
369
|
+
'AskUserQuestion', // Claude built-in
|
|
370
|
+
'ask_user_tool', // consumer-declared (also bypassed automatically; listing here is harmless)
|
|
371
|
+
],
|
|
297
372
|
});
|
|
298
373
|
```
|
|
299
374
|
|
|
375
|
+
Tool names use the **consumer-facing** form — the bare `AgentConfig.tools[i].name` for consumer-declared tools, the
|
|
376
|
+
SDK-built-in name for Claude built-ins (no `mcp__<bridge>__` prefix needed). The harness expands each entry into the
|
|
377
|
+
underlying wire-prefixed form (`mcp__sfdx-agent-sdk-consumer-tools__<name>`) where applicable so consumer tools bypass
|
|
378
|
+
correctly without consumers needing to know the internal namespacing.
|
|
379
|
+
|
|
300
380
|
`bypassApprovalTools` is encoded as a negative-lookahead regex on the SDK `PreToolUse` hook's `matcher` field, so
|
|
301
381
|
non-matching tools fall through to the SDK's permission cache and run natively under the factory-pinned
|
|
302
382
|
`permissionMode`. The flag is Claude-only by design; Mastra has no equivalent because its approval pipeline gates
|
|
303
|
-
consumer-declared tools
|
|
383
|
+
consumer-declared tools differently.
|
|
304
384
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
385
|
+
For bypassed consumer tools, the chat stream emits the `tool-call` ChatEvent and a synthetic `finish('tool-calls')` on
|
|
386
|
+
the same `eventStream` (the SDK contract for client-side tools — see `ToolDefinition` JSDoc). The consumer runs the tool
|
|
387
|
+
locally and calls `submitToolResult` with the result; the agent loop continues on the same `eventStream` (post-#529
|
|
388
|
+
single-stream-per-turn contract).
|
|
309
389
|
|
|
310
390
|
> **Note:** `permissionMode: 'plan'` denies mutating built-ins (`Edit`, `Write`, `Bash`, …) at the SDK's permission
|
|
311
391
|
> cache rather than running them natively. Bypassing such a tool with `bypassApprovalTools` does not "unlock" it under
|
|
312
392
|
> plan mode — the SDK still denies it. Plan mode applies to the whole subprocess; this flag only chooses whether the
|
|
313
|
-
> harness's
|
|
393
|
+
> harness's coordinator gets a say.
|
|
394
|
+
|
|
395
|
+
When `bypassApprovalTools` is undefined or empty (default), every built-in / MCP tool call reaches the coordinator — no
|
|
396
|
+
behavior change for existing consumers. (Consumer-declared tools always bypass.)
|
|
397
|
+
|
|
398
|
+
### Tool-result redaction
|
|
399
|
+
|
|
400
|
+
Tool-result redaction is configured at the manager layer, not the factory. Pass a `hooksForAgent` callback to
|
|
401
|
+
`createAgentManager`; the SDK threads the resolved `AgentHooks` bag through `createAgent`'s `options.hooks`, and the
|
|
402
|
+
Claude harness wires `hooks.onToolResult` to the Claude Agent SDK's `PostToolUse` hook (`updatedToolOutput`)
|
|
403
|
+
automatically. The same `ToolResultRedactor` works on the Mastra harness. See the SDK README → "Tool-Result Redaction"
|
|
404
|
+
for the full pattern (including a `failClosed(...)` wrapper consumers can use to substitute a safe stub on throw).
|
|
405
|
+
|
|
406
|
+
**Bash gotcha.** Claude's built-in `Bash` tool requires the `{ stdout, stderr, interrupted }` shape on the redactor's
|
|
407
|
+
replacement output. A bare-string return is rejected by the Claude Agent SDK and the original leaks. The harness does
|
|
408
|
+
NOT validate this — the redactor knows what tool it's redacting.
|
|
314
409
|
|
|
315
|
-
|
|
316
|
-
|
|
410
|
+
If you also register your own `PostToolUse` hooks via `queryDefaults.hooks.PostToolUse`, both fire — the harness's hook
|
|
411
|
+
is appended **last** in the list so its `updatedToolOutput` has final say over the value the model sees.
|
|
317
412
|
|
|
318
413
|
## Development
|
|
319
414
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { HookCallback, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { type ChatEvent } from '@salesforce/sfdx-agent-sdk';
|
|
3
|
+
import { GenSink } from '@salesforce/sfdx-agent-sdk/harness';
|
|
4
|
+
import type { ClaudeEventAdapter, McpToolMetadataLookup } from './claude-event-adapter.js';
|
|
5
|
+
export interface ClaudeApprovalCoordinatorOptions {
|
|
6
|
+
adapter: ClaudeEventAdapter;
|
|
7
|
+
toolApprovalTimeoutMs: number;
|
|
8
|
+
abortController: AbortController;
|
|
9
|
+
/**
|
|
10
|
+
* Optional lookup that resolves an MCP server name + annotations from a
|
|
11
|
+
* Claude-namespaced tool name (`mcp__<server>__<tool>`). When present,
|
|
12
|
+
* `tool-approval-request` events the coordinator pushes from
|
|
13
|
+
* {@link preToolUseHook} carry `serverName` + `annotations` for
|
|
14
|
+
* MCP-bridged tools — matching the enrichment the adapter applies to
|
|
15
|
+
* `tool-call` / `tool-result` events.
|
|
16
|
+
*/
|
|
17
|
+
lookupMcpToolMetadata?: McpToolMetadataLookup;
|
|
18
|
+
/**
|
|
19
|
+
* Approval-emission mode (#447). Defaults to `'serial'` when omitted.
|
|
20
|
+
*
|
|
21
|
+
* - `'serial'`: emit `tool-approval-request` only when the SDK's
|
|
22
|
+
* `PreToolUse` hook fires for that tool — one at a time. Identical
|
|
23
|
+
* to behavior before parallel-approval UX. Safe for consumers
|
|
24
|
+
* whose iterator returns on the first approval-request.
|
|
25
|
+
* - `'batch'`: front-load `tool-approval-request` emission off the
|
|
26
|
+
* adapter's `tool-call` events so parallel `tool_use` batches
|
|
27
|
+
* surface all approvals on the same stream. Consumers MUST iterate
|
|
28
|
+
* to natural park collecting approvals.
|
|
29
|
+
*/
|
|
30
|
+
mode?: 'serial' | 'batch';
|
|
31
|
+
/**
|
|
32
|
+
* Logical bridge server name for the consumer-tools bridge. The
|
|
33
|
+
* coordinator strips this prefix on `tool-call` / `tool-result`
|
|
34
|
+
* ChatEvents before forwarding them to the consumer so the
|
|
35
|
+
* `ToolCallEvent.toolName === AgentConfig.tools[i].name` cross-harness
|
|
36
|
+
* contract holds.
|
|
37
|
+
*
|
|
38
|
+
* When omitted, the coordinator skips consumer-tool bookkeeping
|
|
39
|
+
* entirely — used by tests that drive the approval path without any
|
|
40
|
+
* consumer tools registered.
|
|
41
|
+
*/
|
|
42
|
+
consumerToolsBridgeName?: string;
|
|
43
|
+
}
|
|
44
|
+
export declare class ClaudeApprovalCoordinator {
|
|
45
|
+
private readonly adapter;
|
|
46
|
+
private readonly toolApprovalTimeoutMs;
|
|
47
|
+
private readonly abortController;
|
|
48
|
+
private readonly lookupMcpToolMetadata?;
|
|
49
|
+
private readonly mode;
|
|
50
|
+
private readonly consumerToolBridgePrefix;
|
|
51
|
+
/** Single per-turn sink. Issue #529 contract: one stream per turn. */
|
|
52
|
+
private readonly initial;
|
|
53
|
+
/** Parked `PreToolUse` hook promises, keyed by `toolCallId`. */
|
|
54
|
+
private readonly pendingApprovals;
|
|
55
|
+
/** Buffered consumer decisions, keyed by `toolCallId`. */
|
|
56
|
+
private readonly decisions;
|
|
57
|
+
/** ToolCallIds for which a `tool-approval-request` has been emitted. */
|
|
58
|
+
private readonly emittedApprovals;
|
|
59
|
+
/** ToolCallIds the consumer has settled. Rejects double-settle. */
|
|
60
|
+
private readonly consumerSettled;
|
|
61
|
+
/**
|
|
62
|
+
* ToolCallIds the coordinator auto-resolved (timeout / abort / dispose).
|
|
63
|
+
* A subsequent settle for these ids rejects with "already auto-resolved".
|
|
64
|
+
*/
|
|
65
|
+
private readonly settledByCoordinator;
|
|
66
|
+
/** Args from the adapter's `tool-call` ChatEvent, keyed by `toolCallId`. */
|
|
67
|
+
private readonly toolCallArgs;
|
|
68
|
+
/**
|
|
69
|
+
* Consumer-tool: tool-call events pump has seen but no parker invocation
|
|
70
|
+
* for yet. FIFO list per tool name. Empirically the Claude subprocess
|
|
71
|
+
* emits the assistant's `tool_use` block (rendered as `tool-call`) BEFORE
|
|
72
|
+
* dispatching the MCP `tools/call` to the bridge handler.
|
|
73
|
+
*/
|
|
74
|
+
private readonly recentToolCallsByName;
|
|
75
|
+
/** Reverse lookup `toolCallId → toolName` for entries in {@link recentToolCallsByName}. */
|
|
76
|
+
private readonly recentToolCallNameById;
|
|
77
|
+
/** Consumer-tool: parker invocations awaiting a `tool-call` arrival. FIFO list per tool name. */
|
|
78
|
+
private readonly waitingParkersByName;
|
|
79
|
+
/** Consumer-tool: bound parkers, keyed by `toolCallId`. `submitToolResult` resolves these. */
|
|
80
|
+
private readonly pendingByToolCallId;
|
|
81
|
+
/** Consumer-tool: results submitted before the bridge handler parked. Keyed by `toolCallId`. */
|
|
82
|
+
private readonly preSubmittedByToolCallId;
|
|
83
|
+
/**
|
|
84
|
+
* Callbacks invoked exactly once when the coordinator tears down
|
|
85
|
+
* (`abort` / `dispose` / pump exit, whichever fires first). Used by
|
|
86
|
+
* the harness to detach the consumer-AbortSignal listener.
|
|
87
|
+
*/
|
|
88
|
+
private readonly teardownCallbacks;
|
|
89
|
+
private disposed;
|
|
90
|
+
private aborted;
|
|
91
|
+
/**
|
|
92
|
+
* Set on entry to {@link teardown} so re-entry from any reason
|
|
93
|
+
* (`abort` / `dispose` / `timeout` / `pump-error`) is a no-op. The
|
|
94
|
+
* timeout path in particular calls `abortController.abort()` which
|
|
95
|
+
* causes the pump iterator to throw — without this guard the pump's
|
|
96
|
+
* catch block would observe `!aborted && !disposed` and fire a second
|
|
97
|
+
* teardown. Side effects in `teardown` are idempotent today
|
|
98
|
+
* (`pendingApprovals` clear, ended-sink push no-ops, `teardownCallbacks`
|
|
99
|
+
* set cleared on first run, `AbortController.abort` itself is
|
|
100
|
+
* idempotent), but a single guard is cheaper than auditing every
|
|
101
|
+
* future addition.
|
|
102
|
+
*/
|
|
103
|
+
private tornDown;
|
|
104
|
+
constructor(opts: ClaudeApprovalCoordinatorOptions);
|
|
105
|
+
initialSink(): GenSink<ChatEvent>;
|
|
106
|
+
onTeardown(callback: () => void): void;
|
|
107
|
+
private runTeardownCallbacks;
|
|
108
|
+
start(query: AsyncIterable<SDKMessage>): void;
|
|
109
|
+
abort(): void;
|
|
110
|
+
dispose(): void;
|
|
111
|
+
/**
|
|
112
|
+
* Single teardown path for abort / dispose / approval-timeout /
|
|
113
|
+
* pump-error. Marks every emitted approval `settledByCoordinator`
|
|
114
|
+
* (so a subsequent `approve` / `decline` rejects with
|
|
115
|
+
* "already auto-resolved"), drains every parked `PreToolUse` hook
|
|
116
|
+
* Promise and per-id timer, rejects every parked consumer-tool
|
|
117
|
+
* parker, pushes the synthetic terminal pair on the sink, aborts
|
|
118
|
+
* the subprocess via the injected controller, and fires teardown
|
|
119
|
+
* callbacks.
|
|
120
|
+
*
|
|
121
|
+
* The `reason` parameter changes two things:
|
|
122
|
+
* - `'dispose'` pushes an error event only, no `finish` (matches
|
|
123
|
+
* pre-#529 dispose contract: disposal is consumer-triggered,
|
|
124
|
+
* not a normal turn end).
|
|
125
|
+
* - `'timeout'` resolves every parked hook with `decision: 'block'`
|
|
126
|
+
* so the agentic loop ends politely; the other reasons reject
|
|
127
|
+
* parked hooks with the underlying error so the SDK surfaces
|
|
128
|
+
* each as a tool failure.
|
|
129
|
+
*
|
|
130
|
+
* Mirrors the Mastra coordinator's `teardown` so the pump-throw /
|
|
131
|
+
* abort / dispose / timeout paths can't drift out of step on either
|
|
132
|
+
* harness — the audit's Finding #1 (pump-throw drain hygiene) is
|
|
133
|
+
* the regression this single-source teardown discipline pins.
|
|
134
|
+
*/
|
|
135
|
+
private teardown;
|
|
136
|
+
/**
|
|
137
|
+
* The Claude SDK's `PreToolUse` hook. Fires before the SDK's per-tool /
|
|
138
|
+
* per-arg permission cache (which the deprecated `canUseTool` route hit
|
|
139
|
+
* AFTER the cache, so ~70% of tool calls slipped past the gate — see
|
|
140
|
+
* #484).
|
|
141
|
+
*
|
|
142
|
+
* Consumer-executed tools never reach this hook because the options
|
|
143
|
+
* builder unconditionally bypasses the consumer-tool wire prefix in the
|
|
144
|
+
* matcher (#529 step 2b decouple). That keeps the parker path the
|
|
145
|
+
* single source of truth for consumer-tool lifecycle and lets this
|
|
146
|
+
* hook focus on built-in / external-MCP tool gating.
|
|
147
|
+
*/
|
|
148
|
+
readonly preToolUseHook: HookCallback;
|
|
149
|
+
private emitApprovalRequest;
|
|
150
|
+
private enrichmentFor;
|
|
151
|
+
private handleApprovalTimeout;
|
|
152
|
+
approve(toolCallId: string): void;
|
|
153
|
+
decline(toolCallId: string): void;
|
|
154
|
+
private settlePending;
|
|
155
|
+
/**
|
|
156
|
+
* Parker callback the in-process consumer-tools MCP bridge handler
|
|
157
|
+
* invokes when the Claude subprocess calls `tools/call` for a
|
|
158
|
+
* consumer-declared tool. Returns a Promise the consumer settles via
|
|
159
|
+
* {@link submitToolResult}.
|
|
160
|
+
*
|
|
161
|
+
* Three-way ladder:
|
|
162
|
+
* 1. A `tool-call` event with `toolCallId` already routed AND a result
|
|
163
|
+
* has been pre-submitted under that id → resolve immediately.
|
|
164
|
+
* 2. A `tool-call` event already routed (no pre-submit) → bind under
|
|
165
|
+
* `toolCallId` so the eventual `submitToolResult` resolves us.
|
|
166
|
+
* 3. Neither → queue under `toolName` (FIFO); the next `tool-call`
|
|
167
|
+
* for that name binds to us.
|
|
168
|
+
*/
|
|
169
|
+
readonly parkConsumerToolCall: (call: {
|
|
170
|
+
toolName: string;
|
|
171
|
+
args: Record<string, unknown>;
|
|
172
|
+
}) => Promise<{
|
|
173
|
+
result: unknown;
|
|
174
|
+
isError?: boolean;
|
|
175
|
+
}>;
|
|
176
|
+
/**
|
|
177
|
+
* Settle a parked consumer-tool call. Issue #529 contract: returns
|
|
178
|
+
* `void`; post-resume events flow through the same `initial` sink the
|
|
179
|
+
* consumer is iterating from `chat()`.
|
|
180
|
+
*/
|
|
181
|
+
submitToolResult(toolCallId: string, result: unknown, isError?: boolean): void;
|
|
182
|
+
private failAllPendingConsumerCalls;
|
|
183
|
+
private pump;
|
|
184
|
+
private route;
|
|
185
|
+
/**
|
|
186
|
+
* Strip the consumer-tool bridge prefix on `tool-call` / `tool-result`
|
|
187
|
+
* events and FIFO-bind `toolCallId`s for parker matching. Returns
|
|
188
|
+
* `undefined` if no rewrite is needed.
|
|
189
|
+
*/
|
|
190
|
+
private maybeRewriteForConsumerTool;
|
|
191
|
+
/**
|
|
192
|
+
* FIFO-bind a freshly-seen consumer-tool `toolCallId` to whichever queued
|
|
193
|
+
* state already exists for `strippedToolName`. Three-way ladder:
|
|
194
|
+
*
|
|
195
|
+
* 1. A parker is already waiting (bridge handler arrived first) →
|
|
196
|
+
* pair the parker against this `toolCallId`.
|
|
197
|
+
* 2. Neither parker nor pre-submitted → record the `toolCallId` against
|
|
198
|
+
* the name so the next parker invocation for that name binds to it.
|
|
199
|
+
*
|
|
200
|
+
* (Pre-submit-before-tool-call is impossible: `submitToolResult`
|
|
201
|
+
* requires a `toolCallId` the consumer received from a prior
|
|
202
|
+
* `tool-call` event.)
|
|
203
|
+
*/
|
|
204
|
+
private bindToolCallId;
|
|
205
|
+
}
|