@salesforce/sfdx-agent-harness-openai 0.0.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE.txt +21 -0
  3. package/README.md +55 -0
  4. package/dist/gen-sink.d.ts +8 -0
  5. package/dist/gen-sink.js +13 -0
  6. package/dist/gen-sink.js.map +1 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.js +16 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/mcp-error-classifier.d.ts +36 -0
  11. package/dist/mcp-error-classifier.js +166 -0
  12. package/dist/mcp-error-classifier.js.map +1 -0
  13. package/dist/openai-agents-harness-factory.d.ts +36 -0
  14. package/dist/openai-agents-harness-factory.js +39 -0
  15. package/dist/openai-agents-harness-factory.js.map +1 -0
  16. package/dist/openai-agents-harness.d.ts +302 -0
  17. package/dist/openai-agents-harness.js +1014 -0
  18. package/dist/openai-agents-harness.js.map +1 -0
  19. package/dist/openai-approval-coordinator.d.ts +231 -0
  20. package/dist/openai-approval-coordinator.js +422 -0
  21. package/dist/openai-approval-coordinator.js.map +1 -0
  22. package/dist/openai-built-in-policies.d.ts +29 -0
  23. package/dist/openai-built-in-policies.js +33 -0
  24. package/dist/openai-built-in-policies.js.map +1 -0
  25. package/dist/openai-event-adapter.d.ts +119 -0
  26. package/dist/openai-event-adapter.js +322 -0
  27. package/dist/openai-event-adapter.js.map +1 -0
  28. package/dist/openai-mcp-config-mapper.d.ts +58 -0
  29. package/dist/openai-mcp-config-mapper.js +133 -0
  30. package/dist/openai-mcp-config-mapper.js.map +1 -0
  31. package/dist/openai-mcp-state.d.ts +67 -0
  32. package/dist/openai-mcp-state.js +6 -0
  33. package/dist/openai-mcp-state.js.map +1 -0
  34. package/dist/openai-message-mapper.d.ts +79 -0
  35. package/dist/openai-message-mapper.js +374 -0
  36. package/dist/openai-message-mapper.js.map +1 -0
  37. package/dist/openai-model-provider.d.ts +46 -0
  38. package/dist/openai-model-provider.js +144 -0
  39. package/dist/openai-model-provider.js.map +1 -0
  40. package/dist/openai-session-store.d.ts +149 -0
  41. package/dist/openai-session-store.js +328 -0
  42. package/dist/openai-session-store.js.map +1 -0
  43. package/dist/openai-tool-mapper.d.ts +121 -0
  44. package/dist/openai-tool-mapper.js +231 -0
  45. package/dist/openai-tool-mapper.js.map +1 -0
  46. package/dist/openai-tool-redaction.d.ts +55 -0
  47. package/dist/openai-tool-redaction.js +82 -0
  48. package/dist/openai-tool-redaction.js.map +1 -0
  49. package/dist/test/tsconfig.tsbuildinfo +1 -0
  50. package/dist/text-stream.d.ts +30 -0
  51. package/dist/text-stream.js +103 -0
  52. package/dist/text-stream.js.map +1 -0
  53. package/package.json +66 -0
@@ -0,0 +1,1014 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { backfillCreatedAt, buildSummaryPrompt, getErrorMessage, isAbortError, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
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';
9
+ import { OpenAIApprovalCoordinator } from './openai-approval-coordinator.js';
10
+ import { OPENAI_BUILT_IN_TOOL_POLICIES } from './openai-built-in-policies.js';
11
+ import { buildMcpErrorDetail, sanitizeMcpErrorMessage } from './mcp-error-classifier.js';
12
+ import { hasEnabledServers, mapToOpenAIMcpServers } from './openai-mcp-config-mapper.js';
13
+ import { MAX_TRANSCRIPT_CHARS, messagesToRecords, partToTranscriptText, recordsToMessages, splitIntoChunks, } from './openai-message-mapper.js';
14
+ import { buildOpenAIModelProvider } from './openai-model-provider.js';
15
+ import { OpenAISessionStore } from './openai-session-store.js';
16
+ import { ConsumerToolRegistry, mapConsumerTools } from './openai-tool-mapper.js';
17
+ import { buildRedactedMcpTools } from './openai-tool-redaction.js';
18
+ /**
19
+ * OpenAI Agents SDK-backed implementation of the SDK's `AgentHarness` contract.
20
+ *
21
+ * Implements connectivity, streamed turns, disk-backed sessions, thread
22
+ * lifecycle, message history (with `createdAt`/`isError` persistence), tool
23
+ * approval (`approveToolCall` / `declineToolCall`), consumer-executed tools
24
+ * (`submitToolResult`), MCP server lifecycle (`getMcpServerInfo` /
25
+ * `reconnectMcpServer` / `updateAgent` preservation), and thread compaction
26
+ * (`compactThread`). A per-`(agentId, threadId)` {@link OpenAIApprovalCoordinator}
27
+ * owns each turn's run loop, the approval suspend/resume flow, the opt-in policy
28
+ * gate, the per-`toolCallId` timeout, and the consumer-tool parker.
29
+ *
30
+ * MCP tools attach via the SDK's native `Agent({ mcpServers })` path and are
31
+ * observable (`tool-call` / `tool-result`, enriched with `serverName` /
32
+ * `bareToolName`) but are NOT approval-gated this milestone — the native attach
33
+ * exposes no per-tool `needsApproval` hook, so gating is a deferred follow-up.
34
+ * MCP discovery/status telemetry and wire-communication events emit on their
35
+ * respective seams, and the `options.hooks.onToolResult` redaction hook fires
36
+ * per tool result (consumer tools at the parked `execute`; MCP tools via a
37
+ * `getAllMcpTools` materialize-and-wrap when a redactor is set). Every
38
+ * agent-scoped method throws `AGENT_NOT_FOUND` on an unregistered id first (SDK
39
+ * invariant).
40
+ */
41
+ export class OpenAIAgentsHarness {
42
+ storageRootFolder;
43
+ harnessId = 'openai-agents';
44
+ protocolVersion = SUPPORTED_PROTOCOL_VERSIONS[0];
45
+ extensions = {};
46
+ buses = new HarnessBusOwner();
47
+ agents = new Map();
48
+ idGenerator;
49
+ clock;
50
+ innerFetch;
51
+ modelProviderFactory;
52
+ mcpServerFactory;
53
+ sessions;
54
+ toolApprovalTimeoutMs;
55
+ shuttingDown = false;
56
+ constructor(storageRootFolder, options = {}) {
57
+ this.storageRootFolder = storageRootFolder;
58
+ this.innerFetch = options.innerFetch;
59
+ this.idGenerator = options.idGenerator ?? new UUIDGenerator();
60
+ this.clock = options.clock ?? new RealClock();
61
+ this.modelProviderFactory =
62
+ options.modelProviderFactory ??
63
+ ((getInfo) => buildOpenAIModelProvider({
64
+ getInfo,
65
+ innerFetch: this.innerFetch,
66
+ // The wire seam emits one request/response pair per outbound
67
+ // HTTP call onto the harness's wire bus. The emitter swallows
68
+ // the typed DISPOSED error a late emit during shutdown would
69
+ // hit, so a trace hook never fails the underlying fetch.
70
+ wireHooks: {
71
+ emitWireCommunication: (event) => {
72
+ try {
73
+ this.buses.emitWireCommunication(event);
74
+ }
75
+ catch (error) {
76
+ if (!isDisposedError(error))
77
+ throw error;
78
+ }
79
+ },
80
+ clock: this.clock,
81
+ },
82
+ }));
83
+ this.mcpServerFactory =
84
+ options.mcpServerFactory ?? ((config, orgJwt) => mapToOpenAIMcpServers(config, orgJwt, this.innerFetch));
85
+ this.sessions = new OpenAISessionStore(this.storageRootFolder, this.buses.getLogBus(), this.clock);
86
+ this.toolApprovalTimeoutMs = options.toolApprovalTimeoutMs;
87
+ }
88
+ // ── Telemetry / Log / Wire Subscription ──────────────────────────────
89
+ onTelemetry(callback) {
90
+ return this.buses.onTelemetry(callback);
91
+ }
92
+ onLog(callback) {
93
+ return this.buses.onLog(callback);
94
+ }
95
+ onWireCommunication(callback) {
96
+ return this.buses.onWireCommunication(callback);
97
+ }
98
+ async shutdown() {
99
+ if (this.shuttingDown)
100
+ return;
101
+ this.shuttingDown = true;
102
+ // Tear down every in-flight turn so a disposed harness leaves no pump
103
+ // running against a torn-down agent, and each consumer stream sees its
104
+ // terminal `error` + `finish('error')` pair.
105
+ for (const state of this.agents.values()) {
106
+ for (const coordinator of state.coordinators.values())
107
+ coordinator.dispose();
108
+ state.coordinators.clear();
109
+ }
110
+ // Close every MCP server so a disposed harness leaves no live transport /
111
+ // subprocess. Await discovery first so close can't race an in-flight
112
+ // connect; all closes settle before the registry is cleared.
113
+ await Promise.allSettled([...this.agents.values()].map((state) => this.closeMcpServers(state)));
114
+ this.agents.clear();
115
+ this.buses.dispose();
116
+ }
117
+ // ── Agent Lifecycle ──────────────────────────────────────────────────
118
+ async createAgent(agentId, projectRoot, modelConnectivityInfo, config, options) {
119
+ if (this.agents.has(agentId)) {
120
+ throw new Error(`Agent with id "${agentId}" is already registered`);
121
+ }
122
+ const state = {
123
+ projectRoot,
124
+ config: config ?? {},
125
+ modelConnectivityInfo,
126
+ hooks: options?.hooks ?? {},
127
+ // The provider reads the live bag on every `getModel()` so a
128
+ // within-shape modelId swap / JWT rotation lands without a rebuild.
129
+ modelProvider: this.modelProviderFactory(() => this.getAgentOrThrow(agentId).modelConnectivityInfo),
130
+ threads: new Set(),
131
+ coordinators: new Map(),
132
+ mcpServers: new Map(),
133
+ mcpConfig: config?.mcpServers,
134
+ mcpCatalog: new Map(),
135
+ };
136
+ this.agents.set(agentId, state);
137
+ // Construct + connect + discover the agent's MCP servers. Non-blocking:
138
+ // each server's discovery runs in the background (its promise parked on
139
+ // `entry.ready`) and a failure is recorded on per-server state rather
140
+ // than failing `createAgent` — the same soft-skip posture the thread
141
+ // rehydrate below uses.
142
+ this.startMcpServers(agentId, state, config?.mcpServers, options?.abortSignal);
143
+ // Rehydrate threads persisted under a prior harness instance so
144
+ // `getThreadIds` + history survive a restart and the SDK's boot-time
145
+ // restore path reattaches every session (mirrors Claude's rehydrate).
146
+ // A discovery failure is non-fatal — an agent with an unreadable
147
+ // sessions dir comes up with no threads rather than failing createAgent,
148
+ // matching the SDK's soft-skip posture on persisted state.
149
+ try {
150
+ for (const threadId of await this.sessions.listThreadIds(agentId)) {
151
+ state.threads.add(threadId);
152
+ }
153
+ }
154
+ catch (error) {
155
+ this.buses.getLogBus()?.warn('Failed to rehydrate OpenAI harness threads', {
156
+ agentId,
157
+ error: getErrorMessage(error),
158
+ });
159
+ }
160
+ }
161
+ async destroyAgent(agentId) {
162
+ const state = this.getAgentOrThrow(agentId);
163
+ // Tear down every in-flight turn on this agent before dropping it.
164
+ for (const coordinator of state.coordinators.values())
165
+ coordinator.dispose();
166
+ state.coordinators.clear();
167
+ // Close every MCP server (await discovery first so close can't race an
168
+ // in-flight connect) so a destroyed agent leaves no live transport.
169
+ await this.closeMcpServers(state);
170
+ // Best-effort delete every persisted thread so a destroyed agent leaves
171
+ // no orphan session files. A delete failure does not block the registry
172
+ // removal — the agent id is gone either way.
173
+ await Promise.allSettled([...state.threads].map((threadId) => this.sessions.delete(agentId, threadId)));
174
+ return this.agents.delete(agentId);
175
+ }
176
+ async getAgentIds() {
177
+ return [...this.agents.keys()];
178
+ }
179
+ // ── Thread Lifecycle ─────────────────────────────────────────────────
180
+ async createThread(agentId, threadId) {
181
+ const state = this.getAgentOrThrow(agentId);
182
+ const id = threadId ?? this.idGenerator.getUniqueId();
183
+ state.threads.add(id);
184
+ return id;
185
+ }
186
+ async getThreadIds(agentId) {
187
+ const state = this.getAgentOrThrow(agentId);
188
+ return [...state.threads];
189
+ }
190
+ async destroyThread(agentId, threadId) {
191
+ const state = this.getAgentOrThrow(agentId);
192
+ // Cancel an in-flight turn on this thread before deleting its history.
193
+ state.coordinators.get(threadId)?.dispose();
194
+ state.coordinators.delete(threadId);
195
+ state.threads.delete(threadId);
196
+ await this.sessions.delete(agentId, threadId);
197
+ }
198
+ async cloneThread(agentId, sourceThreadId) {
199
+ const state = this.getAgentOrThrow(agentId);
200
+ const newId = this.idGenerator.getUniqueId();
201
+ // Copy the source's persisted records verbatim (item + createdAt +
202
+ // isError) into a fresh thread. The OpenAI SDK has no `forkSession`
203
+ // primitive, so the harness copies the transcript itself — a
204
+ // never-streamed source yields an empty clone (no records to copy),
205
+ // matching the contract's two source-state shapes.
206
+ const records = await this.sessions.getRecords(agentId, sourceThreadId);
207
+ await this.sessions.appendRecords(agentId, newId, records);
208
+ state.threads.add(newId);
209
+ return newId;
210
+ }
211
+ // ── Core Messaging ───────────────────────────────────────────────────
212
+ async stream(agentId, threadId, message, options) {
213
+ const state = this.getAgentOrThrow(agentId);
214
+ const abortSignal = options?.abortSignal;
215
+ abortSignal?.throwIfAborted();
216
+ // A `stream()` on a thread the harness hasn't seen registers it — the
217
+ // SDK creates threads via `createThread`, but a direct `stream()` on a
218
+ // fresh id must still persist under that thread rather than silently
219
+ // dropping history.
220
+ state.threads.add(threadId);
221
+ const input = lowerMessageToText(message);
222
+ // The disk-backed Session the runner reads history from and appends new
223
+ // items to. Passing it to `run(..., { session })` is what makes the turn
224
+ // multi-turn-aware and persistent (the runner owns the write).
225
+ const session = this.sessions.session(agentId, threadId);
226
+ // The per-tool approval decider the coordinator consults to route each
227
+ // interruption, or `undefined` when gating is off for this turn (the
228
+ // SDK's "no policy ⇒ no gating" back-compat path). Frozen at stream start;
229
+ // 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.
237
+ const policy = this.buildPolicyGate(state);
238
+ // The tool-result redaction context, or `undefined` when the agent has no
239
+ // `onToolResult` hook (both redaction seams then take their zero-cost
240
+ // pass-through path). Built here — not per tool — so `threadId` is stable
241
+ // across approval resumes, and the live `mcpCatalog` supplies `serverName`
242
+ // for MCP tools.
243
+ const redaction = state.hooks.onToolResult !== undefined
244
+ ? { redactor: state.hooks.onToolResult, agentId, threadId, mcpCatalog: state.mcpCatalog }
245
+ : undefined;
246
+ // Consumer-executed tools (declared in `config.tools`, no `execute`): the
247
+ // model can call them, but the harness never runs them — each mapped
248
+ // tool's `execute` parks on this per-turn registry, and the consumer
249
+ // resolves it out-of-band via `submitToolResult`. `undefined` when the
250
+ // agent declares none, so a plain turn allocates nothing extra.
251
+ const consumerToolDefs = state.config.tools ?? [];
252
+ const consumerTools = consumerToolDefs.length > 0
253
+ ? new ConsumerToolRegistry(new Set(consumerToolDefs.map((t) => t.name)))
254
+ : undefined;
255
+ const tools = consumerTools ? mapConsumerTools(consumerToolDefs, consumerTools, redaction) : [];
256
+ const { agent, runner } = await this.buildAgent(agentId, state, tools, redaction);
257
+ // One coordinator owns this turn's run stream + sink + teardown. Dispose
258
+ // any prior coordinator on the same thread first so a re-`stream()`
259
+ // mid-turn can't leave a stale pump running against the old sink.
260
+ state.coordinators.get(threadId)?.dispose();
261
+ const coordinator = new OpenAIApprovalCoordinator({
262
+ runner,
263
+ agent,
264
+ input,
265
+ session,
266
+ policy,
267
+ consumerTools,
268
+ onConsumerToolErrors: (toolCallIds) => {
269
+ // 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', {
276
+ agentId,
277
+ threadId,
278
+ error: getErrorMessage(error),
279
+ }));
280
+ },
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
+ externalSignal: abortSignal,
288
+ // Live catalog reference so a mid-turn discovery settle enriches
289
+ // later MCP tool events; empty when the agent has no MCP servers.
290
+ mcpCatalog: state.mcpCatalog,
291
+ // Factory-configured per-`toolCallId` approval timeout; the
292
+ // coordinator falls back to its own default when undefined.
293
+ toolApprovalTimeoutMs: this.toolApprovalTimeoutMs,
294
+ // The turn's step limit → `run(..., { maxTurns })`. Undefined leaves
295
+ // the SDK default in place; a hit surfaces as `finish('max-steps')`.
296
+ maxTurns: options?.maxSteps,
297
+ });
298
+ state.coordinators.set(threadId, coordinator);
299
+ return coordinator.start();
300
+ }
301
+ // ── Message History ──────────────────────────────────────────────────
302
+ async getMessages(agentId, threadId) {
303
+ this.getAgentOrThrow(agentId);
304
+ const records = await this.sessions.getRecords(agentId, threadId);
305
+ // `recordsToMessages` maps each flat item to a Message and runs the
306
+ // shared `splitToolResultsIntoToolMessages` normalizer for the canonical
307
+ // #647 layout; sort ascending by `createdAt` afterwards (the #464
308
+ // read contract). The normalizer inherits each source message's
309
+ // `createdAt`, so a hoisted `role:'tool'` message sorts at-or-after its
310
+ // originating assistant call under the stable sort.
311
+ const messages = recordsToMessages(records);
312
+ messages.sort((a, b) => (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0));
313
+ return messages;
314
+ }
315
+ async clearMessages(agentId, threadId) {
316
+ this.getAgentOrThrow(agentId);
317
+ // Clearing leaves the session id reusable, so the thread stays live with
318
+ // empty history — no id rotation is needed and the thread id stays in
319
+ // `state.threads`.
320
+ await this.sessions.clear(agentId, threadId);
321
+ }
322
+ async addContext(agentId, threadId, messages) {
323
+ const state = this.getAgentOrThrow(agentId);
324
+ state.threads.add(threadId);
325
+ // Backfill `createdAt` on any message missing it (per-position
326
+ // `nextAfter` so a bulk insert is strictly ascending) BEFORE lowering to
327
+ // records, so the persisted `createdAt` drives the `getMessages` sort.
328
+ // Defensively backfill at the harness boundary too — a direct
329
+ // `harness.addContext` bypassing the SDK still yields sortable records.
330
+ const filled = backfillCreatedAt(messages, this.clock);
331
+ await this.sessions.appendRecords(agentId, threadId, messagesToRecords(filled));
332
+ }
333
+ // ── Agent Update (#541 MCP preservation) ─────────────────────────────
334
+ /**
335
+ * Apply a new connectivity bag + config to a live agent, preserving any MCP
336
+ * client whose config is structurally unchanged (#541) and cycling only the
337
+ * changed/added/removed servers. No eager `Agent` rebuild is needed: this
338
+ * harness constructs a fresh `Agent` on every `stream()` (see
339
+ * {@link buildAgent}) reading `state.config` / `state.modelConnectivityInfo`
340
+ * / `state.mcpServers` live, so mutating state here is sufficient — the next
341
+ * turn picks up the change. Does NOT dispose in-flight coordinators, touch
342
+ * sessions, or persist (persistence is the SDK's responsibility).
343
+ */
344
+ async updateAgent(agentId, modelConnectivityInfo, config, options) {
345
+ const state = this.getAgentOrThrow(agentId);
346
+ 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.
353
+ // Phase 1 — diff the next MCP config against the applied one, preserving
354
+ // structurally-equal servers and cycling the rest. Per-server Map
355
+ // mutations are synchronous so a re-entrant rollback `updateAgent`
356
+ // (SDK-driven, against the previous config) sees current truth.
357
+ const next = config?.mcpServers ?? {};
358
+ this.diffMcpServers(agentId, state, next, config?.orgJwt, options?.abortSignal);
359
+ // Phase 2 — apply non-MCP changes. The model provider closure and
360
+ // `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.
362
+ state.config = config ?? {};
363
+ state.mcpConfig = config?.mcpServers;
364
+ state.modelConnectivityInfo = modelConnectivityInfo;
365
+ state.hooks = options?.hooks ?? {};
366
+ }
367
+ // ── MCP Introspection / Reconnect ────────────────────────────────────
368
+ /**
369
+ * Synchronous snapshot of the agent's MCP servers: connected servers with
370
+ * their discovered tools, errored servers with a sanitized message +
371
+ * structured `errorDetail`, in-flight servers as `Connecting` /
372
+ * `Reconnecting`, and configured-but-disabled servers (never constructed) as
373
+ * `Disabled`. Status is updated asynchronously by background discovery, so a
374
+ * caller polls this to observe a `createAgent` / `reconnectMcpServer` result.
375
+ */
376
+ getMcpServerInfo(agentId) {
377
+ const state = this.getAgentOrThrow(agentId);
378
+ const results = [];
379
+ for (const [name, server] of state.mcpServers) {
380
+ if (server.status === McpServerStatus.Connected) {
381
+ results.push({ name, status: server.status, tools: server.tools });
382
+ }
383
+ else if (server.status === McpServerStatus.Error) {
384
+ results.push({
385
+ name,
386
+ status: server.status,
387
+ tools: [],
388
+ ...(server.error !== undefined ? { error: server.error } : {}),
389
+ ...(server.errorDetail !== undefined ? { errorDetail: server.errorDetail } : {}),
390
+ });
391
+ }
392
+ else {
393
+ // Connecting / Reconnecting — no tools yet.
394
+ results.push({ name, status: server.status, tools: [] });
395
+ }
396
+ }
397
+ // Disabled servers live only in config (never constructed) — report them
398
+ // so a consumer can see the full configured roster.
399
+ if (state.mcpConfig) {
400
+ for (const [name, cfg] of Object.entries(state.mcpConfig)) {
401
+ if (cfg.enabled === false)
402
+ results.push({ name, status: McpServerStatus.Disabled, tools: [] });
403
+ }
404
+ }
405
+ return results;
406
+ }
407
+ /**
408
+ * Cycle one MCP server's transport and re-discover its tools. Eager: awaits
409
+ * the close → connect → `invalidateToolsCache` → `listTools` round-trip, but
410
+ * a transient transport / discovery failure is recorded on per-server state
411
+ * (surfaced via `getMcpServerInfo`) rather than rejected — only the three
412
+ * programming errors throw. `getAgentOrThrow` runs first (AGENT_NOT_FOUND
413
+ * wins), then an unconfigured server throws `MCP_SERVER_NOT_FOUND` and a
414
+ * disabled one `MCP_SERVER_DISABLED`.
415
+ */
416
+ async reconnectMcpServer(agentId, serverName) {
417
+ const state = this.getAgentOrThrow(agentId);
418
+ const entry = state.mcpServers.get(serverName);
419
+ if (entry === undefined) {
420
+ const disabled = state.mcpConfig?.[serverName]?.enabled === false;
421
+ throw new AgentSDKError(disabled
422
+ ? `MCP server "${serverName}" is disabled on agent "${agentId}".`
423
+ : `No MCP server "${serverName}" configured on agent "${agentId}".`, disabled ? AgentSDKErrorType.MCP_SERVER_DISABLED : AgentSDKErrorType.MCP_SERVER_NOT_FOUND);
424
+ }
425
+ // Cycle status (Connected → Reconnecting; recovery-from-Error →
426
+ // Connecting) and emit the transient `status-changed` before the transport
427
+ // work; then `runMcpDiscovery` emits the discovery triple + the terminal
428
+ // status-change. Clear the stale snapshot before the transport work.
429
+ const transientStatus = entry.status === McpServerStatus.Connected ? McpServerStatus.Reconnecting : McpServerStatus.Connecting;
430
+ this.prepareMcpStatusChange(agentId, serverName, entry, transientStatus)?.();
431
+ entry.error = undefined;
432
+ entry.errorDetail = undefined;
433
+ entry.tools = [];
434
+ this.removeCatalogForServer(state, serverName);
435
+ entry.ready = this.runMcpDiscovery(agentId, state, serverName, entry.server, { reconnect: true });
436
+ await entry.ready;
437
+ }
438
+ // ── Compaction ───────────────────────────────────────────────────────
439
+ /**
440
+ * Compact a thread's history into a fresh thread seeded with an LLM-generated
441
+ * summary, then destroy the source (the `AgentHarness` contract). The OpenAI
442
+ * Agents SDK ships a server-side `OpenAIResponsesCompactionAwareSession.runCompaction`
443
+ * (via `responses.compact`), but it relies on `previous_response_id` /
444
+ * stored responses — unavailable against the stateless Salesforce gateway
445
+ * (`store: false`, the same reason the coordinator pins `reasoningItemIdPolicy: 'omit'`).
446
+ * So this hand-rolls summarize+seed+destroy, mirroring the Mastra/Claude harnesses.
447
+ *
448
+ * `getAgentOrThrow` runs first (AGENT_NOT_FOUND). An empty source is compacted
449
+ * to a fresh empty thread (leaving it attached would leak). A summarization or
450
+ * seed failure surfaces as `AgentSDKError(COMPACTION_FAILED)` with the source
451
+ * left intact; the source is destroyed best-effort only after the new thread
452
+ * is safely seeded.
453
+ */
454
+ async compactThread(agentId, threadId) {
455
+ const state = this.getAgentOrThrow(agentId);
456
+ const messages = await this.getMessages(agentId, threadId);
457
+ if (messages.length === 0) {
458
+ const newThreadId = await this.createThread(agentId);
459
+ // Empty source still gets compacted away — leaving it attached would
460
+ // be a leak. Mirrors the non-empty path's destroy below; both exit
461
+ // with the source detached.
462
+ await this.destroyThreadBestEffort(agentId, threadId);
463
+ return newThreadId;
464
+ }
465
+ const transcript = messages
466
+ .map((m) => {
467
+ const text = typeof m.content === 'string' ? m.content : m.content.map((p) => partToTranscriptText(p)).join(' ');
468
+ return `${m.role}: ${text}`;
469
+ })
470
+ .join('\n');
471
+ let newThreadId;
472
+ try {
473
+ const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
474
+ ? await this.summarize(state, buildSummaryPrompt(transcript))
475
+ : await this.summarizeInChunks(state, transcript);
476
+ newThreadId = await this.createThread(agentId);
477
+ const userTs = this.clock.now();
478
+ const assistantTs = this.clock.nextAfter(userTs);
479
+ await this.addContext(agentId, newThreadId, [
480
+ {
481
+ id: this.idGenerator.getUniqueId(),
482
+ role: 'user',
483
+ content: `The following is a summary of our prior conversation, provided as context for this session:\n\n${summaryText}`,
484
+ createdAt: userTs,
485
+ },
486
+ {
487
+ id: this.idGenerator.getUniqueId(),
488
+ role: 'assistant',
489
+ content: 'Understood. I have the context from our prior conversation and am ready to continue.',
490
+ createdAt: assistantTs,
491
+ },
492
+ ]);
493
+ }
494
+ catch (error) {
495
+ throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(error)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: error });
496
+ }
497
+ // Source-thread cleanup is best-effort. The user's data is already safely
498
+ // seeded into newThreadId; a failed delete leaves an orphan session file
499
+ // but does not invalidate the compaction outcome.
500
+ await this.destroyThreadBestEffort(agentId, threadId);
501
+ return newThreadId;
502
+ }
503
+ // ── Tool Execution ───────────────────────────────────────────────────
504
+ /**
505
+ * Feed a consumer-executed tool's result back into the thread's in-flight
506
+ * turn. `getAgentOrThrow` runs FIRST (AGENT_NOT_FOUND wins over the
507
+ * coordinator lookup, per the SDK invariant); a thread with no in-flight
508
+ * coordinator throws `TOOL_CALL_NOT_FOUND`. The coordinator resolves the
509
+ * parked `execute` keyed by `toolCallId`, the run continues on the SAME
510
+ * eventStream the consumer is already iterating, and idempotency (#589) is
511
+ * owned downstream. `error` rides through as an error-shaped outcome so a
512
+ * consumer-reported failure reaches the tool call.
513
+ */
514
+ async submitToolResult(agentId, threadId, toolResult) {
515
+ const state = this.getAgentOrThrow(agentId);
516
+ const coordinator = state.coordinators.get(threadId);
517
+ if (coordinator === undefined) {
518
+ throw new AgentSDKError(`No in-flight tool call for thread "${threadId}".`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
519
+ }
520
+ coordinator.submitToolResult(toolResult.toolCallId, {
521
+ result: toolResult.isError && toolResult.error ? toolResult.error : toolResult.result,
522
+ isError: toolResult.isError,
523
+ });
524
+ }
525
+ // ── Tool Approval ────────────────────────────────────────────────────
526
+ async approveToolCall(agentId, threadId, toolCallId) {
527
+ this.settleToolApproval(agentId, threadId, toolCallId, 'approve');
528
+ }
529
+ async declineToolCall(agentId, threadId, toolCallId) {
530
+ this.settleToolApproval(agentId, threadId, toolCallId, 'decline');
531
+ }
532
+ // ── Internals ────────────────────────────────────────────────────────
533
+ /**
534
+ * Forward an approve / decline to the thread's in-flight coordinator.
535
+ * `getAgentOrThrow` runs FIRST so an unregistered agent surfaces
536
+ * `AGENT_NOT_FOUND` (the SDK invariant: agent-registry check before the
537
+ * per-`(agentId, threadId)` coordinator lookup) — distinguishable from a
538
+ * registered agent with no in-flight run, which throws `TOOL_CALL_NOT_FOUND`.
539
+ * The coordinator owns idempotency (#589): a repeat / post-teardown settle is
540
+ * a silent no-op there. Post-resume events flow through the SAME eventStream
541
+ * the consumer is already iterating from `stream()`.
542
+ */
543
+ settleToolApproval(agentId, threadId, toolCallId, decision) {
544
+ const state = this.getAgentOrThrow(agentId);
545
+ const coordinator = state.coordinators.get(threadId);
546
+ if (coordinator === undefined) {
547
+ throw new AgentSDKError(`No in-flight tool approval for thread "${threadId}".`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
548
+ }
549
+ if (decision === 'approve')
550
+ coordinator.approve(toolCallId);
551
+ else
552
+ coordinator.decline(toolCallId);
553
+ }
554
+ /**
555
+ * Build the `@openai/agents` `Agent` + `Runner` a turn runs against. One seam
556
+ * for the pair so a `stream()` constructs them identically each turn. The
557
+ * `Runner` binds the agent's live `modelProvider` (JWT rotation lands per
558
+ * request) with tracing off. `tools` carries the turn's consumer-executed
559
+ * tools (mapped from `config.tools`).
560
+ *
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
565
+ * `server.name`) and this harness reuses the SAME connected instances across
566
+ * 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`).
569
+ *
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
574
+ * `getAllMcpTools` reads each server's warm `cacheToolsList` cache with no
575
+ * network `tools/list`. This makes the method async (the materialize awaits
576
+ * `listTools()`), so `stream()` awaits it.
577
+ */
578
+ async buildAgent(agentId, state, tools = [], redaction) {
579
+ 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.
590
+ const connectedMcpServers = [...state.mcpServers.values()]
591
+ .filter((s) => s.status === McpServerStatus.Connected)
592
+ .map((s) => s.server);
593
+ const redactedMcpTools = redaction !== undefined && connectedMcpServers.length > 0
594
+ ? await buildRedactedMcpTools(connectedMcpServers, redaction)
595
+ : [];
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;
599
+ const agent = new Agent({
600
+ name: agentId,
601
+ instructions: state.config.instructions ?? '',
602
+ model: state.modelConnectivityInfo.nativeModelId,
603
+ ...(allTools.length > 0 ? { tools: allTools } : {}),
604
+ ...(attachMcpServers ? { mcpServers: mcpServerInstances } : {}),
605
+ });
606
+ const runner = new Runner({ modelProvider: state.modelProvider, tracingDisabled: true });
607
+ return { agent, runner };
608
+ }
609
+ // ── Compaction internals ─────────────────────────────────────────────
610
+ /**
611
+ * One-shot, non-agentic summarization for `compactThread`. Builds a throwaway
612
+ * `Agent` (summarization-role instructions, NO tools, NO MCP servers) bound to
613
+ * the agent's live `modelProvider`, and runs it non-streaming with no
614
+ * `session` (the summary call must not leak into the thread's persisted
615
+ * history). Returns the model's final text; throws if it produced none.
616
+ *
617
+ * `reasoningItemIdPolicy: 'omit'` is load-bearing for the same reason the
618
+ * turn coordinator pins it — the stateless gateway (`store: false`) rejects
619
+ * reasoning items sent by id reference.
620
+ */
621
+ async summarize(state, prompt) {
622
+ const summaryAgent = new Agent({
623
+ name: 'openai-harness-summarizer',
624
+ instructions: 'You are a summarization assistant. ' +
625
+ 'Summarize the conversation transcript provided in the user message. ' +
626
+ 'Preserve numeric values, dates, and proper names verbatim. ' +
627
+ 'Do not invent details that are not present in the transcript.',
628
+ model: state.modelConnectivityInfo.nativeModelId,
629
+ });
630
+ const runner = new Runner({ modelProvider: state.modelProvider, tracingDisabled: true });
631
+ const result = await runner.run(summaryAgent, prompt, { reasoningItemIdPolicy: 'omit' });
632
+ const text = typeof result.finalOutput === 'string' ? result.finalOutput : '';
633
+ if (text.length === 0) {
634
+ throw new Error('Summary call produced no result text');
635
+ }
636
+ return text;
637
+ }
638
+ /**
639
+ * Two-pass summarization for transcripts above {@link MAX_TRANSCRIPT_CHARS}.
640
+ * Splits on line boundaries, summarizes each chunk, then summarizes the
641
+ * concatenated chunk-summaries (recursing if the merge itself is still over
642
+ * budget). Mirrors the sibling harnesses' map/reduce so cross-harness
643
+ * compaction behavior matches.
644
+ */
645
+ async summarizeInChunks(state, transcript) {
646
+ const chunks = splitIntoChunks(transcript, MAX_TRANSCRIPT_CHARS);
647
+ const chunkSummaries = [];
648
+ for (const chunk of chunks) {
649
+ chunkSummaries.push(await this.summarize(state, buildSummaryPrompt(chunk)));
650
+ }
651
+ const merged = chunkSummaries.map((s, i) => `Part ${i + 1}:\n${s}`).join('\n\n');
652
+ return merged.length <= MAX_TRANSCRIPT_CHARS
653
+ ? this.summarize(state, buildSummaryPrompt(merged))
654
+ : this.summarizeInChunks(state, merged);
655
+ }
656
+ /**
657
+ * Best-effort `destroyThread`: swallows the error so `compactThread`'s outer
658
+ * flow isn't aborted by a teardown blip, but logs it at `warn` so a future
659
+ * `destroyThread` regression stays observable. Used on both the empty-source
660
+ * and non-empty compaction paths.
661
+ */
662
+ async destroyThreadBestEffort(agentId, threadId) {
663
+ try {
664
+ await this.destroyThread(agentId, threadId);
665
+ }
666
+ catch (error) {
667
+ this.buses
668
+ .getLogBus()
669
+ ?.warn('Best-effort destroyThread failed during compactThread', { agentId, threadId }, error instanceof Error ? error : new Error(String(error)));
670
+ }
671
+ }
672
+ // ── MCP lifecycle internals ──────────────────────────────────────────
673
+ /**
674
+ * Construct the agent's enabled MCP servers and kick off background discovery
675
+ * for each. Synchronous: it inserts every server entry (status `Connecting`)
676
+ * then assigns its background discovery promise to `entry.ready`, so
677
+ * `createAgent` / `updateAgent` never block on `connect()` / `listTools()`.
678
+ * A no-op when the config declares no enabled servers.
679
+ */
680
+ startMcpServers(agentId, state, config, abortSignal) {
681
+ if (!hasEnabledServers(config))
682
+ return;
683
+ const servers = this.mcpServerFactory(config, state.config.orgJwt);
684
+ for (const [name, server] of servers) {
685
+ const entry = {
686
+ server,
687
+ config: config[name],
688
+ status: McpServerStatus.Connecting,
689
+ tools: [],
690
+ ready: Promise.resolve(),
691
+ };
692
+ state.mcpServers.set(name, entry);
693
+ entry.ready = this.runMcpDiscovery(agentId, state, name, server, { abortSignal });
694
+ }
695
+ }
696
+ /**
697
+ * Connect + list tools for one MCP server, recording the outcome on
698
+ * per-server state and emitting the discovery telemetry triple
699
+ * (`started` → `completed` | `failed`) plus the terminal `status-changed`.
700
+ * Guards against an orphaned write by re-checking the server instance
701
+ * identity after each await — a superseded cycle replaces the entry with a
702
+ * fresh instance, so a stale discovery must neither clobber state nor emit
703
+ * (every terminal emit sits behind the `isCurrent()` guard). Discovery
704
+ * failure is recorded on state (status `Error`, sanitized message +
705
+ * structured detail) and never rethrown — matching the "failed server ≠
706
+ * failed create/reconnect" contract; an aborted run records the state but
707
+ * emits no `failed` telemetry.
708
+ */
709
+ async runMcpDiscovery(agentId, state, name, server, options = {}) {
710
+ // Guard: only write back if this instance is still the agent's server for
711
+ // `name` (a cycle/reconnect may have replaced it while we awaited).
712
+ const isCurrent = () => state.mcpServers.get(name)?.server === server;
713
+ // Emitted synchronously at entry (before the first await) when this
714
+ // instance is still current — so `started` always precedes its terminal
715
+ // event in timestamp order and a superseded run stays silent. The
716
+ // `reconnect` flag does not change which event fires — one `started` per
717
+ // discovery run whether triggered by create / update / reconnect.
718
+ const startTime = this.clock.now().getTime();
719
+ this.emitMcpTelemetry({
720
+ type: 'mcp-server-discovery-started',
721
+ timestamp: this.clock.now(),
722
+ agentId,
723
+ serverName: name,
724
+ });
725
+ try {
726
+ options.abortSignal?.throwIfAborted();
727
+ if (options.reconnect) {
728
+ await server.close().catch(() => undefined);
729
+ await server.connect();
730
+ await server.invalidateToolsCache();
731
+ }
732
+ else {
733
+ await server.connect();
734
+ }
735
+ const tools = await server.listTools();
736
+ if (!isCurrent())
737
+ return;
738
+ const entry = state.mcpServers.get(name);
739
+ if (entry === undefined)
740
+ return;
741
+ entry.tools = tools.map((tool) => toMcpToolInfo(name, tool));
742
+ entry.error = undefined;
743
+ entry.errorDetail = undefined;
744
+ this.refreshCatalogForServer(state, name, tools);
745
+ // Flip status synchronously, then emit `completed` before the
746
+ // `status-changed` so a subscriber to both sees discovery → status in
747
+ // the same order a `getMcpServerInfo()` snapshot would (#373).
748
+ const emitStatusChange = this.prepareMcpStatusChange(agentId, name, entry, McpServerStatus.Connected);
749
+ this.emitMcpTelemetry({
750
+ type: 'mcp-server-discovery-completed',
751
+ timestamp: this.clock.now(),
752
+ agentId,
753
+ serverName: name,
754
+ toolCount: entry.tools.length,
755
+ durationMs: this.clock.now().getTime() - startTime,
756
+ });
757
+ emitStatusChange?.();
758
+ }
759
+ catch (error) {
760
+ if (!isCurrent())
761
+ return;
762
+ const entry = state.mcpServers.get(name);
763
+ if (entry === undefined)
764
+ return;
765
+ const aborted = isAbortError(error) || options.abortSignal?.aborted === true;
766
+ entry.tools = [];
767
+ const errorMessage = sanitizeMcpErrorMessage(error);
768
+ entry.error = errorMessage;
769
+ entry.errorDetail = buildMcpErrorDetail(error);
770
+ this.removeCatalogForServer(state, name);
771
+ this.buses.getLogBus()?.warn('MCP server discovery failed', {
772
+ serverName: name,
773
+ error: getErrorMessage(error),
774
+ });
775
+ // Flip status synchronously either way; emit only when NOT aborted (an
776
+ // aborted discovery isn't a server failure the consumer should see).
777
+ const emitStatusChange = this.prepareMcpStatusChange(agentId, name, entry, McpServerStatus.Error, errorMessage);
778
+ if (!aborted) {
779
+ this.emitMcpTelemetry({
780
+ type: 'mcp-server-discovery-failed',
781
+ timestamp: this.clock.now(),
782
+ agentId,
783
+ serverName: name,
784
+ durationMs: this.clock.now().getTime() - startTime,
785
+ error: error instanceof Error ? error : new Error(String(error)),
786
+ ...(entry.errorDetail !== undefined ? { errorDetail: entry.errorDetail } : {}),
787
+ });
788
+ emitStatusChange?.();
789
+ }
790
+ }
791
+ }
792
+ /**
793
+ * Emit an MCP telemetry event, swallowing only the typed `DISPOSED` error a
794
+ * post-`shutdown()` background discovery callback would hit (the bus is gone).
795
+ * Any other emit failure rethrows so a real regression surfaces. Every MCP
796
+ * emit is background / racing shutdown, so all route through here.
797
+ */
798
+ emitMcpTelemetry(event) {
799
+ try {
800
+ this.buses.emitTelemetry(event);
801
+ }
802
+ catch (error) {
803
+ if (!isDisposedError(error))
804
+ throw error;
805
+ }
806
+ }
807
+ /**
808
+ * Flip a server's status synchronously and return a closure that emits the
809
+ * matching `mcp-server-status-changed` telemetry — or `undefined` when the
810
+ * status is unchanged (same-status transitions are deduped here so callers
811
+ * don't have to). Splitting the flip from the emit lets callers order the
812
+ * emit AFTER the discovery-terminal event (#373), while a synchronous reader
813
+ * of `getMcpServerInfo()` already sees the post-flip status.
814
+ */
815
+ prepareMcpStatusChange(agentId, serverName, entry, nextStatus, error) {
816
+ const previousStatus = entry.status;
817
+ if (previousStatus === nextStatus)
818
+ return undefined;
819
+ entry.status = nextStatus;
820
+ return () => this.emitMcpTelemetry({
821
+ type: 'mcp-server-status-changed',
822
+ timestamp: this.clock.now(),
823
+ agentId,
824
+ serverName,
825
+ previousStatus,
826
+ nextStatus,
827
+ ...(error !== undefined ? { error } : {}),
828
+ });
829
+ }
830
+ /**
831
+ * Diff the next MCP config against the applied one and preserve / cycle / add
832
+ * / remove servers accordingly. `mcpServerConfigEqual` (SDK-exported, uniform
833
+ * across harnesses) is the preserve predicate: an equal server is left
834
+ * completely untouched (its live instance + warm tool-list cache survive — no
835
+ * `close`, no `tools/list`, the #541 checkpoint). A changed / removed server
836
+ * is closed and dropped; a changed / added enabled server is constructed
837
+ * fresh and discovered in the background.
838
+ */
839
+ diffMcpServers(agentId, state, next, orgJwt, abortSignal) {
840
+ const prev = state.mcpConfig ?? {};
841
+ const names = new Set([...Object.keys(prev), ...Object.keys(next)]);
842
+ for (const name of names) {
843
+ if (mcpServerConfigEqual(prev[name], next[name])) {
844
+ // Preserve: nothing changed for this server. Its live instance +
845
+ // warm tool-list cache survive untouched (no close, no
846
+ // `tools/list`, no fresh construction — the #541 checkpoint).
847
+ // Refresh the stored config to the new (structurally-equal)
848
+ // object for a clean next diff.
849
+ const entry = state.mcpServers.get(name);
850
+ if (entry !== undefined && next[name] !== undefined)
851
+ entry.config = next[name];
852
+ continue;
853
+ }
854
+ // Changed or removed: tear down the previous instance if we held one.
855
+ const prevEntry = state.mcpServers.get(name);
856
+ if (prevEntry !== undefined) {
857
+ state.mcpServers.delete(name);
858
+ this.removeCatalogForServer(state, name);
859
+ // Fire-and-forget: the cycle must not block on the old server's
860
+ // close (the #541 "cycle doesn't wait" design). `closeServerEntry`
861
+ // owns the terminal catch so a synchronous throw from `close()`
862
+ // can't become an unhandled rejection.
863
+ void closeServerEntry(prevEntry);
864
+ }
865
+ // Changed or added: construct + discover a fresh instance if the next
866
+ // config enables this server. Constructed per-name (not for the whole
867
+ // next config) so a preserved server is never rebuilt.
868
+ const nextConfig = next[name];
869
+ if (nextConfig !== undefined && nextConfig.enabled !== false) {
870
+ const server = this.mcpServerFactory({ [name]: nextConfig }, orgJwt).get(name);
871
+ if (server !== undefined) {
872
+ const entry = {
873
+ server,
874
+ config: nextConfig,
875
+ status: McpServerStatus.Connecting,
876
+ tools: [],
877
+ ready: Promise.resolve(),
878
+ };
879
+ state.mcpServers.set(name, entry);
880
+ entry.ready = this.runMcpDiscovery(agentId, state, name, server, { abortSignal });
881
+ }
882
+ }
883
+ }
884
+ }
885
+ /** Close every MCP server on an agent, awaiting in-flight discovery first. */
886
+ async closeMcpServers(state) {
887
+ await Promise.allSettled([...state.mcpServers.values()].map((entry) => closeServerEntry(entry)));
888
+ state.mcpServers.clear();
889
+ state.mcpCatalog.clear();
890
+ }
891
+ /** Rebuild the catalog entries for one server from its freshly-discovered tools. */
892
+ refreshCatalogForServer(state, serverName, tools) {
893
+ this.removeCatalogForServer(state, serverName);
894
+ for (const tool of tools) {
895
+ const annotations = tool.annotations;
896
+ state.mcpCatalog.set(tool.name, { serverName, ...(annotations !== undefined ? { annotations } : {}) });
897
+ }
898
+ }
899
+ /** Drop every catalog entry that points at `serverName`. */
900
+ removeCatalogForServer(state, serverName) {
901
+ for (const [bareToolName, entry] of state.mcpCatalog) {
902
+ if (entry.serverName === serverName)
903
+ state.mcpCatalog.delete(bareToolName);
904
+ }
905
+ }
906
+ /**
907
+ * Decide whether the tool-approval gate engages this turn and, if so, return
908
+ * the per-tool policy decider the coordinator consults. `undefined` ⇒ gate
909
+ * inactive; the coordinator suspends nothing and tools run free (the SDK's
910
+ * "no policy ⇒ no gating" back-compat invariant), avoiding a suspend/resume
911
+ * round-trip per call.
912
+ *
913
+ * Gating is **opt-in**: it engages only when the consumer set
914
+ * `config.toolPolicies` (non-empty) or `config.defaultToolDecision`. Whether
915
+ * the gate engaged is frozen at stream start (the closure is built iff gating
916
+ * is active now), but the closure itself reads `state.config` **live on every
917
+ * call** rather than a snapshot — a mid-turn `updateAgentConfig` (notably the
918
+ * `source: 'remember'` rule an "Allow always" click appends) reassigns
919
+ * `state.config` in place on this same `state` without disposing the in-flight
920
+ * coordinator, so a snapshot would leave approvals 2..N in that turn
921
+ * re-prompting even though the rule is persisted (#638). Reading live makes
922
+ * the remembered decision auto-resolve subsequent matching calls.
923
+ * `OPENAI_BUILT_IN_TOOL_POLICIES` is the `tiers.harness` slice (empty today).
924
+ */
925
+ buildPolicyGate(state) {
926
+ const config = state.config;
927
+ const gatingActive = (config.toolPolicies?.length ?? 0) > 0 || config.defaultToolDecision !== undefined;
928
+ if (!gatingActive)
929
+ return undefined;
930
+ return (invocation) => resolveToolApprovalPolicy(invocation, state.config, { harness: OPENAI_BUILT_IN_TOOL_POLICIES }).decision;
931
+ }
932
+ getAgentOrThrow(agentId) {
933
+ const state = this.agents.get(agentId);
934
+ if (state === undefined) {
935
+ throw new AgentSDKError(`No Agent found with id: "${agentId}"`, AgentSDKErrorType.AGENT_NOT_FOUND);
936
+ }
937
+ return state;
938
+ }
939
+ }
940
+ /**
941
+ * `true` for the typed `DISPOSED` error `HarnessBusOwner` throws after
942
+ * `dispose()`. A background MCP-discovery callback that resolves after
943
+ * `shutdown()` hits this when it emits telemetry; the emit helper swallows only
944
+ * this error and rethrows anything else (Mastra's `isDisposedError` pattern).
945
+ */
946
+ function isDisposedError(error) {
947
+ return error instanceof AgentSDKError && error.type === AgentSDKErrorType.DISPOSED;
948
+ }
949
+ /**
950
+ * Await an MCP server's in-flight discovery, then close its transport. Every
951
+ * step is defended so the whole thing resolves rather than rejects — the sole
952
+ * teardown primitive for both the blocking path (`closeMcpServers` under
953
+ * `Promise.allSettled`) and the fire-and-forget cycle path in `diffMcpServers`
954
+ * (where a bare `.then(() => close())` chain could otherwise surface a
955
+ * synchronous `close()` throw as an unhandled rejection). Awaiting `ready`
956
+ * first ensures close never races an in-flight `connect()`.
957
+ */
958
+ async function closeServerEntry(entry) {
959
+ try {
960
+ await entry.ready;
961
+ }
962
+ catch {
963
+ // Discovery already failed / was recorded on state — proceed to close.
964
+ }
965
+ try {
966
+ await entry.server.close();
967
+ }
968
+ catch {
969
+ // Best-effort close: a torn-down transport must not throw out of teardown.
970
+ }
971
+ }
972
+ /**
973
+ * Map one discovered `@openai/agents` MCP tool to the SDK's {@link McpToolInfo}.
974
+ * The `@openai/agents` native attach registers tools under their bare name (no
975
+ * `${server}_` namespacing), so the display `name`, the authoritative `toolName`,
976
+ * and the catalog key are all the same bare string. `annotations` is read
977
+ * structurally because the installed `MCPTool` schema does not carry it — a
978
+ * future SDK version that adds it will surface here without a code change.
979
+ */
980
+ function toMcpToolInfo(serverName, tool) {
981
+ const annotations = tool.annotations;
982
+ return {
983
+ name: tool.name,
984
+ serverName,
985
+ toolName: tool.name,
986
+ ...(tool.description !== undefined ? { description: tool.description } : {}),
987
+ ...(isJsonObject(tool.inputSchema) ? { inputSchema: tool.inputSchema } : {}),
988
+ ...(annotations !== undefined ? { annotations } : {}),
989
+ };
990
+ }
991
+ /** Narrow an unknown to a plain JSON-object record (MCP `inputSchema` shape). */
992
+ function isJsonObject(value) {
993
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
994
+ }
995
+ /**
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.
999
+ */
1000
+ function lowerMessageToText(message) {
1001
+ if (typeof message === 'string')
1002
+ 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');
1013
+ }
1014
+ //# sourceMappingURL=openai-agents-harness.js.map