@salesforce/sfdx-agent-harness-mastra 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/mastra-harness.d.ts +59 -1
- package/dist/mastra-harness.js +338 -86
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -109,6 +109,30 @@ Mastra-internal pool / index / loaded-tools state is intentionally not on the ha
|
|
|
109
109
|
are `getStateStats()`, `clearState(threadId?)`, `clearAllState()`, and `cleanupNow()` on `ToolSearchHandle`, plus `id`
|
|
110
110
|
on both handles.
|
|
111
111
|
|
|
112
|
+
### MCP tool-event enrichment
|
|
113
|
+
|
|
114
|
+
The harness enriches every `tool-call`, `tool-result`, and `tool-approval-request` event with the originating MCP server
|
|
115
|
+
name and any annotations the server declared, so consumers can branch on tool hints or group by server without parsing
|
|
116
|
+
the namespaced tool name themselves. `serverName` is `undefined` for non-MCP tools (workspace tools, consumer-declared
|
|
117
|
+
tools); `annotations` is `undefined` when the server omitted the field (per the MCP spec).
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
for await (const event of result.eventStream) {
|
|
121
|
+
if (event.type === 'tool-approval-request') {
|
|
122
|
+
if (event.annotations?.readOnlyHint) {
|
|
123
|
+
// Auto-approve safe reads.
|
|
124
|
+
await session.approveToolCall(event.toolCall.toolCallId);
|
|
125
|
+
} else {
|
|
126
|
+
// Group/label by server when present, e.g. "sfdx wants to: deploy_metadata".
|
|
127
|
+
const approved = await promptUser(event.serverName, event.toolCall);
|
|
128
|
+
approved
|
|
129
|
+
? await session.approveToolCall(event.toolCall.toolCallId)
|
|
130
|
+
: await session.declineToolCall(event.toolCall.toolCallId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
112
136
|
### Using LLM Gateway Express
|
|
113
137
|
|
|
114
138
|
```ts
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -28,9 +28,16 @@ interface ActiveRun {
|
|
|
28
28
|
/**
|
|
29
29
|
* Per-server MCP state. Each enabled server gets its own MCPClient and
|
|
30
30
|
* independent discovery promise so readiness is tracked individually.
|
|
31
|
+
*
|
|
32
|
+
* `epoch` increments on every fresh discovery (initial create + each
|
|
33
|
+
* reconnect). Discovery callbacks captured against an earlier epoch drop their
|
|
34
|
+
* writes when a newer discovery has already started for the same server, so an
|
|
35
|
+
* orphaned in-flight `listToolsetsWithErrors()` cannot overwrite a fresher
|
|
36
|
+
* discovery's `tools` / `error` / `settled` state.
|
|
31
37
|
*/
|
|
32
38
|
interface McpServerState {
|
|
33
39
|
client: MCPClient;
|
|
40
|
+
epoch: number;
|
|
34
41
|
error?: string;
|
|
35
42
|
ready: Promise<void>;
|
|
36
43
|
settled: boolean;
|
|
@@ -165,6 +172,18 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
165
172
|
*/
|
|
166
173
|
getAgentIds(): Promise<string[]>;
|
|
167
174
|
getMcpServerInfo(agentId: string): McpServerInfo[];
|
|
175
|
+
/**
|
|
176
|
+
* Eager implementation of {@link AgentHarness.reconnectMcpServer}: calls
|
|
177
|
+
* Mastra's `MCPClient.reconnectServer(name)` primitive, supersedes any
|
|
178
|
+
* in-flight discovery (replace-ready semantics — latest call wins), and
|
|
179
|
+
* awaits the new discovery before resolving.
|
|
180
|
+
*
|
|
181
|
+
* Programming errors (unknown agent / unknown server / disabled) reject.
|
|
182
|
+
* Transport / discovery failures are recorded on the per-server state
|
|
183
|
+
* (visible via {@link getMcpServerInfo}) and a `mcp-server-discovery-*`
|
|
184
|
+
* telemetry pair fires; the returned promise still resolves.
|
|
185
|
+
*/
|
|
186
|
+
reconnectMcpServer(agentId: string, serverName: string): Promise<void>;
|
|
168
187
|
/**
|
|
169
188
|
* @requirements
|
|
170
189
|
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
@@ -215,6 +234,14 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
215
234
|
* User turns are treated as authoritative input.
|
|
216
235
|
*/
|
|
217
236
|
compactThread(agentId: string, threadId: string): Promise<string>;
|
|
237
|
+
/**
|
|
238
|
+
* Best-effort wrapper around `destroyThread`: swallows the error so the
|
|
239
|
+
* caller's outer flow isn't aborted by a teardown blip, but logs the
|
|
240
|
+
* failure at warn level so a future regression in `destroyThread` is
|
|
241
|
+
* still observable. Used by `compactThread` on both the empty-source and
|
|
242
|
+
* non-empty paths.
|
|
243
|
+
*/
|
|
244
|
+
private destroyThreadBestEffort;
|
|
218
245
|
/**
|
|
219
246
|
* @requirements
|
|
220
247
|
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
@@ -302,7 +329,6 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
302
329
|
* directly.
|
|
303
330
|
*/
|
|
304
331
|
private summarize;
|
|
305
|
-
private buildSummaryPrompt;
|
|
306
332
|
/**
|
|
307
333
|
* Two-pass summarization for transcripts above the char budget. The
|
|
308
334
|
* transcript is split into roughly-equal chunks each ≤ MAX_TRANSCRIPT_CHARS,
|
|
@@ -317,9 +343,41 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
317
343
|
*/
|
|
318
344
|
private splitIntoChunks;
|
|
319
345
|
private getAgentStateOrThrow;
|
|
346
|
+
/**
|
|
347
|
+
* Runs MCP discovery for one server: emits the
|
|
348
|
+
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
|
349
|
+
* triple and writes results into the corresponding entry in `serverStates`.
|
|
350
|
+
*
|
|
351
|
+
* The caller MUST `set()` the entry into `serverStates` either before
|
|
352
|
+
* calling this helper (the `createAgent` path constructs the entry
|
|
353
|
+
* synchronously after this returns the promise) or before any await
|
|
354
|
+
* boundary in the helper's chain — the chain reads back via
|
|
355
|
+
* `serverStates.get(serverName)!` to write `tools` / `error`. Both call
|
|
356
|
+
* sites today put the entry in place before the first microtask boundary.
|
|
357
|
+
*
|
|
358
|
+
* Errors observed during discovery are recorded on the entry's `error`
|
|
359
|
+
* field; the returned promise resolves either way (it only rejects when
|
|
360
|
+
* an aborted operation throws after we have already cleaned up). The
|
|
361
|
+
* `settled` flag is pinned by `.finally()` so `getMcpServerInfo()` can
|
|
362
|
+
* tell `Connecting` from `Connected`/`Error`.
|
|
363
|
+
*/
|
|
364
|
+
private runMcpDiscovery;
|
|
320
365
|
private toChatStreamResult;
|
|
321
366
|
private getMemoryOrThrow;
|
|
322
367
|
private agentResourceId;
|
|
368
|
+
/**
|
|
369
|
+
* Build a `RequestContext` carrying the thread + resource ids under the
|
|
370
|
+
* reserved keys Mastra reads when input processors (e.g. `ToolSearchProcessor`,
|
|
371
|
+
* `SkillSearchProcessor`) call `requestContext.get(MASTRA_THREAD_ID_KEY)`.
|
|
372
|
+
*
|
|
373
|
+
* `memory: { thread, resource }` populates Mastra's memory layer but is NOT
|
|
374
|
+
* propagated into the request context, so processors that key per-thread
|
|
375
|
+
* state by `MASTRA_THREAD_ID_KEY` would otherwise see `undefined` and fall
|
|
376
|
+
* back to the `'default'` bucket — folding every consumer thread into one.
|
|
377
|
+
* Surfacing the ids here lets `ToolSearchHandle.clearState(threadId)`
|
|
378
|
+
* actually match what the processor stored.
|
|
379
|
+
*/
|
|
380
|
+
private buildRequestContext;
|
|
323
381
|
private createEventStream;
|
|
324
382
|
private readableStreamToAsyncGenerator;
|
|
325
383
|
/**
|
package/dist/mastra-harness.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Agent } from '@mastra/core/agent';
|
|
|
8
8
|
import { Mastra } from '@mastra/core/mastra';
|
|
9
9
|
import { Memory } from '@mastra/memory';
|
|
10
10
|
import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
|
|
11
|
+
import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY, RequestContext } from '@mastra/core/request-context';
|
|
11
12
|
import { LibSQLStore } from '@mastra/libsql';
|
|
12
13
|
import { MCPClient } from '@mastra/mcp';
|
|
13
14
|
import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, HarnessBusOwner, McpServerStatus, } from '@salesforce/sfdx-agent-sdk';
|
|
@@ -18,7 +19,7 @@ import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
|
|
|
18
19
|
import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
|
|
19
20
|
import { DefaultMastraModelConfigFactory } from './default-model-config-factory.js';
|
|
20
21
|
import { partitionPool, } from './tool-search.js';
|
|
21
|
-
import { Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
|
|
22
|
+
import { buildSummaryPrompt, Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
|
|
22
23
|
import { makeAbortable } from './utils.js';
|
|
23
24
|
import { Workspace, LocalFilesystem, LocalSandbox, WORKSPACE_TOOLS, WORKSPACE_TOOLS_PREFIX, } from '@mastra/core/workspace';
|
|
24
25
|
/**
|
|
@@ -168,6 +169,63 @@ async function listMarkdownFilesInDirectory(dir) {
|
|
|
168
169
|
}
|
|
169
170
|
return names.sort().map((name) => path.join(dir, name));
|
|
170
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Tool ChatEvents that carry the optional `serverName` + `annotations`
|
|
174
|
+
* enrichment fields. Centralizing the predicate lets `createEventStream`
|
|
175
|
+
* narrow once and apply the lookup uniformly across the three variants.
|
|
176
|
+
*/
|
|
177
|
+
function isEnrichableToolEvent(event) {
|
|
178
|
+
return event.type === 'tool-call' || event.type === 'tool-result' || event.type === 'tool-approval-request';
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the MCP server name and (optionally) annotations for a Mastra
|
|
182
|
+
* namespaced tool name (`${serverName}_${toolName}`). Returns `undefined` for
|
|
183
|
+
* tools that are not in any MCP server's catalog (consumer-declared client
|
|
184
|
+
* tools, workspace tools).
|
|
185
|
+
*
|
|
186
|
+
* Selects the **longest matching prefix whose catalog actually contains the
|
|
187
|
+
* tool**. Prefix matching alone is unsafe when two server names are
|
|
188
|
+
* prefix-related (e.g. OOTB `salesforce_dx` and a user-config
|
|
189
|
+
* `salesforce_dx_custom`): naive iteration would attribute every
|
|
190
|
+
* `salesforce_dx_custom_*` call to the OOTB server because `Map` iteration is
|
|
191
|
+
* insertion order and OOTB usually registers first. The existence check on the
|
|
192
|
+
* candidate's `tools` map closes that hole; the longest-prefix tiebreak handles
|
|
193
|
+
* the case where two prefix-related servers both legitimately catalog a tool
|
|
194
|
+
* with the same suffix.
|
|
195
|
+
*
|
|
196
|
+
* `serverState.tools` is keyed by the **namespaced** name (see the
|
|
197
|
+
* `namespacedTools[\`${serverName}_${toolName}\`] = toolConfig` line in
|
|
198
|
+
* `createAgent`), so the existence check uses `namespacedToolName` directly.
|
|
199
|
+
*
|
|
200
|
+
* Design debt: this walk runs per `tool-call` / `tool-result` /
|
|
201
|
+
* `tool-approval-request` event and reconstructs the tool→server linkage from
|
|
202
|
+
* the namespaced string. The cost is O(servers) on a typically-small map, but
|
|
203
|
+
* three call sites now share the same lookup and consumers (e.g. AFV's W4.8
|
|
204
|
+
* trust policy) consume the enriched `serverName` for trust-relevant
|
|
205
|
+
* decisions, so the carry-on-the-record fix — attaching an `McpToolInfo`
|
|
206
|
+
* reference (with `serverName`) on the in-memory tool-call record from the
|
|
207
|
+
* moment Mastra namespaces the name — becomes proportionally more valuable.
|
|
208
|
+
* That fix touches `MastraEventAdapter`, `ToolCallInfo`, and the per-stream
|
|
209
|
+
* pipeline. If this walk shows up in profiles before then, build a
|
|
210
|
+
* `Map<namespacedToolName, { serverName, McpToolInfo }>` index keyed off
|
|
211
|
+
* discovery completion.
|
|
212
|
+
*/
|
|
213
|
+
function lookupMcpToolMetadata(agentState, namespacedToolName) {
|
|
214
|
+
let best;
|
|
215
|
+
for (const [serverName, serverState] of agentState.mcpServers) {
|
|
216
|
+
if (!namespacedToolName.startsWith(`${serverName}_`))
|
|
217
|
+
continue;
|
|
218
|
+
if (!(namespacedToolName in serverState.tools))
|
|
219
|
+
continue;
|
|
220
|
+
if (!best || serverName.length > best.serverName.length) {
|
|
221
|
+
best = { serverName, serverState };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!best)
|
|
225
|
+
return undefined;
|
|
226
|
+
const annotations = best.serverState.tools[namespacedToolName]?.mcp?.annotations;
|
|
227
|
+
return { serverName: best.serverName, ...(annotations ? { annotations } : {}) };
|
|
228
|
+
}
|
|
171
229
|
/**
|
|
172
230
|
* Projects the namespaced Mastra `ToolAction` map for one MCP server into the
|
|
173
231
|
* SDK's harness-agnostic `McpToolInfo[]` shape.
|
|
@@ -361,78 +419,18 @@ export class MastraHarness {
|
|
|
361
419
|
// Fallback only — per-server MCPServerConfig.timeout (in serverDef) takes precedence.
|
|
362
420
|
timeout: DEFAULT_MCP_TIMEOUT_MS,
|
|
363
421
|
});
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
const tools = toolsets[serverName] ?? {};
|
|
378
|
-
const namespacedTools = {};
|
|
379
|
-
for (const [toolName, toolConfig] of Object.entries(tools)) {
|
|
380
|
-
namespacedTools[`${serverName}_${toolName}`] = toolConfig;
|
|
381
|
-
}
|
|
382
|
-
mcpServerStates.get(serverName).tools = namespacedTools;
|
|
383
|
-
// listToolsetsWithErrors() runs in the background and can resolve
|
|
384
|
-
// after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
|
|
385
|
-
// so a real listener exception still surfaces.
|
|
386
|
-
try {
|
|
387
|
-
this.buses.emitTelemetry({
|
|
388
|
-
type: 'mcp-server-discovery-completed',
|
|
389
|
-
timestamp: this.clock.now(),
|
|
390
|
-
agentId,
|
|
391
|
-
serverName,
|
|
392
|
-
toolCount: Object.keys(namespacedTools).length,
|
|
393
|
-
durationMs: this.clock.now().getTime() - startTime,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
catch (emitErr) {
|
|
397
|
-
if (!isDisposedError(emitErr))
|
|
398
|
-
throw emitErr;
|
|
399
|
-
}
|
|
400
|
-
})
|
|
401
|
-
.catch(async (err) => {
|
|
402
|
-
if (options?.abortSignal?.aborted) {
|
|
403
|
-
await client.disconnect().catch(() => undefined);
|
|
404
|
-
mcpServerStates.delete(serverName);
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
const s = mcpServerStates.get(serverName);
|
|
408
|
-
if (s)
|
|
409
|
-
s.error = err instanceof Error ? err.message : String(err);
|
|
410
|
-
this.buses.getLogBus()?.warn('MCP server discovery failed', {
|
|
411
|
-
serverName,
|
|
412
|
-
error: err instanceof Error ? err.message : String(err),
|
|
413
|
-
});
|
|
414
|
-
try {
|
|
415
|
-
this.buses.emitTelemetry({
|
|
416
|
-
type: 'mcp-server-discovery-failed',
|
|
417
|
-
timestamp: this.clock.now(),
|
|
418
|
-
agentId,
|
|
419
|
-
serverName,
|
|
420
|
-
durationMs: this.clock.now().getTime() - startTime,
|
|
421
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
catch (emitErr) {
|
|
425
|
-
if (!isDisposedError(emitErr))
|
|
426
|
-
throw emitErr;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
})
|
|
430
|
-
.finally(() => {
|
|
431
|
-
const s = mcpServerStates.get(serverName);
|
|
432
|
-
if (s)
|
|
433
|
-
s.settled = true;
|
|
434
|
-
});
|
|
435
|
-
mcpServerStates.set(serverName, { client, ready, settled: false, tools: {} });
|
|
422
|
+
// Place the entry first so runMcpDiscovery's epoch-gated closures
|
|
423
|
+
// see a matching epoch on first lookup. The promise chain only runs
|
|
424
|
+
// after the synchronous `set` and `ready` assignment complete.
|
|
425
|
+
const entry = {
|
|
426
|
+
client,
|
|
427
|
+
epoch: 0,
|
|
428
|
+
ready: Promise.resolve(),
|
|
429
|
+
settled: false,
|
|
430
|
+
tools: {},
|
|
431
|
+
};
|
|
432
|
+
mcpServerStates.set(serverName, entry);
|
|
433
|
+
entry.ready = this.runMcpDiscovery(agentId, serverName, client, mcpServerStates, 0, options?.abortSignal);
|
|
436
434
|
}
|
|
437
435
|
}
|
|
438
436
|
const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools) : {};
|
|
@@ -452,6 +450,13 @@ export class MastraHarness {
|
|
|
452
450
|
const localFs = new LocalFilesystem({ basePath: projectRoot, allowedPaths });
|
|
453
451
|
const sandbox = new LocalSandbox({
|
|
454
452
|
workingDirectory: projectRoot,
|
|
453
|
+
// Forward the parent process env so spawned commands see HOME,
|
|
454
|
+
// locale, etc. (LocalSandbox's default forwards only PATH, which
|
|
455
|
+
// breaks tools like `sf org list` that resolve config from HOME).
|
|
456
|
+
// TODO: filter sensitive vars (e.g. ANTHROPIC_*, AWS creds) here
|
|
457
|
+
// if the harness ever runs alongside secrets it shouldn't leak
|
|
458
|
+
// into spawned tool subprocesses.
|
|
459
|
+
env: process.env,
|
|
455
460
|
});
|
|
456
461
|
mastraWorkspace = new Workspace({
|
|
457
462
|
filesystem: localFs,
|
|
@@ -594,6 +599,87 @@ export class MastraHarness {
|
|
|
594
599
|
}
|
|
595
600
|
return results;
|
|
596
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Eager implementation of {@link AgentHarness.reconnectMcpServer}: calls
|
|
604
|
+
* Mastra's `MCPClient.reconnectServer(name)` primitive, supersedes any
|
|
605
|
+
* in-flight discovery (replace-ready semantics — latest call wins), and
|
|
606
|
+
* awaits the new discovery before resolving.
|
|
607
|
+
*
|
|
608
|
+
* Programming errors (unknown agent / unknown server / disabled) reject.
|
|
609
|
+
* Transport / discovery failures are recorded on the per-server state
|
|
610
|
+
* (visible via {@link getMcpServerInfo}) and a `mcp-server-discovery-*`
|
|
611
|
+
* telemetry pair fires; the returned promise still resolves.
|
|
612
|
+
*/
|
|
613
|
+
async reconnectMcpServer(agentId, serverName) {
|
|
614
|
+
const state = this.getAgentStateOrThrow(agentId);
|
|
615
|
+
const serverState = state.mcpServers.get(serverName);
|
|
616
|
+
if (!serverState) {
|
|
617
|
+
const disabled = state.mcpConfig?.[serverName]?.enabled === false;
|
|
618
|
+
throw new AgentSDKError(disabled
|
|
619
|
+
? `MCP server "${serverName}" is disabled on agent "${agentId}"; reconnect is not valid for disabled servers`
|
|
620
|
+
: `MCP server "${serverName}" is not configured on agent "${agentId}"`, disabled ? AgentSDKErrorType.MCP_SERVER_DISABLED : AgentSDKErrorType.MCP_SERVER_NOT_FOUND);
|
|
621
|
+
}
|
|
622
|
+
// Replace-ready: bump the epoch first so any in-flight discovery from a
|
|
623
|
+
// previous epoch will drop its writes when its `currentEntry()` lookup fails.
|
|
624
|
+
// Then reset visible state for the fresh attempt.
|
|
625
|
+
const epoch = serverState.epoch + 1;
|
|
626
|
+
serverState.epoch = epoch;
|
|
627
|
+
serverState.error = undefined;
|
|
628
|
+
serverState.tools = {};
|
|
629
|
+
serverState.settled = false;
|
|
630
|
+
// Emit `started` BEFORE awaiting the transport reconnect so subscribers
|
|
631
|
+
// see started → (completed | failed) timestamps in chronological order.
|
|
632
|
+
// The previous shape had `started` emitted from the catch block AFTER
|
|
633
|
+
// `reconnectServer()` had already thrown — making the started timestamp
|
|
634
|
+
// appear later than the failure event.
|
|
635
|
+
const startTime = this.clock.now().getTime();
|
|
636
|
+
this.buses.emitTelemetry({
|
|
637
|
+
type: 'mcp-server-discovery-started',
|
|
638
|
+
timestamp: this.clock.now(),
|
|
639
|
+
agentId,
|
|
640
|
+
serverName,
|
|
641
|
+
});
|
|
642
|
+
// Transport-level reconnect can throw (stdio spawn failure, remote handshake
|
|
643
|
+
// failure). Record on serverState and emit only `failed` (not started+failed
|
|
644
|
+
// — `started` already fired above). This keeps the public contract
|
|
645
|
+
// ("rejects only on programming errors") honest.
|
|
646
|
+
try {
|
|
647
|
+
await serverState.client.reconnectServer(serverName);
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
// Drop telemetry + state writes if a newer epoch has superseded us.
|
|
651
|
+
if (state.mcpServers.get(serverName)?.epoch !== epoch)
|
|
652
|
+
return;
|
|
653
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
654
|
+
serverState.error = message;
|
|
655
|
+
serverState.settled = true;
|
|
656
|
+
this.buses.getLogBus()?.warn('MCP server reconnect failed', {
|
|
657
|
+
serverName,
|
|
658
|
+
error: message,
|
|
659
|
+
});
|
|
660
|
+
try {
|
|
661
|
+
this.buses.emitTelemetry({
|
|
662
|
+
type: 'mcp-server-discovery-failed',
|
|
663
|
+
timestamp: this.clock.now(),
|
|
664
|
+
agentId,
|
|
665
|
+
serverName,
|
|
666
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
667
|
+
error: err instanceof Error ? err : new Error(message),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (emitErr) {
|
|
671
|
+
if (!isDisposedError(emitErr))
|
|
672
|
+
throw emitErr;
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
// Pass the existing startTime so runMcpDiscovery does NOT emit its own
|
|
677
|
+
// `started` (we already did) and the eventual completed/failed event's
|
|
678
|
+
// durationMs covers the full reconnect+discovery span — one `started`,
|
|
679
|
+
// one terminal event per public reconnect call.
|
|
680
|
+
serverState.ready = this.runMcpDiscovery(agentId, serverName, serverState.client, state.mcpServers, epoch, undefined, startTime);
|
|
681
|
+
await serverState.ready;
|
|
682
|
+
}
|
|
597
683
|
// ── Thread (Chat Session) Lifecycle ──────────────────────────────
|
|
598
684
|
/**
|
|
599
685
|
* @requirements
|
|
@@ -673,7 +759,12 @@ export class MastraHarness {
|
|
|
673
759
|
this.getAgentStateOrThrow(agentId);
|
|
674
760
|
const messages = await this.getMessages(agentId, threadId);
|
|
675
761
|
if (messages.length === 0) {
|
|
676
|
-
|
|
762
|
+
const newThreadId = await this.createThread(agentId);
|
|
763
|
+
// Empty source still gets compacted away — leaving it attached
|
|
764
|
+
// would be a leak. Mirrors the non-empty path's destroyThread
|
|
765
|
+
// call below; both paths exit with the source detached.
|
|
766
|
+
await this.destroyThreadBestEffort(agentId, threadId);
|
|
767
|
+
return newThreadId;
|
|
677
768
|
}
|
|
678
769
|
const transcript = messages
|
|
679
770
|
.map((m) => {
|
|
@@ -684,7 +775,7 @@ export class MastraHarness {
|
|
|
684
775
|
let newThreadId;
|
|
685
776
|
try {
|
|
686
777
|
const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
|
|
687
|
-
? await this.summarize(agentId,
|
|
778
|
+
? await this.summarize(agentId, buildSummaryPrompt(transcript))
|
|
688
779
|
: await this.summarizeInChunks(agentId, transcript);
|
|
689
780
|
newThreadId = await this.createThread(agentId);
|
|
690
781
|
const userTs = this.clock.now();
|
|
@@ -710,9 +801,26 @@ export class MastraHarness {
|
|
|
710
801
|
// Source-thread cleanup is best-effort. The user's data is already
|
|
711
802
|
// safely seeded into newThreadId; a failed delete leaves an orphan
|
|
712
803
|
// but does not invalidate the compaction outcome.
|
|
713
|
-
await this.
|
|
804
|
+
await this.destroyThreadBestEffort(agentId, threadId);
|
|
714
805
|
return newThreadId;
|
|
715
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* Best-effort wrapper around `destroyThread`: swallows the error so the
|
|
809
|
+
* caller's outer flow isn't aborted by a teardown blip, but logs the
|
|
810
|
+
* failure at warn level so a future regression in `destroyThread` is
|
|
811
|
+
* still observable. Used by `compactThread` on both the empty-source and
|
|
812
|
+
* non-empty paths.
|
|
813
|
+
*/
|
|
814
|
+
async destroyThreadBestEffort(agentId, threadId) {
|
|
815
|
+
try {
|
|
816
|
+
await this.destroyThread(agentId, threadId);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
this.buses
|
|
820
|
+
.getLogBus()
|
|
821
|
+
?.warn(`Best-effort destroyThread failed during compactThread for thread "${threadId}" on agent "${agentId}": ${getErrorMessage(err)}`, { agentId, threadId }, err instanceof Error ? err : new Error(String(err)));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
716
824
|
// ── Core Messaging ───────────────────────────────────────────────
|
|
717
825
|
/**
|
|
718
826
|
* @requirements
|
|
@@ -730,6 +838,7 @@ export class MastraHarness {
|
|
|
730
838
|
thread: threadId,
|
|
731
839
|
resource: this.agentResourceId(agentId),
|
|
732
840
|
},
|
|
841
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
733
842
|
maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
734
843
|
abortSignal: options?.abortSignal,
|
|
735
844
|
requireToolApproval: options?.requireToolApproval,
|
|
@@ -767,6 +876,7 @@ export class MastraHarness {
|
|
|
767
876
|
thread: threadId,
|
|
768
877
|
resource: this.agentResourceId(agentId),
|
|
769
878
|
},
|
|
879
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
770
880
|
});
|
|
771
881
|
return this.toChatStreamResult(agentState, threadId, output);
|
|
772
882
|
}
|
|
@@ -804,6 +914,7 @@ export class MastraHarness {
|
|
|
804
914
|
thread: threadId,
|
|
805
915
|
resource: this.agentResourceId(agentId),
|
|
806
916
|
},
|
|
917
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
807
918
|
});
|
|
808
919
|
return this.toChatStreamResult(agentState, threadId, output);
|
|
809
920
|
}
|
|
@@ -835,6 +946,7 @@ export class MastraHarness {
|
|
|
835
946
|
thread: threadId,
|
|
836
947
|
resource: this.agentResourceId(agentId),
|
|
837
948
|
},
|
|
949
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
838
950
|
});
|
|
839
951
|
return this.toChatStreamResult(agentState, threadId, output);
|
|
840
952
|
}
|
|
@@ -898,15 +1010,14 @@ export class MastraHarness {
|
|
|
898
1010
|
*/
|
|
899
1011
|
async summarize(agentId, prompt) {
|
|
900
1012
|
const agentState = this.getAgentStateOrThrow(agentId);
|
|
1013
|
+
// No `requestContext` here on purpose: this is a thread-less one-shot
|
|
1014
|
+
// generate, so per-thread input processors (e.g. ToolSearchProcessor)
|
|
1015
|
+
// harmlessly fall back to the `'default'` bucket. The "every streaming
|
|
1016
|
+
// entry-point passes a RequestContext" invariant in ARCHITECTURE.md
|
|
1017
|
+
// applies to thread-bound entry points only.
|
|
901
1018
|
const result = await agentState.agent.generate(prompt, { maxSteps: 1 });
|
|
902
1019
|
return result.text;
|
|
903
1020
|
}
|
|
904
|
-
buildSummaryPrompt(transcript) {
|
|
905
|
-
return ('Summarize the following conversation into a concise context summary. ' +
|
|
906
|
-
'Preserve key decisions, facts, and action items. ' +
|
|
907
|
-
'Write in third person as a factual record.\n\n' +
|
|
908
|
-
transcript);
|
|
909
|
-
}
|
|
910
1021
|
/**
|
|
911
1022
|
* Two-pass summarization for transcripts above the char budget. The
|
|
912
1023
|
* transcript is split into roughly-equal chunks each ≤ MAX_TRANSCRIPT_CHARS,
|
|
@@ -917,7 +1028,7 @@ export class MastraHarness {
|
|
|
917
1028
|
const chunks = this.splitIntoChunks(transcript, MAX_TRANSCRIPT_CHARS);
|
|
918
1029
|
const chunkSummaries = [];
|
|
919
1030
|
for (const chunk of chunks) {
|
|
920
|
-
const summary = await this.summarize(agentId,
|
|
1031
|
+
const summary = await this.summarize(agentId, buildSummaryPrompt(chunk));
|
|
921
1032
|
chunkSummaries.push(summary);
|
|
922
1033
|
}
|
|
923
1034
|
const merged = chunkSummaries.map((s, i) => `Part ${i + 1}:\n${s}`).join('\n\n');
|
|
@@ -925,7 +1036,7 @@ export class MastraHarness {
|
|
|
925
1036
|
// long chat could in principle still exceed budget. If so, recurse;
|
|
926
1037
|
// otherwise final-summarize.
|
|
927
1038
|
return merged.length <= MAX_TRANSCRIPT_CHARS
|
|
928
|
-
? this.summarize(agentId,
|
|
1039
|
+
? this.summarize(agentId, buildSummaryPrompt(merged))
|
|
929
1040
|
: this.summarizeInChunks(agentId, merged);
|
|
930
1041
|
}
|
|
931
1042
|
/**
|
|
@@ -969,6 +1080,117 @@ export class MastraHarness {
|
|
|
969
1080
|
}
|
|
970
1081
|
return state;
|
|
971
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Runs MCP discovery for one server: emits the
|
|
1085
|
+
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
|
1086
|
+
* triple and writes results into the corresponding entry in `serverStates`.
|
|
1087
|
+
*
|
|
1088
|
+
* The caller MUST `set()` the entry into `serverStates` either before
|
|
1089
|
+
* calling this helper (the `createAgent` path constructs the entry
|
|
1090
|
+
* synchronously after this returns the promise) or before any await
|
|
1091
|
+
* boundary in the helper's chain — the chain reads back via
|
|
1092
|
+
* `serverStates.get(serverName)!` to write `tools` / `error`. Both call
|
|
1093
|
+
* sites today put the entry in place before the first microtask boundary.
|
|
1094
|
+
*
|
|
1095
|
+
* Errors observed during discovery are recorded on the entry's `error`
|
|
1096
|
+
* field; the returned promise resolves either way (it only rejects when
|
|
1097
|
+
* an aborted operation throws after we have already cleaned up). The
|
|
1098
|
+
* `settled` flag is pinned by `.finally()` so `getMcpServerInfo()` can
|
|
1099
|
+
* tell `Connecting` from `Connected`/`Error`.
|
|
1100
|
+
*/
|
|
1101
|
+
runMcpDiscovery(agentId, serverName, client, serverStates, epoch, abortSignal,
|
|
1102
|
+
// When set, reuse the caller's startTime and skip emitting `started` —
|
|
1103
|
+
// the caller (e.g. reconnectMcpServer) has already emitted it. The
|
|
1104
|
+
// resulting completed/failed event still uses the caller's startTime
|
|
1105
|
+
// for `durationMs`, so subscribers see one started → terminal pair.
|
|
1106
|
+
existingStartTime) {
|
|
1107
|
+
const startTime = existingStartTime ?? this.clock.now().getTime();
|
|
1108
|
+
if (existingStartTime === undefined) {
|
|
1109
|
+
this.buses.emitTelemetry({
|
|
1110
|
+
type: 'mcp-server-discovery-started',
|
|
1111
|
+
timestamp: this.clock.now(),
|
|
1112
|
+
agentId,
|
|
1113
|
+
serverName,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
// Returns the entry if and only if it exists AND still belongs to this
|
|
1117
|
+
// epoch. If a fresher discovery has bumped the epoch, this discovery's
|
|
1118
|
+
// writes / telemetry are dropped to prevent orphan-write races with
|
|
1119
|
+
// reconnect's replace-ready semantics.
|
|
1120
|
+
const currentEntry = () => {
|
|
1121
|
+
const s = serverStates.get(serverName);
|
|
1122
|
+
return s && s.epoch === epoch ? s : undefined;
|
|
1123
|
+
};
|
|
1124
|
+
return makeAbortable(client.listToolsetsWithErrors(), abortSignal)
|
|
1125
|
+
.then(({ toolsets, errors }) => {
|
|
1126
|
+
const serverError = errors[serverName];
|
|
1127
|
+
if (serverError) {
|
|
1128
|
+
throw new Error(serverError);
|
|
1129
|
+
}
|
|
1130
|
+
const tools = toolsets[serverName] ?? {};
|
|
1131
|
+
const namespacedTools = {};
|
|
1132
|
+
for (const [toolName, toolConfig] of Object.entries(tools)) {
|
|
1133
|
+
namespacedTools[`${serverName}_${toolName}`] = toolConfig;
|
|
1134
|
+
}
|
|
1135
|
+
const s = currentEntry();
|
|
1136
|
+
if (!s)
|
|
1137
|
+
return; // superseded — drop write + telemetry
|
|
1138
|
+
s.tools = namespacedTools;
|
|
1139
|
+
// listToolsetsWithErrors() runs in the background and can resolve
|
|
1140
|
+
// after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
|
|
1141
|
+
// so a real listener exception still surfaces.
|
|
1142
|
+
try {
|
|
1143
|
+
this.buses.emitTelemetry({
|
|
1144
|
+
type: 'mcp-server-discovery-completed',
|
|
1145
|
+
timestamp: this.clock.now(),
|
|
1146
|
+
agentId,
|
|
1147
|
+
serverName,
|
|
1148
|
+
toolCount: Object.keys(namespacedTools).length,
|
|
1149
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
catch (emitErr) {
|
|
1153
|
+
if (!isDisposedError(emitErr))
|
|
1154
|
+
throw emitErr;
|
|
1155
|
+
}
|
|
1156
|
+
})
|
|
1157
|
+
.catch(async (err) => {
|
|
1158
|
+
if (abortSignal?.aborted) {
|
|
1159
|
+
await client.disconnect().catch(() => undefined);
|
|
1160
|
+
if (currentEntry())
|
|
1161
|
+
serverStates.delete(serverName);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
const s = currentEntry();
|
|
1165
|
+
if (!s)
|
|
1166
|
+
return; // superseded — drop write + telemetry
|
|
1167
|
+
s.error = err instanceof Error ? err.message : String(err);
|
|
1168
|
+
this.buses.getLogBus()?.warn('MCP server discovery failed', {
|
|
1169
|
+
serverName,
|
|
1170
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
this.buses.emitTelemetry({
|
|
1174
|
+
type: 'mcp-server-discovery-failed',
|
|
1175
|
+
timestamp: this.clock.now(),
|
|
1176
|
+
agentId,
|
|
1177
|
+
serverName,
|
|
1178
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
1179
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
catch (emitErr) {
|
|
1183
|
+
if (!isDisposedError(emitErr))
|
|
1184
|
+
throw emitErr;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
})
|
|
1188
|
+
.finally(() => {
|
|
1189
|
+
const s = currentEntry();
|
|
1190
|
+
if (s)
|
|
1191
|
+
s.settled = true;
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
972
1194
|
toChatStreamResult(agentState, threadId, output) {
|
|
973
1195
|
// Entries are keyed by threadId so each thread holds at most one entry.
|
|
974
1196
|
// Stale entries are overwritten here on the next stream; explicit cleanup
|
|
@@ -978,7 +1200,7 @@ export class MastraHarness {
|
|
|
978
1200
|
agentState.activeRuns.set(threadId, { runId: output.runId, fullStream: output.fullStream });
|
|
979
1201
|
const adapter = new MastraEventAdapter(this.buses.getLogBus());
|
|
980
1202
|
return {
|
|
981
|
-
eventStream: this.createEventStream(output.fullStream, adapter),
|
|
1203
|
+
eventStream: this.createEventStream(output.fullStream, adapter, agentState),
|
|
982
1204
|
textStream: this.readableStreamToAsyncGenerator(output.textStream),
|
|
983
1205
|
};
|
|
984
1206
|
}
|
|
@@ -993,11 +1215,41 @@ export class MastraHarness {
|
|
|
993
1215
|
agentResourceId(agentId) {
|
|
994
1216
|
return `AGENT_INSTANCE:${agentId}`;
|
|
995
1217
|
}
|
|
996
|
-
|
|
1218
|
+
/**
|
|
1219
|
+
* Build a `RequestContext` carrying the thread + resource ids under the
|
|
1220
|
+
* reserved keys Mastra reads when input processors (e.g. `ToolSearchProcessor`,
|
|
1221
|
+
* `SkillSearchProcessor`) call `requestContext.get(MASTRA_THREAD_ID_KEY)`.
|
|
1222
|
+
*
|
|
1223
|
+
* `memory: { thread, resource }` populates Mastra's memory layer but is NOT
|
|
1224
|
+
* propagated into the request context, so processors that key per-thread
|
|
1225
|
+
* state by `MASTRA_THREAD_ID_KEY` would otherwise see `undefined` and fall
|
|
1226
|
+
* back to the `'default'` bucket — folding every consumer thread into one.
|
|
1227
|
+
* Surfacing the ids here lets `ToolSearchHandle.clearState(threadId)`
|
|
1228
|
+
* actually match what the processor stored.
|
|
1229
|
+
*/
|
|
1230
|
+
buildRequestContext(agentId, threadId) {
|
|
1231
|
+
const ctx = new RequestContext();
|
|
1232
|
+
ctx.set(MASTRA_THREAD_ID_KEY, threadId);
|
|
1233
|
+
ctx.set(MASTRA_RESOURCE_ID_KEY, this.agentResourceId(agentId));
|
|
1234
|
+
return ctx;
|
|
1235
|
+
}
|
|
1236
|
+
async *createEventStream(fullStream, adapter, agentState) {
|
|
997
1237
|
for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
|
|
998
1238
|
const event = adapter.toChatEvent(chunk);
|
|
999
1239
|
if (!event)
|
|
1000
1240
|
continue;
|
|
1241
|
+
if (isEnrichableToolEvent(event)) {
|
|
1242
|
+
const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
|
|
1243
|
+
const metadata = lookupMcpToolMetadata(agentState, toolName);
|
|
1244
|
+
if (metadata) {
|
|
1245
|
+
yield {
|
|
1246
|
+
...event,
|
|
1247
|
+
serverName: metadata.serverName,
|
|
1248
|
+
...(metadata.annotations ? { annotations: metadata.annotations } : {}),
|
|
1249
|
+
};
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1001
1253
|
yield event;
|
|
1002
1254
|
}
|
|
1003
1255
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/sfdx-agent-harness-mastra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"@mastra/mcp": "^1.8.0",
|
|
34
34
|
"@mastra/memory": "^1.19.0",
|
|
35
35
|
"@mastra/schema-compat": "^1.2.10",
|
|
36
|
-
"@salesforce/agentic-common": "0.
|
|
37
|
-
"@salesforce/llm-gateway-sdk": "0.
|
|
36
|
+
"@salesforce/agentic-common": "0.5.0",
|
|
37
|
+
"@salesforce/llm-gateway-sdk": "0.6.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
40
|
+
"@salesforce/sfdx-agent-sdk": "0.9.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@eslint/js": "^10.0.1",
|
|
44
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
44
|
+
"@salesforce/sfdx-agent-sdk": "0.9.0",
|
|
45
45
|
"@types/node": "^22.19.17",
|
|
46
46
|
"@vitest/coverage-istanbul": "^4.1.7",
|
|
47
47
|
"@vitest/eslint-plugin": "^1.6.17",
|