@salesforce/sfdx-agent-harness-openai 0.0.1 → 0.2.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,21 +3,48 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-openai` are documented in this file. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.2.0] - 2026-07-27
7
+
8
+ ### Features
9
+ - **chat-generations**: GovCloud chat/generations fallback @W-23560954@ ([#695](https://github.com/forcedotcom/agentic-dx/pull/695))
10
+ - **harness-openai**: multimodal image/file input support @W-23546665 ([#702](https://github.com/forcedotcom/agentic-dx/pull/702))
11
+
12
+ ## [0.1.0] - 2026-07-27
13
+
14
+ ### Features
15
+
16
+ - **harness-openai**: skills bridge (AgentConfig.skills + multi-file skills) @W-23546666
17
+ ([#694](https://github.com/forcedotcom/agentic-dx/pull/694))
18
+ - **harness-openai**: rules loader (AgentConfig.rules support) @W-23546667@
19
+ ([#689](https://github.com/forcedotcom/agentic-dx/pull/689))
20
+ - **harness-openai**: MCP-tool approval gating @W-23546664@ ([#688](https://github.com/forcedotcom/agentic-dx/pull/688))
21
+
6
22
  ## [0.0.1] - 2026-07-22
7
23
 
8
24
  ### Features
9
- - **harness-openai**: full conformance gate + e2e closeout @W-23447579@ ([#684](https://github.com/forcedotcom/agentic-dx/pull/684))
10
- - **harness-openai**: compaction, telemetry, hooks @W-23447576@ ([#683](https://github.com/forcedotcom/agentic-dx/pull/683))
11
- - **harness-openai**: MCP lifecycle + updateAgent preservation @W-23447574@ ([#678](https://github.com/forcedotcom/agentic-dx/pull/678))
12
- - **harness-openai**: approval + consumer tools @W-23447572@ ([#677](https://github.com/forcedotcom/agentic-dx/pull/677))
13
- - **harness-openai**: sessions, tools, history + isError sidecar @W-23447571@ ([#676](https://github.com/forcedotcom/agentic-dx/pull/676))
14
- - **harness-openai**: scaffold OpenAI Agents SDK harness with connectivity + streamed turn @W-23447570@ ([#675](https://github.com/forcedotcom/agentic-dx/pull/675))
25
+
26
+ - **harness-openai**: full conformance gate + e2e closeout @W-23447579@
27
+ ([#684](https://github.com/forcedotcom/agentic-dx/pull/684))
28
+ - **harness-openai**: compaction, telemetry, hooks @W-23447576@
29
+ ([#683](https://github.com/forcedotcom/agentic-dx/pull/683))
30
+ - **harness-openai**: MCP lifecycle + updateAgent preservation @W-23447574@
31
+ ([#678](https://github.com/forcedotcom/agentic-dx/pull/678))
32
+ - **harness-openai**: approval + consumer tools @W-23447572@
33
+ ([#677](https://github.com/forcedotcom/agentic-dx/pull/677))
34
+ - **harness-openai**: sessions, tools, history + isError sidecar @W-23447571@
35
+ ([#676](https://github.com/forcedotcom/agentic-dx/pull/676))
36
+ - **harness-openai**: scaffold OpenAI Agents SDK harness with connectivity + streamed turn @W-23447570@
37
+ ([#675](https://github.com/forcedotcom/agentic-dx/pull/675))
15
38
 
16
39
  ### Fixes
17
- - **ci**: bump openai harness off seed version so it can publish @W-23447579@ ([#686](https://github.com/forcedotcom/agentic-dx/pull/686))
40
+
41
+ - **ci**: bump openai harness off seed version so it can publish @W-23447579@
42
+ ([#686](https://github.com/forcedotcom/agentic-dx/pull/686))
18
43
 
19
44
  ### Chores
20
- - **deps-dev**: bump eslint from 10.6.0 to 10.7.0 in the eslint group ([#680](https://github.com/forcedotcom/agentic-dx/pull/680))
45
+
46
+ - **deps-dev**: bump eslint from 10.6.0 to 10.7.0 in the eslint group
47
+ ([#680](https://github.com/forcedotcom/agentic-dx/pull/680))
21
48
 
22
49
  ## [Unreleased]
23
50
 
package/README.md CHANGED
@@ -10,10 +10,21 @@ OpenAI Agents SDK-backed `AgentHarness` implementation for [`@salesforce/sfdx-ag
10
10
  > lifecycle + `updateAgent` preservation (#541), E's compaction / telemetry / hooks, and F's full conformance-gate + e2e
11
11
  > closeout. Every `AgentHarness` method has a working implementation, the shared cross-harness conformance gate is
12
12
  > green, and the harness passes the SDK's `describe.each` e2e matrix under `--harness openai` against the real gateway.
13
- > **Known deferred gaps** (each excluded from the e2e matrix with a documented rationale, not a silent skip): MCP-tool
14
- > **approval gating** (the `@openai/agents` native `Agent({ mcpServers })` attach exposes no per-tool `needsApproval`
15
- > hook), **multimodal** input (the harness is text-only), `AgentConfig.rules` / `skills` composition, and MCP
16
- > `tool-progress`. Consumer-tool approval + `submitToolResult`, and tool-result redaction, ARE supported.
13
+ > **MCP-tool approval gating IS supported** (added post-F): a `require-approval` / `deny` policy on an MCP tool surfaces
14
+ > a `tool-approval-request` and honors the parked-approval lifecycle. When a policy gates MCP tools (or a redaction hook
15
+ > is set), the harness materializes MCP tools via `getAllMcpTools(...)` and stamps a `needsApproval` predicate on each,
16
+ > so the run loop raises the interruption the coordinator already routes. Consumer-tool approval + `submitToolResult`,
17
+ > and tool-result redaction, are likewise supported. **`AgentConfig.rules` composition IS supported** (added post-F,
18
+ > W-23546667): `createAgent` / `updateAgent` load each rule file/directory, strip YAML frontmatter, and compose the
19
+ > bodies onto the effective system prompt — so the harness rejoins the dual-harness rules e2e. **`AgentConfig.skills`
20
+ > composition IS supported** (added post-F, W-23546666): `createAgent` / `updateAgent` load `config.skills` into a skill
21
+ > catalog exposed to the model as `load_skill` / `read_skill_file` function tools (the latter serving a skill's sibling
22
+ > files), so the harness rejoins the dual-harness skills e2e including multi-file skill reachability. **Multimodal
23
+ > image/file input IS supported** (added post-F, W-23546665): `chat()` / `stream()` and `addContext` accept `image` /
24
+ > `file` `MessagePart`s and round-trip them to the model, so the harness rejoined the multimodal e2e matrix. **Known
25
+ > deferred gap** (excluded from the e2e matrix with a documented rationale, not a silent skip): MCP **`tool-progress`**
26
+ > — blocked UPSTREAM (`@openai/agents` 0.13.x exposes no seam to reach MCP `notifications/progress`) and tracked as
27
+ > W-23558557, not a harness gap.
17
28
 
18
29
  ## What it is
19
30
 
@@ -179,19 +179,20 @@ export declare class OpenAIAgentsHarness implements OpenAIAgentsAgentHarness {
179
179
  * request) with tracing off. `tools` carries the turn's consumer-executed
180
180
  * tools (mapped from `config.tools`).
181
181
  *
182
- * **MCP attach, two paths.** Without a redaction hook (`redaction`
183
- * undefined the common case), the agent's live MCP server instances attach
184
- * via the native `mcpServers` field; the SDK fetches their tools per run.
185
- * Because each instance has `cacheToolsList: true` (cache keyed by
182
+ * **MCP attach, two paths.** With neither a redaction hook nor an active
183
+ * approval gate (the common case), the agent's live MCP server instances
184
+ * attach via the native `mcpServers` field; the SDK fetches their tools per
185
+ * run. Because each instance has `cacheToolsList: true` (cache keyed by
186
186
  * `server.name`) and this harness reuses the SAME connected instances across
187
187
  * turns, a rebuilt `Agent` here does not re-run `tools/list` for a preserved
188
- * server — the #541 preservation mechanic. MCP tools are NOT approval-gated
189
- * this milestone (the native attach exposes no per-tool `needsApproval`).
188
+ * server — the #541 preservation mechanic.
190
189
  *
191
- * **When a redaction hook is set,** the native attach exposes no per-result
192
- * rewrite seam, so MCP tools are materialized via `getAllMcpTools(...)` and
193
- * their `invoke` wrapped with the redactor (see {@link buildRedactedMcpTools});
194
- * they attach as `tools` rather than `mcpServers`. #541 is preserved because
190
+ * **When a redaction hook OR an approval gate is set,** the native attach
191
+ * exposes neither a per-result rewrite seam nor a per-tool `needsApproval`
192
+ * seam, so MCP tools are materialized via `getAllMcpTools(...)` and rebuilt
193
+ * with the active hooks (see {@link buildManagedMcpTools}): a redaction shim
194
+ * on `invoke` and/or a `needsApproval` predicate driven by the policy. They
195
+ * attach as `tools` rather than `mcpServers`. #541 is preserved because
195
196
  * `getAllMcpTools` reads each server's warm `cacheToolsList` cache with no
196
197
  * network `tools/list`. This makes the method async (the materialize awaits
197
198
  * `listTools()`), so `stream()` awaits it.
@@ -274,7 +275,16 @@ export declare class OpenAIAgentsHarness implements OpenAIAgentsAgentHarness {
274
275
  private diffMcpServers;
275
276
  /** Close every MCP server on an agent, awaiting in-flight discovery first. */
276
277
  private closeMcpServers;
277
- /** Rebuild the catalog entries for one server from its freshly-discovered tools. */
278
+ /**
279
+ * Rebuild the catalog entries for one server from its freshly-discovered
280
+ * tools. Keys each entry by the tool's **sanitized display name** (what
281
+ * `@openai/agents` registers on materialized tools and run-item events — see
282
+ * {@link toFunctionToolName}), while preserving the original bare MCP name as
283
+ * `toolName`. Keying by the raw name would miss enrichment for any tool whose
284
+ * name carries a character the SDK sanitizes to `_` (e.g. `get-sum` →
285
+ * `get_sum`), so neither the event adapter nor the approval gate would see
286
+ * its `serverName`.
287
+ */
278
288
  private refreshCatalogForServer;
279
289
  /** Drop every catalog entry that points at `serverName`. */
280
290
  private removeCatalogForServer;
@@ -4,17 +4,20 @@
4
4
  */
5
5
  import { backfillCreatedAt, buildSummaryPrompt, getErrorMessage, isAbortError, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
6
6
  import { Agent, Runner } from '@openai/agents';
7
- import { AgentSDKError, AgentSDKErrorType, mcpServerConfigEqual, McpServerStatus, resolveToolApprovalPolicy, } from '@salesforce/sfdx-agent-sdk';
8
- import { HarnessBusOwner, SUPPORTED_PROTOCOL_VERSIONS } from '@salesforce/sfdx-agent-sdk/harness';
7
+ import { AgentSDKError, AgentSDKErrorType, mcpServerConfigEqual, McpServerStatus, resolveToolApprovalPolicy, validateMultimodalFiles, } from '@salesforce/sfdx-agent-sdk';
8
+ import { HarnessBusOwner, lowerStreamInput, SUPPORTED_PROTOCOL_VERSIONS } from '@salesforce/sfdx-agent-sdk/harness';
9
9
  import { OpenAIApprovalCoordinator } from './openai-approval-coordinator.js';
10
10
  import { OPENAI_BUILT_IN_TOOL_POLICIES } from './openai-built-in-policies.js';
11
11
  import { buildMcpErrorDetail, sanitizeMcpErrorMessage } from './mcp-error-classifier.js';
12
12
  import { hasEnabledServers, mapToOpenAIMcpServers } from './openai-mcp-config-mapper.js';
13
- import { MAX_TRANSCRIPT_CHARS, messagesToRecords, partToTranscriptText, recordsToMessages, splitIntoChunks, } from './openai-message-mapper.js';
13
+ import { lowerInputPartToContent, MAX_TRANSCRIPT_CHARS, messagesToRecords, partToTranscriptText, recordsToMessages, splitIntoChunks, } from './openai-message-mapper.js';
14
14
  import { buildOpenAIModelProvider } from './openai-model-provider.js';
15
+ import { composeRulesAppend, composeSystemPrompt } from './rule-composer.js';
15
16
  import { OpenAISessionStore } from './openai-session-store.js';
17
+ import { buildSkillMap } from './skill-loader.js';
18
+ import { buildSkillTools } from './openai-skill-tools.js';
16
19
  import { ConsumerToolRegistry, mapConsumerTools } from './openai-tool-mapper.js';
17
- import { buildRedactedMcpTools } from './openai-tool-redaction.js';
20
+ import { buildManagedMcpTools } from './openai-tool-redaction.js';
18
21
  /**
19
22
  * OpenAI Agents SDK-backed implementation of the SDK's `AgentHarness` contract.
20
23
  *
@@ -119,11 +122,21 @@ export class OpenAIAgentsHarness {
119
122
  if (this.agents.has(agentId)) {
120
123
  throw new Error(`Agent with id "${agentId}" is already registered`);
121
124
  }
125
+ options?.abortSignal?.throwIfAborted();
126
+ // Compose `config.rules` and load `config.skills` BEFORE registering
127
+ // state or starting MCP servers, so a bad/unreadable rule or skill path
128
+ // rejects `createAgent` before any MCP client is constructed (matching
129
+ // Mastra's Phase-0 posture — a non-MCP input failure can't leave servers
130
+ // half-cycled).
131
+ const rulesAppend = await composeRulesAppend(config?.rules);
132
+ const skillMap = await buildSkillMap(config?.skills, projectRoot, this.buses.getLogBus());
122
133
  const state = {
123
134
  projectRoot,
124
135
  config: config ?? {},
125
136
  modelConnectivityInfo,
126
137
  hooks: options?.hooks ?? {},
138
+ rulesAppend,
139
+ skillMap,
127
140
  // The provider reads the live bag on every `getModel()` so a
128
141
  // within-shape modelId swap / JWT rotation lands without a rebuild.
129
142
  modelProvider: this.modelProviderFactory(() => this.getAgentOrThrow(agentId).modelConnectivityInfo),
@@ -218,7 +231,29 @@ export class OpenAIAgentsHarness {
218
231
  // fresh id must still persist under that thread rather than silently
219
232
  // dropping history.
220
233
  state.threads.add(threadId);
221
- const input = lowerMessageToText(message);
234
+ // Debug-emit the composed system prompt (config.instructions, with any
235
+ // rule bodies folded in by composeSystemPrompt) so consumers — notably
236
+ // the dual-harness rules e2e (#493) — can deterministically assert that
237
+ // `AgentConfig.rules` / `AgentConfig.instructions` reached the underlying
238
+ // runtime, without model-output marker assertions. The Mastra and Claude
239
+ // harnesses emit the equivalent `assembled systemPrompt` record from
240
+ // their own `stream()`, so the e2e captures the same record shape on
241
+ // every harness. Skip when the assembled body is empty (no instructions,
242
+ // no rules) so the emit guard matches the sibling harnesses' "skip when
243
+ // no system prompt is configured" posture.
244
+ const composedSystemPrompt = composeSystemPrompt(state.config.instructions, state.rulesAppend);
245
+ if (composedSystemPrompt.length > 0) {
246
+ this.buses.getLogBus()?.debug('openai harness assembled systemPrompt', {
247
+ agentId,
248
+ threadId,
249
+ systemPrompt: composedSystemPrompt,
250
+ });
251
+ }
252
+ // Lower the user message to the run-loop input. A plain string passes
253
+ // through; a `MessagePart[]` runs the shared `lowerStreamInput` (file-cap
254
+ // validation against the live model's `supportedFormats` + the input-part
255
+ // guard) and lowers text/image/file parts to one multimodal user item.
256
+ const input = lowerMessage(message, state.modelConnectivityInfo.model);
222
257
  // The disk-backed Session the runner reads history from and appends new
223
258
  // items to. Passing it to `run(..., { session })` is what makes the turn
224
259
  // multi-turn-aware and persistent (the runner owns the write).
@@ -227,13 +262,10 @@ export class OpenAIAgentsHarness {
227
262
  // interruption, or `undefined` when gating is off for this turn (the
228
263
  // SDK's "no policy ⇒ no gating" back-compat path). Frozen at stream start;
229
264
  // the closure reads `state.config` live per call so a mid-turn
230
- // `updateAgentConfig` ("Allow always") is honored. Note: no tool the
231
- // harness registers today carries a `needsApproval` predicate MCP tools
232
- // attach via the SDK's native path, which exposes no per-tool
233
- // `needsApproval` hook, so they are NOT approval-gated this milestone
234
- // (gating is a deferred follow-up). This decider is therefore dormant in
235
- // production, though the coordinator plumbing it drives is exercised by
236
- // the coordinator unit tests.
265
+ // `updateAgentConfig` ("Allow always") is honored. When active, the same
266
+ // decider also drives the `needsApproval` predicate on materialized MCP
267
+ // tools (below), so a `require-approval` / `deny` MCP tool raises the
268
+ // interruption this decider then routes.
237
269
  const policy = this.buildPolicyGate(state);
238
270
  // The tool-result redaction context, or `undefined` when the agent has no
239
271
  // `onToolResult` hook (both redaction seams then take their zero-cost
@@ -253,7 +285,14 @@ export class OpenAIAgentsHarness {
253
285
  ? new ConsumerToolRegistry(new Set(consumerToolDefs.map((t) => t.name)))
254
286
  : undefined;
255
287
  const tools = consumerTools ? mapConsumerTools(consumerToolDefs, consumerTools, redaction) : [];
256
- const { agent, runner } = await this.buildAgent(agentId, state, tools, redaction);
288
+ // MCP-tool approval gate: when a policy is active, each materialized MCP
289
+ // tool gets a `needsApproval` predicate resolving this same decider over
290
+ // the enriched invocation (bare name → `{ serverName, annotations }` via
291
+ // the live catalog). `undefined` when gating is off, so MCP tools carry
292
+ // no predicate and never suspend. Materialization is forced whenever this
293
+ // OR `redaction` is set — the native attach exposes neither hook.
294
+ const mcpApproval = policy !== undefined ? { policy, mcpCatalog: state.mcpCatalog } : undefined;
295
+ const { agent, runner } = await this.buildAgent(agentId, state, tools, redaction, mcpApproval);
257
296
  // One coordinator owns this turn's run stream + sink + teardown. Dispose
258
297
  // any prior coordinator on the same thread first so a re-`stream()`
259
298
  // mid-turn can't leave a stale pump running against the old sink.
@@ -265,25 +304,20 @@ export class OpenAIAgentsHarness {
265
304
  session,
266
305
  policy,
267
306
  consumerTools,
268
- onConsumerToolErrors: (toolCallIds) => {
307
+ onToolResultErrors: (toolCallIds) => {
269
308
  // Stamp `isError` onto the persisted `function_call_result`
270
- // records for consumer tools the consumer settled with an error
271
- // the run loop wrote them without it (the OpenAI item shape has no
272
- // `isError` field). Best-effort: a persistence failure here must
273
- // not fail the settled turn, and the store's per-thread queue
274
- // orders this after the run's writes.
275
- void this.sessions.markToolResultError(agentId, threadId, toolCallIds).catch((error) => this.buses.getLogBus()?.warn('Failed to persist consumer-tool isError', {
309
+ // records the run loop wrote without it (the OpenAI item shape
310
+ // has no `isError` field): consumer tools the consumer settled
311
+ // with an error, plus MCP tools the coordinator rejected (policy
312
+ // deny / user decline). Best-effort: a persistence failure here
313
+ // must not fail the settled turn, and the store's per-thread
314
+ // queue orders this after the run's writes.
315
+ void this.sessions.markToolResultError(agentId, threadId, toolCallIds).catch((error) => this.buses.getLogBus()?.warn('Failed to persist tool-result isError', {
276
316
  agentId,
277
317
  threadId,
278
318
  error: getErrorMessage(error),
279
319
  }));
280
320
  },
281
- onSettled: () => {
282
- // The coordinator removes its own entry when the turn settles —
283
- // guard against a newer turn having already replaced it.
284
- if (state.coordinators.get(threadId) === coordinator)
285
- state.coordinators.delete(threadId);
286
- },
287
321
  externalSignal: abortSignal,
288
322
  // Live catalog reference so a mid-turn discovery settle enriches
289
323
  // later MCP tool events; empty when the agent has no MCP servers.
@@ -291,6 +325,10 @@ export class OpenAIAgentsHarness {
291
325
  // Factory-configured per-`toolCallId` approval timeout; the
292
326
  // coordinator falls back to its own default when undefined.
293
327
  toolApprovalTimeoutMs: this.toolApprovalTimeoutMs,
328
+ // Emission shape for a parallel `tool_use` batch's approvals: batch
329
+ // (all requests up front) vs serial (one at a time). Defaults to
330
+ // serial when unset, mirroring the sibling harnesses.
331
+ batchApprovals: options?.batchApprovals,
294
332
  // The turn's step limit → `run(..., { maxTurns })`. Undefined leaves
295
333
  // the SDK default in place; a hit surfaces as `finish('max-steps')`.
296
334
  maxTurns: options?.maxSteps,
@@ -344,12 +382,16 @@ export class OpenAIAgentsHarness {
344
382
  async updateAgent(agentId, modelConnectivityInfo, config, options) {
345
383
  const state = this.getAgentOrThrow(agentId);
346
384
  options?.abortSignal?.throwIfAborted();
347
- // Phase 0 — validate non-MCP inputs BEFORE touching any MCP client, so a
348
- // bad input can't leave servers half-cycled. This harness has no
349
- // rules/skills/workspace to load (unlike Mastra), and `instructions` is a
350
- // plain string, so there is nothing to validate today the phase is a
351
- // deliberate no-op. Kept as an explicit ordering anchor so a future
352
- // non-MCP validation lands here, before Phase 1.
385
+ // Phase 0 — load non-MCP inputs BEFORE touching any MCP client, so a bad
386
+ // input can't leave servers half-cycled. Re-compose `config.rules` and
387
+ // re-load `config.skills`: a bad/unreadable rule or skill path rejects
388
+ // here, before the Phase 1 MCP diff cycles any transport (mirrors
389
+ // Mastra's Phase 0). `updateAgent` is atomic a bad skill path in the
390
+ // new config rejects the whole update rather than applying the MCP delta
391
+ // and silently dropping the skill delta. Neither result is stamped onto
392
+ // state until Phase 2, so a rejection leaves the live agent untouched.
393
+ const rulesAppend = await composeRulesAppend(config?.rules);
394
+ const skillMap = await buildSkillMap(config?.skills, state.projectRoot, this.buses.getLogBus());
353
395
  // Phase 1 — diff the next MCP config against the applied one, preserving
354
396
  // structurally-equal servers and cycling the rest. Per-server Map
355
397
  // mutations are synchronous so a re-entrant rollback `updateAgent`
@@ -358,11 +400,16 @@ export class OpenAIAgentsHarness {
358
400
  this.diffMcpServers(agentId, state, next, config?.orgJwt, options?.abortSignal);
359
401
  // Phase 2 — apply non-MCP changes. The model provider closure and
360
402
  // `buildAgent` both read these live, so no provider/agent rebuild is
361
- // required; `hooks` is re-stamped for the next turn's tool-result seam.
403
+ // required; `hooks` is re-stamped for the next turn's tool-result seam,
404
+ // the freshly-composed `rulesAppend` is folded onto the next turn's
405
+ // instructions, and the reloaded `skillMap` backs the next turn's skill
406
+ // tools.
362
407
  state.config = config ?? {};
363
408
  state.mcpConfig = config?.mcpServers;
364
409
  state.modelConnectivityInfo = modelConnectivityInfo;
365
410
  state.hooks = options?.hooks ?? {};
411
+ state.rulesAppend = rulesAppend;
412
+ state.skillMap = skillMap;
366
413
  }
367
414
  // ── MCP Introspection / Reconnect ────────────────────────────────────
368
415
  /**
@@ -558,47 +605,57 @@ export class OpenAIAgentsHarness {
558
605
  * request) with tracing off. `tools` carries the turn's consumer-executed
559
606
  * tools (mapped from `config.tools`).
560
607
  *
561
- * **MCP attach, two paths.** Without a redaction hook (`redaction`
562
- * undefined the common case), the agent's live MCP server instances attach
563
- * via the native `mcpServers` field; the SDK fetches their tools per run.
564
- * Because each instance has `cacheToolsList: true` (cache keyed by
608
+ * **MCP attach, two paths.** With neither a redaction hook nor an active
609
+ * approval gate (the common case), the agent's live MCP server instances
610
+ * attach via the native `mcpServers` field; the SDK fetches their tools per
611
+ * run. Because each instance has `cacheToolsList: true` (cache keyed by
565
612
  * `server.name`) and this harness reuses the SAME connected instances across
566
613
  * turns, a rebuilt `Agent` here does not re-run `tools/list` for a preserved
567
- * server — the #541 preservation mechanic. MCP tools are NOT approval-gated
568
- * this milestone (the native attach exposes no per-tool `needsApproval`).
614
+ * server — the #541 preservation mechanic.
569
615
  *
570
- * **When a redaction hook is set,** the native attach exposes no per-result
571
- * rewrite seam, so MCP tools are materialized via `getAllMcpTools(...)` and
572
- * their `invoke` wrapped with the redactor (see {@link buildRedactedMcpTools});
573
- * they attach as `tools` rather than `mcpServers`. #541 is preserved because
616
+ * **When a redaction hook OR an approval gate is set,** the native attach
617
+ * exposes neither a per-result rewrite seam nor a per-tool `needsApproval`
618
+ * seam, so MCP tools are materialized via `getAllMcpTools(...)` and rebuilt
619
+ * with the active hooks (see {@link buildManagedMcpTools}): a redaction shim
620
+ * on `invoke` and/or a `needsApproval` predicate driven by the policy. They
621
+ * attach as `tools` rather than `mcpServers`. #541 is preserved because
574
622
  * `getAllMcpTools` reads each server's warm `cacheToolsList` cache with no
575
623
  * network `tools/list`. This makes the method async (the materialize awaits
576
624
  * `listTools()`), so `stream()` awaits it.
577
625
  */
578
- async buildAgent(agentId, state, tools = [], redaction) {
626
+ async buildAgent(agentId, state, tools = [], redaction, mcpApproval) {
579
627
  const mcpServerInstances = [...state.mcpServers.values()].map((s) => s.server);
580
- // Redaction path: materialize + wrap MCP tools so their result passes
581
- // through the redactor (the native `mcpServers` attach has no such seam).
582
- // Only CONNECTED servers are materialized: their `cacheToolsList` cache is
583
- // warm (discovery succeeded), so `getAllMcpTools` reads it with no network
584
- // `tools/list` and cannot throw where an errored / still-connecting
585
- // server would make `getAllMcpTools` (which fails fast on the first
586
- // server's throw) reject the whole turn PRE-stream. Skipping them keeps a
587
- // failed MCP server from failing the turn (the WI-D contract) and matches
588
- // the Claude harness's "only connected servers surface" posture; an
589
- // errored server contributes no usable tools on the native path either.
628
+ // Materialize + manage MCP tools when EITHER a redaction hook or an
629
+ // approval gate is active — the native `mcpServers` attach exposes
630
+ // neither the per-result rewrite seam nor the per-tool `needsApproval`
631
+ // seam. Only CONNECTED servers are materialized: their `cacheToolsList`
632
+ // cache is warm (discovery succeeded), so `getAllMcpTools` reads it with
633
+ // no network `tools/list` and cannot throw where an errored /
634
+ // still-connecting server would make `getAllMcpTools` (which fails fast
635
+ // on the first server's throw) reject the whole turn PRE-stream. Skipping
636
+ // them keeps a failed MCP server from failing the turn (the WI-D
637
+ // contract) and matches the Claude harness's "only connected servers
638
+ // surface" posture; an errored server contributes no usable tools on the
639
+ // native path either.
640
+ const manageMcp = redaction !== undefined || mcpApproval !== undefined;
590
641
  const connectedMcpServers = [...state.mcpServers.values()]
591
642
  .filter((s) => s.status === McpServerStatus.Connected)
592
643
  .map((s) => s.server);
593
- const redactedMcpTools = redaction !== undefined && connectedMcpServers.length > 0
594
- ? await buildRedactedMcpTools(connectedMcpServers, redaction)
644
+ const managedMcpTools = manageMcp && connectedMcpServers.length > 0
645
+ ? await buildManagedMcpTools(connectedMcpServers, { redaction, approval: mcpApproval })
595
646
  : [];
596
- const allTools = [...tools, ...redactedMcpTools];
597
- // Attach MCP natively only when we did NOT materialize them for redaction.
598
- const attachMcpServers = redaction === undefined && mcpServerInstances.length > 0;
647
+ // Skill tools (`load_skill` / `read_skill_file`) surface the agent's
648
+ // configured skills to the model. Built fresh per turn from the live
649
+ // `state.skillMap` never cached so an `updateAgent` that changes
650
+ // `config.skills` lands on the next turn. Empty when the agent has no
651
+ // skills, so a skill-less agent's tool surface is unchanged.
652
+ const skillTools = buildSkillTools(state.skillMap);
653
+ const allTools = [...tools, ...skillTools, ...managedMcpTools];
654
+ // Attach MCP natively only when we did NOT materialize them for a hook.
655
+ const attachMcpServers = !manageMcp && mcpServerInstances.length > 0;
599
656
  const agent = new Agent({
600
657
  name: agentId,
601
- instructions: state.config.instructions ?? '',
658
+ instructions: composeSystemPrompt(state.config.instructions, state.rulesAppend),
602
659
  model: state.modelConnectivityInfo.nativeModelId,
603
660
  ...(allTools.length > 0 ? { tools: allTools } : {}),
604
661
  ...(attachMcpServers ? { mcpServers: mcpServerInstances } : {}),
@@ -888,19 +945,32 @@ export class OpenAIAgentsHarness {
888
945
  state.mcpServers.clear();
889
946
  state.mcpCatalog.clear();
890
947
  }
891
- /** Rebuild the catalog entries for one server from its freshly-discovered tools. */
948
+ /**
949
+ * Rebuild the catalog entries for one server from its freshly-discovered
950
+ * tools. Keys each entry by the tool's **sanitized display name** (what
951
+ * `@openai/agents` registers on materialized tools and run-item events — see
952
+ * {@link toFunctionToolName}), while preserving the original bare MCP name as
953
+ * `toolName`. Keying by the raw name would miss enrichment for any tool whose
954
+ * name carries a character the SDK sanitizes to `_` (e.g. `get-sum` →
955
+ * `get_sum`), so neither the event adapter nor the approval gate would see
956
+ * its `serverName`.
957
+ */
892
958
  refreshCatalogForServer(state, serverName, tools) {
893
959
  this.removeCatalogForServer(state, serverName);
894
960
  for (const tool of tools) {
895
961
  const annotations = tool.annotations;
896
- state.mcpCatalog.set(tool.name, { serverName, ...(annotations !== undefined ? { annotations } : {}) });
962
+ state.mcpCatalog.set(toFunctionToolName(tool.name), {
963
+ serverName,
964
+ toolName: tool.name,
965
+ ...(annotations !== undefined ? { annotations } : {}),
966
+ });
897
967
  }
898
968
  }
899
969
  /** Drop every catalog entry that points at `serverName`. */
900
970
  removeCatalogForServer(state, serverName) {
901
- for (const [bareToolName, entry] of state.mcpCatalog) {
971
+ for (const [displayName, entry] of state.mcpCatalog) {
902
972
  if (entry.serverName === serverName)
903
- state.mcpCatalog.delete(bareToolName);
973
+ state.mcpCatalog.delete(displayName);
904
974
  }
905
975
  }
906
976
  /**
@@ -993,22 +1063,38 @@ function isJsonObject(value) {
993
1063
  return typeof value === 'object' && value !== null && !Array.isArray(value);
994
1064
  }
995
1065
  /**
996
- * Lowers a `stream()` message to the plain-text `input` the OpenAI Agents run
997
- * loop takes. Only `text` parts are supported; multimodal input is not yet
998
- * implemented.
1066
+ * Sanitize an MCP tool name to the function-tool identifier `@openai/agents`
1067
+ * registers it under. A byte-identical copy of the SDK's internal
1068
+ * `toFunctionToolName` (`@openai/agents-core` `utils/tools.ts`), which is not
1069
+ * exported: whitespace and every non-alphanumeric character become `_`. The
1070
+ * SDK applies this to both the materialized function tool's `name` and the
1071
+ * `tool_called` / `tool_output` run-item names, so the harness must key its MCP
1072
+ * catalog by the same transformed name for enrichment + approval lookups to hit
1073
+ * (e.g. a `tools/list` name `get-sum` surfaces as `get_sum`). Kept local rather
1074
+ * than reached through a deep import per the harness-isolation rule; the
1075
+ * transform is a stable identifier-safety rule unlikely to drift.
999
1076
  */
1000
- function lowerMessageToText(message) {
1077
+ function toFunctionToolName(name) {
1078
+ return name.replace(/\s/g, '_').replace(/[^a-zA-Z0-9]/g, '_');
1079
+ }
1080
+ /**
1081
+ * Lowers a `stream()` message to the `input` the OpenAI Agents run loop takes.
1082
+ *
1083
+ * A plain `string` passes through unchanged (the common text turn). A
1084
+ * `MessagePart[]` runs the shared `lowerStreamInput`, which validates any
1085
+ * `image` / `file` parts against the model's `supportedFormats` + the global
1086
+ * gateway caps (throwing `AgentSDKError(MULTIMODAL_NOT_SUPPORTED)` pre-stream on a
1087
+ * violation) and rejects non-input parts (`reasoning` / `tool-call` /
1088
+ * `tool-result`) with `INVALID_MESSAGE_CONTENT`, then lowers each valid part to an
1089
+ * `@openai/agents` `UserContent` block. The blocks are wrapped in one `user`
1090
+ * message item, so a mixed text-plus-attachment turn reaches the model as a single
1091
+ * multimodal user message. See {@link lowerInputPartToContent} for the wire shape
1092
+ * (notably the mandatory `data:` URL for images).
1093
+ */
1094
+ function lowerMessage(message, model) {
1001
1095
  if (typeof message === 'string')
1002
1096
  return message;
1003
- const texts = [];
1004
- for (const part of message) {
1005
- if (part.type === 'text') {
1006
- texts.push(part.text);
1007
- }
1008
- else {
1009
- throw new AgentSDKError(`The OpenAI harness does not yet support "${part.type}" message parts (text only).`, AgentSDKErrorType.INVALID_MESSAGE_CONTENT);
1010
- }
1011
- }
1012
- return texts.join('\n');
1097
+ const content = lowerStreamInput(message, (files) => validateMultimodalFiles(files, model), lowerInputPartToContent);
1098
+ return [{ role: 'user', content }];
1013
1099
  }
1014
1100
  //# sourceMappingURL=openai-agents-harness.js.map