@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.
- package/CHANGELOG.md +37 -0
- package/LICENSE.txt +21 -0
- package/README.md +55 -0
- package/dist/gen-sink.d.ts +8 -0
- package/dist/gen-sink.js +13 -0
- package/dist/gen-sink.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-error-classifier.d.ts +36 -0
- package/dist/mcp-error-classifier.js +166 -0
- package/dist/mcp-error-classifier.js.map +1 -0
- package/dist/openai-agents-harness-factory.d.ts +36 -0
- package/dist/openai-agents-harness-factory.js +39 -0
- package/dist/openai-agents-harness-factory.js.map +1 -0
- package/dist/openai-agents-harness.d.ts +302 -0
- package/dist/openai-agents-harness.js +1014 -0
- package/dist/openai-agents-harness.js.map +1 -0
- package/dist/openai-approval-coordinator.d.ts +231 -0
- package/dist/openai-approval-coordinator.js +422 -0
- package/dist/openai-approval-coordinator.js.map +1 -0
- package/dist/openai-built-in-policies.d.ts +29 -0
- package/dist/openai-built-in-policies.js +33 -0
- package/dist/openai-built-in-policies.js.map +1 -0
- package/dist/openai-event-adapter.d.ts +119 -0
- package/dist/openai-event-adapter.js +322 -0
- package/dist/openai-event-adapter.js.map +1 -0
- package/dist/openai-mcp-config-mapper.d.ts +58 -0
- package/dist/openai-mcp-config-mapper.js +133 -0
- package/dist/openai-mcp-config-mapper.js.map +1 -0
- package/dist/openai-mcp-state.d.ts +67 -0
- package/dist/openai-mcp-state.js +6 -0
- package/dist/openai-mcp-state.js.map +1 -0
- package/dist/openai-message-mapper.d.ts +79 -0
- package/dist/openai-message-mapper.js +374 -0
- package/dist/openai-message-mapper.js.map +1 -0
- package/dist/openai-model-provider.d.ts +46 -0
- package/dist/openai-model-provider.js +144 -0
- package/dist/openai-model-provider.js.map +1 -0
- package/dist/openai-session-store.d.ts +149 -0
- package/dist/openai-session-store.js +328 -0
- package/dist/openai-session-store.js.map +1 -0
- package/dist/openai-tool-mapper.d.ts +121 -0
- package/dist/openai-tool-mapper.js +231 -0
- package/dist/openai-tool-mapper.js.map +1 -0
- package/dist/openai-tool-redaction.d.ts +55 -0
- package/dist/openai-tool-redaction.js +82 -0
- package/dist/openai-tool-redaction.js.map +1 -0
- package/dist/test/tsconfig.tsbuildinfo +1 -0
- package/dist/text-stream.d.ts +30 -0
- package/dist/text-stream.js +103 -0
- package/dist/text-stream.js.map +1 -0
- 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
|