@salesforce/sfdx-agent-sdk 0.72.0 → 0.73.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 CHANGED
@@ -3,6 +3,11 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-sdk` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.73.0] - 2026-09-04
7
+
8
+ ### Features
9
+ - **agent-sdk,harness-claude,harness-mastra,harness-openai**: validate frontend tool-call args @W-24023028@ ([#789](https://github.com/forcedotcom/agentic-dx/pull/789))
10
+
6
11
  ## [0.72.0] - 2026-09-02
7
12
 
8
13
  ### Features
package/README.md CHANGED
@@ -174,7 +174,7 @@ A single conversation thread.
174
174
  | `getContextUsage` | `() => ContextUsage` | Snapshot of how much of the model's context window the most recent turn used. |
175
175
  | `addMessages` | `(message: string \| Message[]) => Promise<void>` | Append real transcript messages (`user` / `assistant` / `tool`) to the thread **without requesting an agent response** — the write-only half of a turn. The messages persist, appear in `getMessageHistory()`, and replay to the model as prior conversation on the next `chat()`. Use it to seed earlier turns (e.g. file contents as a user message) before the first live prompt; the SDK equivalent of the service's `POST /messages` with `noReply=true`. **Not** `setSessionContext`: this writes _transcript history_ (visible in `getMessageHistory`, additive); `setSessionContext` writes an _out-of-history overlay object_ (never in history, whole-object replace). `'system'` is not a valid role here — system-level state rides `setSessionContext` / `AgentConfig.instructions`. |
176
176
  | `addContext` | `(message: string \| Message[]) => Promise<void>` | **Deprecated** — renamed to `addMessages` (identical signature/behavior); delegates to it. The old name read as a sibling of `setSessionContext`, but the two are distinct channels. Will be removed in a future release; migrate to `addMessages`. |
177
- | `setSessionContext` | `(content: SessionContext) => Promise<void>` | Replace this session's session-context object in full (whole-object set, not a merge). Persisted per-thread and durable across restart; kept out of message history, so it never appears in `getMessageHistory()`. Rendered into the model's system-level context on every subsequent turn (deterministic, key-ordered) on all three harnesses (**Mastra** W-23632686, **Claude** W-23632691, and **OpenAI** W-23632694). Delegates to `AgentHarness.setSessionContext` — see that method's JSDoc for the full delivery/durability/isolation contract. |
177
+ | `setSessionContext` | `(content: SessionContext) => Promise<void>` | Replace this session's session-context object in full (whole-object set, not a merge). Persisted per-thread and durable across restart; kept out of message history, so it never appears in `getMessageHistory()`. Rendered into the model's system-level context on every subsequent turn (deterministic, key-ordered) on all three harnesses (**Mastra** W-23632686, **Claude** W-23632691, and **OpenAI** W-23632694). Delegates to `AgentHarness.setSessionContext` — see that method's JSDoc for the full delivery/durability/isolation contract. |
178
178
  | `getSessionContext` | `() => Promise<SessionContext>` | Read this session's current session-context object. Returns `{}` (an empty object) — never `null` or `undefined` — when nothing has been set on this thread yet, so callers never need a null-check. Unrelated to `getContextUsage()`, which reports context-window token occupancy, not the seeded context object. |
179
179
  | `subscribe` | `(callback: (event: ChatEvent) => void) => void` | Register a real-time event listener. |
180
180
  | `unsubscribe` | `(callback: (event: ChatEvent) => void) => void` | Remove a listener. |
@@ -783,6 +783,7 @@ from `AgentSDKErrorType`:
783
783
  | `DISPOSED` | `Agent` and `ChatSession` methods called after the owner has been destroyed |
784
784
  | `INCOMPATIBLE_HARNESS` | `createAgentManager()` when the factory advertises an unsupported `protocolVersion`, or the constructed harness reports a `protocolVersion` that differs from the factory's |
785
785
  | `INVALID_MESSAGE_CONTENT` | `ChatSession.chat()` / harness `stream()` when a message part is not valid as input (a `tool-call`/`tool-result` part, or non-base64-string file data); also `ChatSession.setSessionContext()` / harness `setSessionContext()` when the object exceeds a harness's size / nesting bounds (Mastra: 256 KiB serialized, depth 200). `getSessionContext()` never throws on a corrupt stored slot — it soft-skips to `{}` and logs. |
786
+ | `INVALID_TOOL_SCHEMA` | `AgentManager.createAgent()` / `Agent.updateAgentConfig()` when a consumer-declared `AgentConfig.tools[i].inputSchema` is not a compilable JSON Schema. Fails loud at registration (before any harness work) so a schema typo doesn't surface lazily on the model's first call to that tool. |
786
787
  | `MCP_SERVER_DISABLED` | `Agent.reconnectMcpServer()` when the named server is configured with `enabled: false` |
787
788
  | `MCP_SERVER_NOT_FOUND` | `Agent.reconnectMcpServer()` when the server name is not in the agent's `mcpServers` config |
788
789
  | `MODEL_NOT_SUPPORTED_BY_HARNESS` | `AgentManager.createAgent()` / `Agent.updateAgentConfig()` (G8 pre-flight) when the resolved `ModelConnectivityInfo.providerHint` isn't in the harness's `supportedProviderHints`. Surfaces before any harness work runs (no MCP discovery, no subprocess spawn, no language-model construction) so the consumer can branch cleanly on `err.type` and recover without resource cleanup. |
@@ -1559,20 +1560,25 @@ This package publishes two ESM entry points:
1559
1560
  > see the subpath. Modern bundlers (Vite, esbuild, Webpack 5+, tsup, Rollup with `@rollup/plugin-node-resolve` v15+)
1560
1561
  > resolve it natively. This is a harness-author concern only; consumer applications never touch the subpath.
1561
1562
 
1562
- | Export | Surface | Role |
1563
- | ---------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1564
- | `HarnessFactory<H>` | Type only on bare; value+type on `/harness` | Construct a harness of type `H` bound to a storage root. Declares `harnessId` and `protocolVersion`. Default `H = AgentHarness`. |
1565
- | `AgentHarness` | Type only on bare; type on `/harness` | Runtime contract: agent / thread / stream / tool / message lifecycle. Declares its own `harnessId` and `protocolVersion`. |
1566
- | `SUPPORTED_PROTOCOL_VERSIONS` | `/harness` only | Readonly list of harness protocol versions this SDK accepts. `createAgentManager` checks both the factory and the constructed harness. |
1567
- | `HarnessBusOwner` | `/harness` only | Composition helper owning telemetry + log buses with `dispose()` semantics. Reuse it instead of reimplementing bus plumbing. |
1568
- | `lowerStreamInput` | `/harness` only | Validates a `MessagePart[]` and lowers each input part to your runtime's content-block shape. Use it in `stream()` so multimodal caps and `MULTIMODAL_NOT_SUPPORTED` / `INVALID_MESSAGE_CONTENT` semantics match every other harness. |
1569
- | `GenSink<T>` | `/harness` only | Buffered async-generator wrapper for routing `ChatEvent`s to a consumer's `ChatStreamResult.eventStream`. Single-iteration: calling `generator()` twice throws — sinks have one waiter slot and one buffer, two iterators race on both. |
1570
- | `mcpServerConfigEqual` | Bare specifier and `/harness` | Structural deep-equality predicate over `MCPServerConfig`. Use inside `updateAgent` to decide which servers to preserve vs. cycle. Treats `enabled: undefined` and `enabled: true` as equal; compares URLs via `String(url)` (so `URL` instances and strings round-trip); `headers` and `env` are key-order-insensitive; `reconnectionOptions` compares field-wise. |
1571
- | `AlwaysActiveEntry` | `/harness` only | Entry shape consumed by per-harness `toolSearch.alwaysActive` extension fields. Three matching patterns: `{ serverName }` (server-wide), `{ serverName, toolName }` (precise), `{ toolName }` (cross-source). At least one of `serverName` / `toolName` must be present. |
1572
- | `matchesAlwaysActive` | `/harness` only | Predicate `(entries, serverName, toolName) → boolean` consulted per-tool when stamping always-load metadata or partitioning a tool-search pool. Use this instead of pattern-matching entries by hand so harness behavior stays uniform. |
1573
- | `validateAlwaysActiveEntry` | `/harness` only | Throws on a malformed entry (`{}`, both fields empty). Call once per entry at the harness boundary so a typo fails loud at config time rather than silently dropping the entry on every `stream()`. |
1574
- | `splitToolResultsIntoToolMessages` | `/harness` only | Read-side normalizer `(Message[]) → Message[]`. Hoists every completed tool call's `tool-result` part onto its own `role: 'tool'` message (leaving the `tool-call` on the assistant message), so `getMessages()` returns the canonical cross-harness layout. Idempotent; backfills a blank result `toolName` from the matching call; preserves `isError` and (in-memory) `error`. Call it at the end of `getMessages()`. (Whether `error` survives to the returned history is a harness-persistence concern — see `AgentHarness.getMessages`; `isError` always survives.) |
1575
- | `mergeToolResultsIntoAssistant` | `/harness` only | Write-side inverse `(Message[]) → Message[]`. Folds each `role: 'tool'` message's result back adjacent to its `tool-call` in the preceding assistant message, so a runtime that stores a completed call as one merged object round-trips losslessly. Call it at the start of `addMessages()` before persisting. |
1563
+ | Export | Surface | Role |
1564
+ | ---------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1565
+ | `HarnessFactory<H>` | Type only on bare; value+type on `/harness` | Construct a harness of type `H` bound to a storage root. Declares `harnessId` and `protocolVersion`. Default `H = AgentHarness`. |
1566
+ | `AgentHarness` | Type only on bare; type on `/harness` | Runtime contract: agent / thread / stream / tool / message lifecycle. Declares its own `harnessId` and `protocolVersion`. |
1567
+ | `SUPPORTED_PROTOCOL_VERSIONS` | `/harness` only | Readonly list of harness protocol versions this SDK accepts. `createAgentManager` checks both the factory and the constructed harness. |
1568
+ | `HarnessBusOwner` | `/harness` only | Composition helper owning telemetry + log buses with `dispose()` semantics. Reuse it instead of reimplementing bus plumbing. |
1569
+ | `lowerStreamInput` | `/harness` only | Validates a `MessagePart[]` and lowers each input part to your runtime's content-block shape. Use it in `stream()` so multimodal caps and `MULTIMODAL_NOT_SUPPORTED` / `INVALID_MESSAGE_CONTENT` semantics match every other harness. |
1570
+ | `GenSink<T>` | `/harness` only | Buffered async-generator wrapper for routing `ChatEvent`s to a consumer's `ChatStreamResult.eventStream`. Single-iteration: calling `generator()` twice throws — sinks have one waiter slot and one buffer, two iterators race on both. |
1571
+ | `mcpServerConfigEqual` | Bare specifier and `/harness` | Structural deep-equality predicate over `MCPServerConfig`. Use inside `updateAgent` to decide which servers to preserve vs. cycle. Treats `enabled: undefined` and `enabled: true` as equal; compares URLs via `String(url)` (so `URL` instances and strings round-trip); `headers` and `env` are key-order-insensitive; `reconnectionOptions` compares field-wise. |
1572
+ | `AlwaysActiveEntry` | `/harness` only | Entry shape consumed by per-harness `toolSearch.alwaysActive` extension fields. Three matching patterns: `{ serverName }` (server-wide), `{ serverName, toolName }` (precise), `{ toolName }` (cross-source). At least one of `serverName` / `toolName` must be present. |
1573
+ | `matchesAlwaysActive` | `/harness` only | Predicate `(entries, serverName, toolName) → boolean` consulted per-tool when stamping always-load metadata or partitioning a tool-search pool. Use this instead of pattern-matching entries by hand so harness behavior stays uniform. |
1574
+ | `validateAlwaysActiveEntry` | `/harness` only | Throws on a malformed entry (`{}`, both fields empty). Call once per entry at the harness boundary so a typo fails loud at config time rather than silently dropping the entry on every `stream()`. |
1575
+ | `splitToolResultsIntoToolMessages` | `/harness` only | Read-side normalizer `(Message[]) → Message[]`. Hoists every completed tool call's `tool-result` part onto its own `role: 'tool'` message (leaving the `tool-call` on the assistant message), so `getMessages()` returns the canonical cross-harness layout. Idempotent; backfills a blank result `toolName` from the matching call; preserves `isError` and (in-memory) `error`. Call it at the end of `getMessages()`. (Whether `error` survives to the returned history is a harness-persistence concern — see `AgentHarness.getMessages`; `isError` always survives.) |
1576
+ | `mergeToolResultsIntoAssistant` | `/harness` only | Write-side inverse `(Message[]) → Message[]`. Folds each `role: 'tool'` message's result back adjacent to its `tool-call` in the preceding assistant message, so a runtime that stores a completed call as one merged object round-trips losslessly. Call it at the start of `addMessages()` before persisting. |
1577
+ | `validateToolArgs` | `/harness` only | Validates a raw tool-call argument buffer for a consumer/frontend tool (an `AgentConfig.tools` entry with no `execute`) against its declared `inputSchema`, returning a discriminated `'ok' \| 'unparseable'{reason:'not-json'\|'not-object'} \| 'schema' \| 'invalid-schema'` result. `'invalid-schema'` is a consumer misconfiguration (a schema that won't compile) the gate fails **open** on — the parsed args pass through with a loggable `schemaError`, never fed to the model. Call it before forwarding parsed args to the consumer so every harness rejects malformed/schema-violating buffers identically instead of silently collapsing them to `{}`. |
1578
+ | `validateToolSchemaCompiles` | `/harness` only | Registration-time guard: reports whether a consumer tool's declared `inputSchema` compiles, so a typo fails loud at `createAgent` / `updateAgentConfig` (via `INVALID_TOOL_SCHEMA`) rather than lazily at first call. Shares the same compile cache as `validateToolArgs`. |
1579
+ | `TOOL_ARGS_MAX_ATTEMPTS` | `/harness` only | The single shared retry cap (`3`) every harness bounds its per-tool-call consumer-args retries against, so the "will not be retried further" exhaustion wording can't drift between harnesses. |
1580
+ | `describeToolArgsFailure` | `/harness` only | Builds the one canonical model-visible message for a `validateToolArgs` failure outcome, so wording can't drift between harnesses. Pass `{ exhausted: true, maxAttempts }` on the final retry to append a "will not be retried further" clause. Throws on the non-failure outcomes (`'ok'` / `'invalid-schema'`), which are never fed to the model. |
1581
+ | `recordToolArgsFailure` | `/harness` only | Records one consumer-args failure against a caller-owned, tool-NAME-keyed attempt counter (`Map<string, number>`) and returns `{ attempt, exhausted }` (against `TOOL_ARGS_MAX_ATTEMPTS`, or a supplied override). The one shared increment-then-compare computation every harness's gate uses; storage stays caller-owned (harnesses scope the map per-turn/per-thread or per-`stream()`). Reset a name's streak with a plain `counts.delete(name)` on a non-failure outcome. |
1576
1582
 
1577
1583
  Minimal skeleton:
1578
1584
 
@@ -13,6 +13,7 @@ import { AgentSDKError, AgentSDKErrorType } from './errors.js';
13
13
  import { TelemetryRouter } from './internal/telemetry-router.js';
14
14
  import { WireCommunicationRouter } from './internal/wire-communication-router.js';
15
15
  import { AgentIdentityStore } from './internal/agent-identity-store.js';
16
+ import { assertToolSchemasCompile } from './internal/tool-schema-registration.js';
16
17
  import { createTelemetryBus } from './types/telemetry-events.js';
17
18
  import { DefaultAgentConnectivityResolver } from './agent-connectivity-resolver.js';
18
19
  /**
@@ -150,6 +151,11 @@ export class DefaultAgentManager {
150
151
  if (this.agents.has(agentId)) {
151
152
  throw new Error(`Agent with id "${agentId}" already exists`);
152
153
  }
154
+ // Fail loud at registration on a non-compilable consumer tool inputSchema
155
+ // (W-24023028 B1) — before any harness resources are allocated. NOT run on
156
+ // the boot-restore path (see the helper's doc): a bad persisted schema
157
+ // degrades to the runtime gate's fail-open instead of bricking reboot.
158
+ assertToolSchemasCompile(agentConfig);
153
159
  // installAgent validates projectRoot existence — same path as the restore loop.
154
160
  const agent = await this.installAgent(agentId, resolvedProjectRoot, agentConfig, {
155
161
  abortSignal: options?.abortSignal,
package/dist/agent.js CHANGED
@@ -7,6 +7,7 @@ import { toHarnessConfig, } from './harness/harness-config.js';
7
7
  import { normalizeMcpAuthProviders } from './mcp-auth.js';
8
8
  import { DefaultChatSession } from './chat-session.js';
9
9
  import { AgentSDKError, AgentSDKErrorType } from './errors.js';
10
+ import { assertToolSchemasCompile } from './internal/tool-schema-registration.js';
10
11
  import { createTelemetryBus } from './types/telemetry-events.js';
11
12
  /**
12
13
  * Resolves the next consumer-metadata value from the current value and an optional mutation. `replace` deep-clones the
@@ -187,6 +188,14 @@ export class DefaultAgent {
187
188
  }
188
189
  return;
189
190
  }
191
+ // Fail loud on a non-compilable consumer tool inputSchema in THIS update's
192
+ // `tools` delta (W-24023028 B1), before any harness work. Validate only the
193
+ // incoming delta — not the merged `nextConfig` — so a policy-only update
194
+ // (notably the load-bearing `approveToolCall({ remember: true })` path,
195
+ // which passes `toolPolicies` and no `tools`) never re-validates inherited
196
+ // schemas and so can never regress on a pre-guard agent whose persisted
197
+ // schema is bad (that degrades to the runtime gate's fail-open instead).
198
+ assertToolSchemasCompile(config);
190
199
  const orgAliasRequested = Object.prototype.hasOwnProperty.call(config, 'orgAlias');
191
200
  const modelIdRequested = Object.prototype.hasOwnProperty.call(config, 'modelId');
192
201
  let nextModelConnectivityInfo = previousModelConnectivityInfo;
package/dist/errors.d.ts CHANGED
@@ -7,6 +7,13 @@ export declare const AgentSDKErrorType: {
7
7
  readonly INCOMPATIBLE_HARNESS: 'INCOMPATIBLE_HARNESS';
8
8
  readonly INVALID_MCP_AUTH_CONFIG: 'INVALID_MCP_AUTH_CONFIG';
9
9
  readonly INVALID_MESSAGE_CONTENT: 'INVALID_MESSAGE_CONTENT';
10
+ /**
11
+ * A consumer-declared `AgentConfig.tools[i].inputSchema` is not a compilable
12
+ * JSON Schema. Thrown at `createAgent` / `updateAgentConfig` so a schema typo
13
+ * fails loud at registration rather than surfacing lazily (and fail-open) on
14
+ * the model's first call to that tool (W-24023028 B1).
15
+ */
16
+ readonly INVALID_TOOL_SCHEMA: 'INVALID_TOOL_SCHEMA';
10
17
  readonly MCP_SERVER_DISABLED: 'MCP_SERVER_DISABLED';
11
18
  readonly MCP_SERVER_NOT_FOUND: 'MCP_SERVER_NOT_FOUND';
12
19
  readonly MODEL_NOT_SUPPORTED_BY_HARNESS: 'MODEL_NOT_SUPPORTED_BY_HARNESS';
package/dist/errors.js CHANGED
@@ -11,6 +11,13 @@ export const AgentSDKErrorType = {
11
11
  INCOMPATIBLE_HARNESS: 'INCOMPATIBLE_HARNESS',
12
12
  INVALID_MCP_AUTH_CONFIG: 'INVALID_MCP_AUTH_CONFIG',
13
13
  INVALID_MESSAGE_CONTENT: 'INVALID_MESSAGE_CONTENT',
14
+ /**
15
+ * A consumer-declared `AgentConfig.tools[i].inputSchema` is not a compilable
16
+ * JSON Schema. Thrown at `createAgent` / `updateAgentConfig` so a schema typo
17
+ * fails loud at registration rather than surfacing lazily (and fail-open) on
18
+ * the model's first call to that tool (W-24023028 B1).
19
+ */
20
+ INVALID_TOOL_SCHEMA: 'INVALID_TOOL_SCHEMA',
14
21
  MCP_SERVER_DISABLED: 'MCP_SERVER_DISABLED',
15
22
  MCP_SERVER_NOT_FOUND: 'MCP_SERVER_NOT_FOUND',
16
23
  MODEL_NOT_SUPPORTED_BY_HARNESS: 'MODEL_NOT_SUPPORTED_BY_HARNESS',
@@ -51,4 +51,5 @@ export { lowerStreamInput, type InputMessagePart } from './stream-input.js';
51
51
  export { GenSink } from './gen-sink.js';
52
52
  export { matchesAlwaysActive, validateAlwaysActiveEntry, type AlwaysActiveEntry } from './always-active.js';
53
53
  export { splitToolResultsIntoToolMessages, mergeToolResultsIntoAssistant } from './tool-message-normalizer.js';
54
+ export { validateToolArgs, validateToolSchemaCompiles, describeToolArgsFailure, recordToolArgsFailure, TOOL_ARGS_MAX_ATTEMPTS, type ValidateToolArgsResult, type ToolArgsSchemaViolation, } from './tool-args-validation.js';
54
55
  export { resolveToolDeclineModelMessage } from './tool-decline.js';
@@ -10,5 +10,6 @@ export { lowerStreamInput } from './stream-input.js';
10
10
  export { GenSink } from './gen-sink.js';
11
11
  export { matchesAlwaysActive, validateAlwaysActiveEntry } from './always-active.js';
12
12
  export { splitToolResultsIntoToolMessages, mergeToolResultsIntoAssistant } from './tool-message-normalizer.js';
13
+ export { validateToolArgs, validateToolSchemaCompiles, describeToolArgsFailure, recordToolArgsFailure, TOOL_ARGS_MAX_ATTEMPTS, } from './tool-args-validation.js';
13
14
  export { resolveToolDeclineModelMessage } from './tool-decline.js';
14
15
  //# sourceMappingURL=public.js.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The single, shared retry cap for the consumer/frontend tool-args gate. Every
3
+ * harness bounds its per-tool-call retries against THIS constant so the
4
+ * exhaustion behavior — and the "will not be retried further after N failed
5
+ * attempts" wording {@link describeToolArgsFailure} builds — cannot drift
6
+ * between harnesses. Owning the cap here, next to the message that names it,
7
+ * keeps the two in lockstep (W-24023028 S2 — replaces the three per-harness
8
+ * literals that a future tune of one could have silently diverged).
9
+ */
10
+ export declare const TOOL_ARGS_MAX_ATTEMPTS = 3;
11
+ /**
12
+ * Records one model-facing tool-args failure against a caller-owned,
13
+ * tool-NAME-keyed attempt counter and reports whether the retry cap is now
14
+ * reached. Every harness gates its consumer/frontend tool-args retries with an
15
+ * identical increment-then-compare against {@link TOOL_ARGS_MAX_ATTEMPTS}; this
16
+ * factors out that one shared computation so the three can't drift on it, while
17
+ * the storage stays caller-owned (the Mastra harness scopes its counter
18
+ * per-`(agentId, threadId)`; Claude/OpenAI scope theirs per-`stream()` adapter
19
+ * instance — the map's lifetime is the harness's concern, not this helper's).
20
+ * Keying by tool NAME (not `toolCallId`) is deliberate: each retry mints a fresh
21
+ * id, so a stable name is the only thing a streak can accumulate against.
22
+ * Callers reset a name's streak on a non-failure outcome via a plain
23
+ * `counts.delete(name)` — a one-liner not worth wrapping.
24
+ */
25
+ export declare function recordToolArgsFailure(counts: Map<string, number>, toolName: string, maxAttempts?: number): {
26
+ attempt: number;
27
+ exhausted: boolean;
28
+ };
29
+ /**
30
+ * One AJV validation failure, reduced to just the fields a model-visible
31
+ * message needs. AJV's own `ErrorObject` never leaks past this module.
32
+ */
33
+ export type ToolArgsSchemaViolation = {
34
+ /** JSON-pointer-ish path to the offending field, e.g. `/city` or `/` (root). */
35
+ path: string;
36
+ /** AJV's human-readable message for this violation, e.g. "must be string". */
37
+ message: string;
38
+ };
39
+ /**
40
+ * Outcome of validating a raw tool-call argument buffer against a consumer
41
+ * tool's declared `inputSchema`.
42
+ *
43
+ * Two of these are model-facing failures the harness feeds back so the model
44
+ * can retry (`unparseable`, `schema`); the other two are not the model's fault
45
+ * and are never fed back (`ok` is success; `invalid-schema` is a consumer
46
+ * misconfiguration the gate fails *open* on — see {@link validateToolArgs}).
47
+ */
48
+ export type ValidateToolArgsResult = {
49
+ outcome: 'ok';
50
+ args: Record<string, unknown>;
51
+ } | {
52
+ outcome: 'unparseable';
53
+ rawBuffer: string;
54
+ reason: 'not-json' | 'not-object';
55
+ } | {
56
+ outcome: 'schema';
57
+ args: Record<string, unknown>;
58
+ violations: ToolArgsSchemaViolation[];
59
+ } | {
60
+ outcome: 'invalid-schema';
61
+ args: Record<string, unknown>;
62
+ schemaError: string;
63
+ };
64
+ /**
65
+ * Validates a raw tool-call argument buffer (the string a model emitted for a
66
+ * consumer/frontend tool call) against that tool's declared `inputSchema`.
67
+ *
68
+ * One code path handles every shape a harness can hand it:
69
+ *
70
+ * 1. `null` / `undefined` / whitespace-only buffer coerces to `'{}'` before
71
+ * parsing — the ONLY special case, and it folds into the normal path rather
72
+ * than short-circuiting, so an empty buffer against a schema with no
73
+ * required fields still validates as `ok:{}`.
74
+ * 2. `JSON.parse` failure ⇒ `unparseable` / `not-json`; a successfully-parsed
75
+ * non-object (array, string, number, `null`) ⇒ `unparseable` / `not-object`.
76
+ * Both echo the *original*, uncoerced buffer so the caller's error message
77
+ * reflects what the model actually sent, and the `reason` lets the message
78
+ * distinguish "fix your JSON syntax" from "send a JSON object".
79
+ * 3. No `schema` declared ⇒ `ok`, the parsed object unvalidated.
80
+ * 4. Schema that will not compile (a consumer typo) ⇒ `invalid-schema`, failing
81
+ * OPEN: the parsed `args` are returned so the caller can let the call
82
+ * through, and `schemaError` lets the caller log the misconfiguration. The
83
+ * model is never told to fix a schema it did not author. (Registration-time
84
+ * validation via {@link validateToolSchemaCompiles} is the loud early
85
+ * signal; this is the safe runtime fallback so a bad schema degrades one
86
+ * tool to no-validation instead of throwing mid-turn — W-24023028 B1.)
87
+ * 5. Otherwise validated against the cached compiled schema ⇒ `ok` or `schema`.
88
+ */
89
+ export declare function validateToolArgs(rawBuffer: string | null | undefined, schema: Record<string, unknown> | undefined): ValidateToolArgsResult;
90
+ /**
91
+ * Registration-time guard: reports whether a consumer tool's declared
92
+ * `inputSchema` compiles. Harnesses call this when an agent is created / updated
93
+ * so a schema typo fails loud at registration rather than lazily on the model's
94
+ * first call to that tool. Shares — and warms — the same compile cache
95
+ * {@link validateToolArgs} uses (W-24023028 B1).
96
+ */
97
+ export declare function validateToolSchemaCompiles(schema: Record<string, unknown>): {
98
+ compilable: true;
99
+ } | {
100
+ compilable: false;
101
+ error: string;
102
+ };
103
+ /**
104
+ * Builds the one canonical model-visible message per model-facing
105
+ * {@link ValidateToolArgsResult} failure kind, reused verbatim by every harness
106
+ * so the wording cannot drift between them.
107
+ *
108
+ * Only the two model-facing failures (`unparseable`, `schema`) have a message;
109
+ * `ok` and `invalid-schema` are never fed to the model, so calling this with
110
+ * either is a programmer error.
111
+ *
112
+ * `opts` pairs `exhausted` with `maxAttempts` as one optional unit (N1): the
113
+ * exhaustion clause interpolates the cap, so a caller cannot ask for the clause
114
+ * without supplying the number — the type makes an `…after undefined failed
115
+ * attempts.` message unconstructible rather than relying on every call site to
116
+ * remember to pass both.
117
+ */
118
+ export declare function describeToolArgsFailure(toolName: string, result: ValidateToolArgsResult, opts?: {
119
+ exhausted: true;
120
+ maxAttempts: number;
121
+ }): string;
@@ -0,0 +1,180 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { getErrorMessage } from '@salesforce/agentic-common';
6
+ import { Ajv } from 'ajv';
7
+ /**
8
+ * The single, shared retry cap for the consumer/frontend tool-args gate. Every
9
+ * harness bounds its per-tool-call retries against THIS constant so the
10
+ * exhaustion behavior — and the "will not be retried further after N failed
11
+ * attempts" wording {@link describeToolArgsFailure} builds — cannot drift
12
+ * between harnesses. Owning the cap here, next to the message that names it,
13
+ * keeps the two in lockstep (W-24023028 S2 — replaces the three per-harness
14
+ * literals that a future tune of one could have silently diverged).
15
+ */
16
+ export const TOOL_ARGS_MAX_ATTEMPTS = 3;
17
+ /**
18
+ * Records one model-facing tool-args failure against a caller-owned,
19
+ * tool-NAME-keyed attempt counter and reports whether the retry cap is now
20
+ * reached. Every harness gates its consumer/frontend tool-args retries with an
21
+ * identical increment-then-compare against {@link TOOL_ARGS_MAX_ATTEMPTS}; this
22
+ * factors out that one shared computation so the three can't drift on it, while
23
+ * the storage stays caller-owned (the Mastra harness scopes its counter
24
+ * per-`(agentId, threadId)`; Claude/OpenAI scope theirs per-`stream()` adapter
25
+ * instance — the map's lifetime is the harness's concern, not this helper's).
26
+ * Keying by tool NAME (not `toolCallId`) is deliberate: each retry mints a fresh
27
+ * id, so a stable name is the only thing a streak can accumulate against.
28
+ * Callers reset a name's streak on a non-failure outcome via a plain
29
+ * `counts.delete(name)` — a one-liner not worth wrapping.
30
+ */
31
+ export function recordToolArgsFailure(counts, toolName, maxAttempts = TOOL_ARGS_MAX_ATTEMPTS) {
32
+ const attempt = (counts.get(toolName) ?? 0) + 1;
33
+ counts.set(toolName, attempt);
34
+ return { attempt, exhausted: attempt >= maxAttempts };
35
+ }
36
+ /**
37
+ * Compiled-validator cache keyed by schema object identity. Schema objects are
38
+ * stable for the lifetime of an `AgentConfig` until a consumer supplies a new
39
+ * one via `updateAgentConfig`, so a `WeakMap` gives free invalidation with no
40
+ * explicit clear path. A *failed* compile is cached too, so a malformed schema
41
+ * is compiled (and reported) exactly once rather than re-throwing on every
42
+ * retry (W-24023028 B1).
43
+ */
44
+ const compiledCache = new WeakMap();
45
+ function compile(schema) {
46
+ const cached = compiledCache.get(schema);
47
+ if (cached) {
48
+ return cached;
49
+ }
50
+ let result;
51
+ try {
52
+ // `strict: false` — `inputSchema` is arbitrary consumer-declared JSON
53
+ // Schema, not a dialect this SDK controls. `allErrors: true` so a
54
+ // schema failure can name every offending field, not just the first.
55
+ const validate = new Ajv({ strict: false, allErrors: true }).compile(schema);
56
+ // AJV compiles an `$async: true` schema into a PROMISE-returning validator,
57
+ // not a boolean one — and `.compile()` does NOT throw for it. Our gate calls
58
+ // `validate(args)` synchronously: a returned Promise is always truthy, so a
59
+ // `$async` schema would silently pass EVERY payload as `'ok'` (validation
60
+ // bypass), and the promise it returns rejects unawaited — crashing the host
61
+ // with an unhandled rejection on the first invalid call. Treat an async
62
+ // schema as non-compilable so it degrades through the same fail-open
63
+ // (`'invalid-schema'`) runtime path and is rejected loud at registration by
64
+ // `validateToolSchemaCompiles`, exactly like a schema that won't compile.
65
+ result = validate.$async
66
+ ? { error: 'async JSON Schema ($async: true) is not supported for tool inputSchema validation' }
67
+ : { validate };
68
+ }
69
+ catch (error) {
70
+ // AJV throws synchronously on a malformed schema even with `strict:
71
+ // false` (a bad `type`, a non-object `properties`, an unresolvable
72
+ // `$ref`, a bad `pattern`, …). Capture the reason and cache it so the
73
+ // throw happens once, not on every model retry of that tool.
74
+ result = { error: getErrorMessage(error) };
75
+ }
76
+ compiledCache.set(schema, result);
77
+ return result;
78
+ }
79
+ /**
80
+ * Validates a raw tool-call argument buffer (the string a model emitted for a
81
+ * consumer/frontend tool call) against that tool's declared `inputSchema`.
82
+ *
83
+ * One code path handles every shape a harness can hand it:
84
+ *
85
+ * 1. `null` / `undefined` / whitespace-only buffer coerces to `'{}'` before
86
+ * parsing — the ONLY special case, and it folds into the normal path rather
87
+ * than short-circuiting, so an empty buffer against a schema with no
88
+ * required fields still validates as `ok:{}`.
89
+ * 2. `JSON.parse` failure ⇒ `unparseable` / `not-json`; a successfully-parsed
90
+ * non-object (array, string, number, `null`) ⇒ `unparseable` / `not-object`.
91
+ * Both echo the *original*, uncoerced buffer so the caller's error message
92
+ * reflects what the model actually sent, and the `reason` lets the message
93
+ * distinguish "fix your JSON syntax" from "send a JSON object".
94
+ * 3. No `schema` declared ⇒ `ok`, the parsed object unvalidated.
95
+ * 4. Schema that will not compile (a consumer typo) ⇒ `invalid-schema`, failing
96
+ * OPEN: the parsed `args` are returned so the caller can let the call
97
+ * through, and `schemaError` lets the caller log the misconfiguration. The
98
+ * model is never told to fix a schema it did not author. (Registration-time
99
+ * validation via {@link validateToolSchemaCompiles} is the loud early
100
+ * signal; this is the safe runtime fallback so a bad schema degrades one
101
+ * tool to no-validation instead of throwing mid-turn — W-24023028 B1.)
102
+ * 5. Otherwise validated against the cached compiled schema ⇒ `ok` or `schema`.
103
+ */
104
+ export function validateToolArgs(rawBuffer, schema) {
105
+ const original = rawBuffer ?? '';
106
+ const buffer = original.trim() === '' ? '{}' : original;
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(buffer);
110
+ }
111
+ catch {
112
+ return { outcome: 'unparseable', rawBuffer: original, reason: 'not-json' };
113
+ }
114
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
115
+ return { outcome: 'unparseable', rawBuffer: original, reason: 'not-object' };
116
+ }
117
+ const args = parsed;
118
+ if (!schema) {
119
+ return { outcome: 'ok', args };
120
+ }
121
+ const compiled = compile(schema);
122
+ if ('error' in compiled) {
123
+ return { outcome: 'invalid-schema', args, schemaError: compiled.error };
124
+ }
125
+ if (compiled.validate(args)) {
126
+ return { outcome: 'ok', args };
127
+ }
128
+ const violations = (compiled.validate.errors ?? []).map((error) => ({
129
+ path: error.instancePath || '/',
130
+ message: error.message ?? 'does not match schema',
131
+ }));
132
+ return { outcome: 'schema', args, violations };
133
+ }
134
+ /**
135
+ * Registration-time guard: reports whether a consumer tool's declared
136
+ * `inputSchema` compiles. Harnesses call this when an agent is created / updated
137
+ * so a schema typo fails loud at registration rather than lazily on the model's
138
+ * first call to that tool. Shares — and warms — the same compile cache
139
+ * {@link validateToolArgs} uses (W-24023028 B1).
140
+ */
141
+ export function validateToolSchemaCompiles(schema) {
142
+ const compiled = compile(schema);
143
+ return 'error' in compiled ? { compilable: false, error: compiled.error } : { compilable: true };
144
+ }
145
+ /**
146
+ * Builds the one canonical model-visible message per model-facing
147
+ * {@link ValidateToolArgsResult} failure kind, reused verbatim by every harness
148
+ * so the wording cannot drift between them.
149
+ *
150
+ * Only the two model-facing failures (`unparseable`, `schema`) have a message;
151
+ * `ok` and `invalid-schema` are never fed to the model, so calling this with
152
+ * either is a programmer error.
153
+ *
154
+ * `opts` pairs `exhausted` with `maxAttempts` as one optional unit (N1): the
155
+ * exhaustion clause interpolates the cap, so a caller cannot ask for the clause
156
+ * without supplying the number — the type makes an `…after undefined failed
157
+ * attempts.` message unconstructible rather than relying on every call site to
158
+ * remember to pass both.
159
+ */
160
+ export function describeToolArgsFailure(toolName, result, opts) {
161
+ let message;
162
+ if (result.outcome === 'unparseable') {
163
+ message =
164
+ result.reason === 'not-object'
165
+ ? `Invalid arguments for tool "${toolName}": arguments must be a JSON object.`
166
+ : `Invalid JSON in arguments for tool "${toolName}": the arguments were not valid JSON.`;
167
+ }
168
+ else if (result.outcome === 'schema') {
169
+ const clauses = result.violations.map((v) => `at "${v.path}", ${v.message}`).join('; ');
170
+ message = `Arguments for tool "${toolName}" do not match its schema: ${clauses}`;
171
+ }
172
+ else {
173
+ throw new Error(`describeToolArgsFailure called with a non-failure outcome: "${result.outcome}"`);
174
+ }
175
+ if (opts?.exhausted) {
176
+ message += ` This tool call will not be retried further after ${opts.maxAttempts} failed attempts.`;
177
+ }
178
+ return message;
179
+ }
180
+ //# sourceMappingURL=tool-args-validation.js.map
@@ -0,0 +1,17 @@
1
+ import type { AgentConfig } from '../harness/harness-config.js';
2
+ /**
3
+ * Registration-time guard (W-24023028 B1): asserts that every consumer-declared
4
+ * `AgentConfig.tools[i].inputSchema` compiles, throwing
5
+ * `AgentSDKError(INVALID_TOOL_SCHEMA)` on the first that does not. Called at
6
+ * `createAgent` / `updateAgentConfig` so a schema typo fails loud at
7
+ * registration rather than surfacing lazily — and fail-open — on the model's
8
+ * first call to that tool. This is the cross-harness, single-site complement to
9
+ * the runtime gate's fail-open `'invalid-schema'` outcome: normally a bad schema
10
+ * is caught here; if one ever slips to runtime the gate degrades that one tool
11
+ * to no-validation instead of terminating the turn.
12
+ *
13
+ * It is deliberately NOT called on the boot-restore path — a bad schema
14
+ * persisted before this guard shipped should degrade to fail-open at runtime,
15
+ * not brick the agent on every reboot.
16
+ */
17
+ export declare function assertToolSchemasCompile(config: Pick<AgentConfig, 'tools'>): void;
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { AgentSDKError, AgentSDKErrorType } from '../errors.js';
6
+ import { validateToolSchemaCompiles } from '../harness/tool-args-validation.js';
7
+ /**
8
+ * Registration-time guard (W-24023028 B1): asserts that every consumer-declared
9
+ * `AgentConfig.tools[i].inputSchema` compiles, throwing
10
+ * `AgentSDKError(INVALID_TOOL_SCHEMA)` on the first that does not. Called at
11
+ * `createAgent` / `updateAgentConfig` so a schema typo fails loud at
12
+ * registration rather than surfacing lazily — and fail-open — on the model's
13
+ * first call to that tool. This is the cross-harness, single-site complement to
14
+ * the runtime gate's fail-open `'invalid-schema'` outcome: normally a bad schema
15
+ * is caught here; if one ever slips to runtime the gate degrades that one tool
16
+ * to no-validation instead of terminating the turn.
17
+ *
18
+ * It is deliberately NOT called on the boot-restore path — a bad schema
19
+ * persisted before this guard shipped should degrade to fail-open at runtime,
20
+ * not brick the agent on every reboot.
21
+ */
22
+ export function assertToolSchemasCompile(config) {
23
+ for (const tool of config.tools ?? []) {
24
+ const result = validateToolSchemaCompiles(tool.inputSchema);
25
+ if (!result.compilable) {
26
+ throw new AgentSDKError(`Tool "${tool.name}" declares an inputSchema that does not compile: ${result.error}`, AgentSDKErrorType.INVALID_TOOL_SCHEMA);
27
+ }
28
+ }
29
+ }
30
+ //# sourceMappingURL=tool-schema-registration.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-sdk",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "description": "Harness-agnostic agentic infrastructure for Salesforce developer experience tooling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -43,13 +43,14 @@
43
43
  "LICENSE.txt"
44
44
  ],
45
45
  "dependencies": {
46
- "@salesforce/agentic-common": "0.19.0"
46
+ "@salesforce/agentic-common": "0.19.0",
47
+ "ajv": "^8.20.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@eslint/js": "^10.0.1",
50
- "@salesforce/sfdx-agent-harness-claude": "0.68.0",
51
- "@salesforce/sfdx-agent-harness-mastra": "0.71.0",
52
- "@salesforce/sfdx-agent-harness-openai": "0.37.0",
51
+ "@salesforce/sfdx-agent-harness-claude": "0.69.0",
52
+ "@salesforce/sfdx-agent-harness-mastra": "0.72.0",
53
+ "@salesforce/sfdx-agent-harness-openai": "0.38.0",
53
54
  "@types/node": "^22.20.1",
54
55
  "@vitest/coverage-istanbul": "^4.1.11",
55
56
  "@vitest/eslint-plugin": "^1.6.27",