@salesforce/sfdx-agent-harness-mastra 0.1.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/LICENSE.txt +21 -0
- package/README.md +66 -0
- package/dist/default-model-config-factory.d.ts +11 -0
- package/dist/default-model-config-factory.js +18 -0
- package/dist/default-model-config-factory.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/llmg-express-model-config-factory.d.ts +23 -0
- package/dist/llmg-express-model-config-factory.js +28 -0
- package/dist/llmg-express-model-config-factory.js.map +1 -0
- package/dist/mastra-event-adapter.d.ts +73 -0
- package/dist/mastra-event-adapter.js +204 -0
- package/dist/mastra-event-adapter.js.map +1 -0
- package/dist/mastra-harness-factory.d.ts +9 -0
- package/dist/mastra-harness-factory.js +17 -0
- package/dist/mastra-harness-factory.js.map +1 -0
- package/dist/mastra-harness.d.ts +292 -0
- package/dist/mastra-harness.js +838 -0
- package/dist/mastra-harness.js.map +1 -0
- package/dist/mastra-message-mapper.d.ts +42 -0
- package/dist/mastra-message-mapper.js +195 -0
- package/dist/mastra-message-mapper.js.map +1 -0
- package/dist/mastra-tool-mapper.d.ts +29 -0
- package/dist/mastra-tool-mapper.js +55 -0
- package/dist/mastra-tool-mapper.js.map +1 -0
- package/dist/mcp-config-mapper.d.ts +20 -0
- package/dist/mcp-config-mapper.js +73 -0
- package/dist/mcp-config-mapper.js.map +1 -0
- package/dist/model-config-factory.d.ts +16 -0
- package/dist/model-config-factory.js +6 -0
- package/dist/model-config-factory.js.map +1 -0
- package/dist/salesforce-llm-gateway.d.ts +29 -0
- package/dist/salesforce-llm-gateway.js +266 -0
- package/dist/salesforce-llm-gateway.js.map +1 -0
- package/dist/test/tsconfig.tsbuildinfo +1 -0
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +29 -0
- package/dist/utils.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026, Salesforce, Inc. All rights reserved.
|
|
3
|
+
* See LICENSE.txt for license terms.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Agent } from '@mastra/core/agent';
|
|
7
|
+
import { Mastra } from '@mastra/core/mastra';
|
|
8
|
+
import { Memory } from '@mastra/memory';
|
|
9
|
+
import { LibSQLStore } from '@mastra/libsql';
|
|
10
|
+
import { MCPClient } from '@mastra/mcp';
|
|
11
|
+
import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, HarnessBusOwner, McpServerStatus, } from '@salesforce/sfdx-agent-sdk';
|
|
12
|
+
import { MastraEventAdapter } from './mastra-event-adapter.js';
|
|
13
|
+
import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
|
|
14
|
+
import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
|
|
15
|
+
import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
|
|
16
|
+
import { DefaultMastraModelConfigFactory } from './default-model-config-factory.js';
|
|
17
|
+
import { Clock, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
|
|
18
|
+
import { makeAbortable } from './utils.js';
|
|
19
|
+
import { Workspace, LocalFilesystem, LocalSandbox, WORKSPACE_TOOLS, WORKSPACE_TOOLS_PREFIX, } from '@mastra/core/workspace';
|
|
20
|
+
/**
|
|
21
|
+
* The literal prefix Mastra prepends to every workspace tool name (e.g.
|
|
22
|
+
* `mastra_workspace_read_file`). Stripping this prefix yields the
|
|
23
|
+
* provider-neutral name we expose to consumers.
|
|
24
|
+
*/
|
|
25
|
+
const MASTRA_PREFIX = `${WORKSPACE_TOOLS_PREFIX}_`;
|
|
26
|
+
/**
|
|
27
|
+
* Every Mastra workspace tool, flattened from `WORKSPACE_TOOLS`. Used to
|
|
28
|
+
* auto-generate the `Workspace({ tools })` config so every built-in is
|
|
29
|
+
* registered under its prefix-stripped name without per-tool maintenance.
|
|
30
|
+
*/
|
|
31
|
+
const ALL_MASTRA_WORKSPACE_TOOL_NAMES = [
|
|
32
|
+
...Object.values(WORKSPACE_TOOLS.FILESYSTEM),
|
|
33
|
+
...Object.values(WORKSPACE_TOOLS.SANDBOX),
|
|
34
|
+
...Object.values(WORKSPACE_TOOLS.SEARCH),
|
|
35
|
+
...Object.values(WORKSPACE_TOOLS.LSP),
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Strip Mastra's `mastra_workspace_` prefix from a tool name, returning the
|
|
39
|
+
* sanitized public name. Non-prefixed names (custom tools, MCP tools) pass
|
|
40
|
+
* through unchanged.
|
|
41
|
+
*/
|
|
42
|
+
function stripMastraWorkspacePrefix(name) {
|
|
43
|
+
return name.startsWith(MASTRA_PREFIX) ? name.slice(MASTRA_PREFIX.length) : name;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Predicate used by the MCP discovery `.then` / `.catch` chains to distinguish a benign
|
|
47
|
+
* post-shutdown `DISPOSED` error (which we swallow because the bus owner was torn down before
|
|
48
|
+
* the background promise resolved) from any other error that should propagate.
|
|
49
|
+
*/
|
|
50
|
+
function isDisposedError(err) {
|
|
51
|
+
return err instanceof AgentSDKError && err.type === AgentSDKErrorType.DISPOSED;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Mastra-backed implementation of {@link AgentHarness}.
|
|
55
|
+
*
|
|
56
|
+
* ### Mastra Concepts
|
|
57
|
+
*
|
|
58
|
+
* | Our Concept | Mastra Concept |
|
|
59
|
+
* | ---------------------- | ------------------------------------------------------- |
|
|
60
|
+
* | `initialize()` | `new Mastra({ storage, gateways })` |
|
|
61
|
+
* | `createAgent()` | `new Agent({ id, instructions, model, tools, memory })` |
|
|
62
|
+
* | `createThread()` | `memory.createThread({ threadId, resourceId })` |
|
|
63
|
+
* | `stream()` | `agent.stream(message, { memory: { thread, resource } })`|
|
|
64
|
+
* | `approveToolCall()` | `agent.approveToolCall({ toolCallId, runId, threadId })` |
|
|
65
|
+
* | `getMessages()` | `memory.recall({ threadId, perPage, page })` |
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* Production memory strategy for v1.
|
|
69
|
+
*
|
|
70
|
+
* Memory is configured once at the MastraHarness level and shared by all agents.
|
|
71
|
+
* This is an intentional design choice: Mastra's Memory is an agent-level
|
|
72
|
+
* construct, not a session-level one. Per-thread memory configuration is not
|
|
73
|
+
* natively supported through Mastra's public API. Data isolation between
|
|
74
|
+
* sessions is handled by Mastra's threadId/resourceId scoping, not by
|
|
75
|
+
* separate Memory instances.
|
|
76
|
+
*
|
|
77
|
+
* Configuration:
|
|
78
|
+
* - lastMessages: 40 — sliding window of recent messages included in context.
|
|
79
|
+
* - workingMemory: enabled, scope 'thread' — structured state persists across
|
|
80
|
+
* turns within a single chat session only. We use thread scope (not Mastra's
|
|
81
|
+
* default resource scope) so each conversation is isolated: resource-scoped
|
|
82
|
+
* working memory would be shared across all threads for the same agent.
|
|
83
|
+
* - semanticRecall: disabled — no embedding provider or vector store is
|
|
84
|
+
* configured for v1. RAG-based retrieval of past messages can be enabled
|
|
85
|
+
* later by adding an embedder and vector store to the Memory constructor.
|
|
86
|
+
* - observationalMemory: disabled (default) — Mastra's Observational Memory
|
|
87
|
+
* runs background Observer and Reflector LLM calls to compress long context.
|
|
88
|
+
* Those calls consume model requests beyond the user's visible conversation;
|
|
89
|
+
* we avoid enabling OM until customers can afford silent background usage or
|
|
90
|
+
* we have dedicated infrastructure budget. See:
|
|
91
|
+
* https://mastra.ai/docs/memory/observational-memory
|
|
92
|
+
* - generateTitle: disabled (default) — Mastra can auto-generate thread titles
|
|
93
|
+
* via an extra LLM call per new thread. We leave it off for the same budget
|
|
94
|
+
* rationale as observational memory; apps can set titles explicitly if needed.
|
|
95
|
+
*
|
|
96
|
+
* This configuration is internal and is NOT exposed through the SDK's public
|
|
97
|
+
* API surface (src/index.ts).
|
|
98
|
+
*/
|
|
99
|
+
const LAST_MESSAGES_WINDOW = 40;
|
|
100
|
+
/**
|
|
101
|
+
* Maximum flattened-transcript length (in characters) sent to the model in a
|
|
102
|
+
* single summarization call. Transcripts above this size are split into
|
|
103
|
+
* roughly-equal chunks, each chunk is summarized separately, and the
|
|
104
|
+
* concatenated chunk-summaries are then summarized into a final summary.
|
|
105
|
+
*
|
|
106
|
+
* Rationale: ~400k characters maps to ~100k tokens at a conservative 4:1
|
|
107
|
+
* chars-per-token ratio for English plus JSON tool args. That leaves headroom
|
|
108
|
+
* under typical 200k-token gateway model context windows for the prompt
|
|
109
|
+
* scaffolding and the model's own response. The ratio is rough but errs
|
|
110
|
+
* toward "fits" — a tokenizer can be swapped in later behind the same
|
|
111
|
+
* constant if precise budgeting becomes necessary.
|
|
112
|
+
*/
|
|
113
|
+
const MAX_TRANSCRIPT_CHARS = 400_000;
|
|
114
|
+
/**
|
|
115
|
+
* Default timeout (in milliseconds) for MCP server connections and requests.
|
|
116
|
+
* 10 minutes accommodates first-run `npx` installs and long-running operations
|
|
117
|
+
* like deploys. Per-server `timeout` in MCPServerConfig still overrides this.
|
|
118
|
+
*/
|
|
119
|
+
const DEFAULT_MCP_TIMEOUT_MS = 600_000;
|
|
120
|
+
export class MastraHarness {
|
|
121
|
+
storageRootFolder;
|
|
122
|
+
modelConfigFactory;
|
|
123
|
+
clock;
|
|
124
|
+
idGenerator;
|
|
125
|
+
harnessId = 'mastra';
|
|
126
|
+
protocolVersion = 1;
|
|
127
|
+
/**
|
|
128
|
+
* Registry of agentId → Mastra state. Holds per-agent MCPClient instances
|
|
129
|
+
* so each agent has isolated server connections; sharing across agents
|
|
130
|
+
* would mix process state.
|
|
131
|
+
*/
|
|
132
|
+
activeAgents = new Map();
|
|
133
|
+
storage;
|
|
134
|
+
memory;
|
|
135
|
+
mastra;
|
|
136
|
+
buses = new HarnessBusOwner();
|
|
137
|
+
constructor(storageRootFolder, modelConfigFactory = new DefaultMastraModelConfigFactory(), clock = new RealClock(), idGenerator = new UUIDGenerator()) {
|
|
138
|
+
this.storageRootFolder = storageRootFolder;
|
|
139
|
+
this.modelConfigFactory = modelConfigFactory;
|
|
140
|
+
this.clock = clock;
|
|
141
|
+
this.idGenerator = idGenerator;
|
|
142
|
+
this.storage = new LibSQLStore({
|
|
143
|
+
id: 'mastra-storage',
|
|
144
|
+
url: `file:${this.storageRootFolder}/mastra.db`,
|
|
145
|
+
});
|
|
146
|
+
this.memory = new Memory({
|
|
147
|
+
storage: this.storage,
|
|
148
|
+
options: {
|
|
149
|
+
lastMessages: LAST_MESSAGES_WINDOW,
|
|
150
|
+
semanticRecall: false,
|
|
151
|
+
workingMemory: {
|
|
152
|
+
enabled: true,
|
|
153
|
+
scope: 'thread',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
this.mastra = new Mastra({
|
|
158
|
+
storage: this.storage,
|
|
159
|
+
memory: { shared: this.memory },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* @requirements
|
|
164
|
+
* - MUST disconnect the mcpClient for every active agent before clearing state.
|
|
165
|
+
* - MUST clear the `activeAgents` map.
|
|
166
|
+
* - MUST shut down the global `this.mastra` orchestrator.
|
|
167
|
+
*/
|
|
168
|
+
async shutdown() {
|
|
169
|
+
await Promise.all([...this.activeAgents.values()].map(async (state) => {
|
|
170
|
+
await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
|
|
171
|
+
await s.ready.catch(() => undefined);
|
|
172
|
+
await s.client.disconnect();
|
|
173
|
+
}));
|
|
174
|
+
}));
|
|
175
|
+
this.activeAgents.clear();
|
|
176
|
+
await this.mastra?.shutdown();
|
|
177
|
+
this.buses.dispose();
|
|
178
|
+
}
|
|
179
|
+
onTelemetry(callback) {
|
|
180
|
+
return this.buses.onTelemetry(callback);
|
|
181
|
+
}
|
|
182
|
+
onLog(callback) {
|
|
183
|
+
return this.buses.onLog(callback);
|
|
184
|
+
}
|
|
185
|
+
// ── Agent Lifecycle ──────────────────────────────────────────────
|
|
186
|
+
/**
|
|
187
|
+
* @requirements
|
|
188
|
+
* - MUST throw an error if the harness has not been initialized.
|
|
189
|
+
* - MUST throw an error if an agent with the given `config.agentId` is already registered.
|
|
190
|
+
* - MUST instantiate a Mastra `Agent` with instructions, model, and memory.
|
|
191
|
+
* - MUST provide a fallback model string if modelId is absent.
|
|
192
|
+
* - MUST register the agent with the Mastra instance for gateway resolution.
|
|
193
|
+
* - MUST construct a per-agent `MCPClient` if `config.mcpServers` has enabled servers and
|
|
194
|
+
* kick off `listToolsetsWithErrors()` in the background (non-blocking). MCP tools become available
|
|
195
|
+
* once discovery completes; until then the agent operates with custom tools only.
|
|
196
|
+
* - MUST use a tools function (DynamicArgument) so the agent picks up MCP tools dynamically.
|
|
197
|
+
* - MUST disconnect and discard the `MCPClient` if tool discovery fails, to avoid orphaned processes.
|
|
198
|
+
* - MUST respect the `abortSignal` option: check before MCP setup, race listToolsetsWithErrors() against it.
|
|
199
|
+
* - MUST namespace MCP tool names as `${serverName}_${toolName}` to avoid collisions with custom tools.
|
|
200
|
+
* - MUST convert `config.tools` (SDK `ToolDefinition[]`) to Mastra `ToolAction` entries via
|
|
201
|
+
* `mapToolDefinitionsToMastraTools()` and merge with namespaced MCP-discovered tools.
|
|
202
|
+
* - MUST store the original `MCPConfiguration` so getMcpServerInfo can report disabled servers.
|
|
203
|
+
*/
|
|
204
|
+
async createAgent(agentId, projectRoot, llmGatewayClient, config = {}, options) {
|
|
205
|
+
if (this.activeAgents.has(agentId)) {
|
|
206
|
+
throw new Error(`Agent with id "${agentId}" is already registered`);
|
|
207
|
+
}
|
|
208
|
+
// ── MCP setup (non-blocking, per-server) ──────────────────────────
|
|
209
|
+
const mcpServerStates = new Map();
|
|
210
|
+
if (hasEnabledServers(config.mcpServers)) {
|
|
211
|
+
options?.abortSignal?.throwIfAborted();
|
|
212
|
+
const enabledServers = mapToMastraMCPServers(config.mcpServers, config.orgJwt);
|
|
213
|
+
for (const [serverName, serverDef] of Object.entries(enabledServers)) {
|
|
214
|
+
const client = new MCPClient({
|
|
215
|
+
id: `${agentId}:${serverName}`,
|
|
216
|
+
servers: { [serverName]: serverDef },
|
|
217
|
+
// Fallback only — per-server MCPServerConfig.timeout (in serverDef) takes precedence.
|
|
218
|
+
timeout: DEFAULT_MCP_TIMEOUT_MS,
|
|
219
|
+
});
|
|
220
|
+
const startTime = this.clock.now().getTime();
|
|
221
|
+
this.buses.emitTelemetry({
|
|
222
|
+
type: 'mcp-server-discovery-started',
|
|
223
|
+
timestamp: this.clock.now(),
|
|
224
|
+
agentId,
|
|
225
|
+
serverName,
|
|
226
|
+
});
|
|
227
|
+
const ready = makeAbortable(client.listToolsetsWithErrors(), options?.abortSignal)
|
|
228
|
+
.then(({ toolsets, errors }) => {
|
|
229
|
+
const serverError = errors[serverName];
|
|
230
|
+
if (serverError) {
|
|
231
|
+
throw new Error(serverError);
|
|
232
|
+
}
|
|
233
|
+
const tools = toolsets[serverName] ?? {};
|
|
234
|
+
const namespacedTools = {};
|
|
235
|
+
for (const [toolName, toolConfig] of Object.entries(tools)) {
|
|
236
|
+
namespacedTools[`${serverName}_${toolName}`] = toolConfig;
|
|
237
|
+
}
|
|
238
|
+
mcpServerStates.get(serverName).tools = namespacedTools;
|
|
239
|
+
// listToolsetsWithErrors() runs in the background and can resolve
|
|
240
|
+
// after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
|
|
241
|
+
// so a real listener exception still surfaces.
|
|
242
|
+
try {
|
|
243
|
+
this.buses.emitTelemetry({
|
|
244
|
+
type: 'mcp-server-discovery-completed',
|
|
245
|
+
timestamp: this.clock.now(),
|
|
246
|
+
agentId,
|
|
247
|
+
serverName,
|
|
248
|
+
toolCount: Object.keys(namespacedTools).length,
|
|
249
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (emitErr) {
|
|
253
|
+
if (!isDisposedError(emitErr))
|
|
254
|
+
throw emitErr;
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.catch(async (err) => {
|
|
258
|
+
if (options?.abortSignal?.aborted) {
|
|
259
|
+
await client.disconnect().catch(() => undefined);
|
|
260
|
+
mcpServerStates.delete(serverName);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const s = mcpServerStates.get(serverName);
|
|
264
|
+
if (s)
|
|
265
|
+
s.error = err instanceof Error ? err.message : String(err);
|
|
266
|
+
this.buses.getLogBus()?.warn('MCP server discovery failed', {
|
|
267
|
+
serverName,
|
|
268
|
+
error: err instanceof Error ? err.message : String(err),
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
this.buses.emitTelemetry({
|
|
272
|
+
type: 'mcp-server-discovery-failed',
|
|
273
|
+
timestamp: this.clock.now(),
|
|
274
|
+
agentId,
|
|
275
|
+
serverName,
|
|
276
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
277
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (emitErr) {
|
|
281
|
+
if (!isDisposedError(emitErr))
|
|
282
|
+
throw emitErr;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.finally(() => {
|
|
287
|
+
const s = mcpServerStates.get(serverName);
|
|
288
|
+
if (s)
|
|
289
|
+
s.settled = true;
|
|
290
|
+
});
|
|
291
|
+
mcpServerStates.set(serverName, { client, ready, settled: false, tools: {} });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools) : {};
|
|
295
|
+
const { model: resolvedModel } = this.modelConfigFactory.create(llmGatewayClient);
|
|
296
|
+
let mastraWorkspace;
|
|
297
|
+
let agent;
|
|
298
|
+
let mastraAgentAdded = false;
|
|
299
|
+
try {
|
|
300
|
+
const allowedPaths = (config.skills ?? []).filter((s) => path.isAbsolute(s));
|
|
301
|
+
const localFs = new LocalFilesystem({ basePath: projectRoot, allowedPaths });
|
|
302
|
+
const sandbox = new LocalSandbox({
|
|
303
|
+
workingDirectory: projectRoot,
|
|
304
|
+
});
|
|
305
|
+
mastraWorkspace = new Workspace({
|
|
306
|
+
filesystem: localFs,
|
|
307
|
+
skills: config.skills,
|
|
308
|
+
sandbox,
|
|
309
|
+
tools: Object.fromEntries(ALL_MASTRA_WORKSPACE_TOOL_NAMES.map((nativeName) => [
|
|
310
|
+
nativeName,
|
|
311
|
+
{ name: stripMastraWorkspacePrefix(nativeName) },
|
|
312
|
+
])),
|
|
313
|
+
});
|
|
314
|
+
await mastraWorkspace.init();
|
|
315
|
+
agent = new Agent({
|
|
316
|
+
id: agentId,
|
|
317
|
+
name: config.name || agentId,
|
|
318
|
+
instructions: config.instructions || '',
|
|
319
|
+
model: resolvedModel,
|
|
320
|
+
memory: this.memory,
|
|
321
|
+
tools: () => {
|
|
322
|
+
const mcpTools = [...mcpServerStates.values()].reduce((acc, s) => ({ ...acc, ...s.tools }), {});
|
|
323
|
+
return { ...customTools, ...mcpTools };
|
|
324
|
+
},
|
|
325
|
+
workspace: mastraWorkspace,
|
|
326
|
+
});
|
|
327
|
+
this.mastra.addAgent(agent);
|
|
328
|
+
mastraAgentAdded = true;
|
|
329
|
+
this.activeAgents.set(agentId, {
|
|
330
|
+
agent,
|
|
331
|
+
mcpServers: mcpServerStates,
|
|
332
|
+
mcpConfig: config.mcpServers,
|
|
333
|
+
mastraWorkspace,
|
|
334
|
+
activeRuns: new Map(),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (mastraAgentAdded) {
|
|
339
|
+
this.mastra.removeAgent(agentId);
|
|
340
|
+
}
|
|
341
|
+
await mastraWorkspace?.destroy().catch(() => undefined);
|
|
342
|
+
await Promise.allSettled(Array.from(mcpServerStates.values()).map((s) => s.client.disconnect().catch(() => undefined)));
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* @requirements
|
|
348
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
349
|
+
* - MUST disconnect all per-server MCP clients to terminate any spawned processes.
|
|
350
|
+
* - MUST remove the agent from the internal `activeAgents` map.
|
|
351
|
+
*/
|
|
352
|
+
async destroyAgent(agentId) {
|
|
353
|
+
const state = this.getAgentStateOrThrow(agentId);
|
|
354
|
+
await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
|
|
355
|
+
await s.ready.catch(() => undefined);
|
|
356
|
+
await s.client.disconnect();
|
|
357
|
+
}));
|
|
358
|
+
await state.mastraWorkspace?.destroy();
|
|
359
|
+
this.mastra.removeAgent(agentId);
|
|
360
|
+
return this.activeAgents.delete(agentId);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* @requirements
|
|
364
|
+
* - MUST return an array of strings representing the keys currently present in `activeAgents`.
|
|
365
|
+
*/
|
|
366
|
+
async getAgentIds() {
|
|
367
|
+
return Array.from(this.activeAgents.keys());
|
|
368
|
+
}
|
|
369
|
+
getMcpServerInfo(agentId) {
|
|
370
|
+
const state = this.getAgentStateOrThrow(agentId);
|
|
371
|
+
const results = [];
|
|
372
|
+
for (const [name, serverState] of state.mcpServers) {
|
|
373
|
+
if (!serverState.settled) {
|
|
374
|
+
results.push({ name, status: McpServerStatus.Connecting, tools: [] });
|
|
375
|
+
}
|
|
376
|
+
else if (serverState.error) {
|
|
377
|
+
results.push({ error: serverState.error, name, status: McpServerStatus.Error, tools: [] });
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
results.push({ name, status: McpServerStatus.Connected, tools: Object.keys(serverState.tools) });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (state.mcpConfig) {
|
|
384
|
+
for (const [name, serverConfig] of Object.entries(state.mcpConfig)) {
|
|
385
|
+
if (serverConfig.enabled === false) {
|
|
386
|
+
results.push({ name, status: McpServerStatus.Disabled, tools: [] });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return results;
|
|
391
|
+
}
|
|
392
|
+
// ── Thread (Chat Session) Lifecycle ──────────────────────────────
|
|
393
|
+
/**
|
|
394
|
+
* @requirements
|
|
395
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
396
|
+
* - MUST call `memory.createThread()` with the resourceId scoped to this agent.
|
|
397
|
+
* - MUST use the provided `threadId` if supplied, otherwise generate a new one.
|
|
398
|
+
* - MUST return the ID of the created thread.
|
|
399
|
+
*/
|
|
400
|
+
async createThread(agentId, threadId) {
|
|
401
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
402
|
+
const thread = await memory.createThread({
|
|
403
|
+
threadId: threadId || this.idGenerator.getUniqueId(),
|
|
404
|
+
resourceId: this.agentResourceId(agentId),
|
|
405
|
+
});
|
|
406
|
+
return thread.id;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* @requirements
|
|
410
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
411
|
+
* - MUST delete the thread and its history from the storage backend.
|
|
412
|
+
* - MUST remove any active run tracked for this thread to prevent stale references.
|
|
413
|
+
*/
|
|
414
|
+
async destroyThread(agentId, threadId) {
|
|
415
|
+
this.getAgentStateOrThrow(agentId).activeRuns.delete(threadId);
|
|
416
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
417
|
+
await memory.deleteThread(threadId);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* @requirements
|
|
421
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
422
|
+
* - MUST query threads scoped to this agent and return their IDs.
|
|
423
|
+
*/
|
|
424
|
+
async getThreadIds(agentId) {
|
|
425
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
426
|
+
const { threads } = await memory.listThreads({
|
|
427
|
+
filter: { resourceId: this.agentResourceId(agentId) },
|
|
428
|
+
perPage: false,
|
|
429
|
+
});
|
|
430
|
+
return threads.map((t) => t.id);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* @requirements
|
|
434
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
435
|
+
* - MUST clone the source thread's messages into a new thread.
|
|
436
|
+
*/
|
|
437
|
+
async cloneThread(agentId, sourceThreadId, targetThreadId) {
|
|
438
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
439
|
+
const { thread } = await memory.cloneThread({
|
|
440
|
+
sourceThreadId,
|
|
441
|
+
newThreadId: targetThreadId,
|
|
442
|
+
});
|
|
443
|
+
return thread.id;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* @requirements
|
|
447
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
448
|
+
* - MUST create a new thread seeded with an LLM-generated summary.
|
|
449
|
+
* - MUST chunk-summarize transcripts that exceed `MAX_TRANSCRIPT_CHARS`
|
|
450
|
+
* to keep any single model call inside the context window.
|
|
451
|
+
* - MUST surface summarization or seeding failures as `AgentSDKError`
|
|
452
|
+
* with type `COMPACTION_FAILED` and leave the source thread intact on
|
|
453
|
+
* failure.
|
|
454
|
+
* - MUST destroy the source thread on success so it no longer appears in
|
|
455
|
+
* `getThreadIds()`. Cleanup of the source thread is best-effort: if the
|
|
456
|
+
* delete fails the new thread is still returned, since the user's data
|
|
457
|
+
* has already been persisted there. An orphaned source thread is
|
|
458
|
+
* hygiene, not correctness.
|
|
459
|
+
* - MUST seed the summary as a user/assistant turn pair where the user
|
|
460
|
+
* turn carries the summary content and the assistant turn is a brief
|
|
461
|
+
* acknowledgment. Storing the summary as an assistant turn (even with
|
|
462
|
+
* a preceding user prompt) is empirically unreliable: the model
|
|
463
|
+
* recognizes it has no record of producing that summary and rejects
|
|
464
|
+
* it as fabricated, refusing to use the facts on follow-up turns.
|
|
465
|
+
* User turns are treated as authoritative input.
|
|
466
|
+
*/
|
|
467
|
+
async compactThread(agentId, threadId) {
|
|
468
|
+
this.getAgentStateOrThrow(agentId);
|
|
469
|
+
const messages = await this.getMessages(agentId, threadId);
|
|
470
|
+
if (messages.length === 0) {
|
|
471
|
+
return this.createThread(agentId);
|
|
472
|
+
}
|
|
473
|
+
const transcript = messages
|
|
474
|
+
.map((m) => {
|
|
475
|
+
const text = typeof m.content === 'string' ? m.content : m.content.map((p) => partToTranscriptText(p)).join(' ');
|
|
476
|
+
return `${m.role}: ${text}`;
|
|
477
|
+
})
|
|
478
|
+
.join('\n');
|
|
479
|
+
let newThreadId;
|
|
480
|
+
try {
|
|
481
|
+
const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
|
|
482
|
+
? await this.summarize(agentId, this.buildSummaryPrompt(transcript))
|
|
483
|
+
: await this.summarizeInChunks(agentId, transcript);
|
|
484
|
+
newThreadId = await this.createThread(agentId);
|
|
485
|
+
const userTs = this.clock.now();
|
|
486
|
+
const assistantTs = this.clock.nextAfter(userTs);
|
|
487
|
+
await this.addContext(agentId, newThreadId, [
|
|
488
|
+
{
|
|
489
|
+
id: this.idGenerator.getUniqueId(),
|
|
490
|
+
role: 'user',
|
|
491
|
+
content: `The following is a summary of our prior conversation, provided as context for this session:\n\n${summaryText}`,
|
|
492
|
+
createdAt: userTs,
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
id: this.idGenerator.getUniqueId(),
|
|
496
|
+
role: 'assistant',
|
|
497
|
+
content: 'Understood. I have the context from our prior conversation and am ready to continue.',
|
|
498
|
+
createdAt: assistantTs,
|
|
499
|
+
},
|
|
500
|
+
]);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${err instanceof Error ? err.message : String(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
|
|
504
|
+
}
|
|
505
|
+
// Source-thread cleanup is best-effort. The user's data is already
|
|
506
|
+
// safely seeded into newThreadId; a failed delete leaves an orphan
|
|
507
|
+
// but does not invalidate the compaction outcome.
|
|
508
|
+
await this.destroyThread(agentId, threadId).catch(() => undefined);
|
|
509
|
+
return newThreadId;
|
|
510
|
+
}
|
|
511
|
+
// ── Core Messaging ───────────────────────────────────────────────
|
|
512
|
+
/**
|
|
513
|
+
* @requirements
|
|
514
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
515
|
+
* - MUST invoke `agent.stream()` with memory context for the thread.
|
|
516
|
+
* - MUST capture the `runId` for tool approval flows.
|
|
517
|
+
* - MUST return a `ChatStreamResult` with `textStream` and `eventStream`.
|
|
518
|
+
* - On pre-stream rejection from `agent.stream()`, MUST re-throw without emitting telemetry —
|
|
519
|
+
* the SDK's chat-session boundary catches the rejection and emits `chat-stream-error`.
|
|
520
|
+
*/
|
|
521
|
+
async stream(agentId, threadId, message, options) {
|
|
522
|
+
const agentState = this.getAgentStateOrThrow(agentId);
|
|
523
|
+
const output = await agentState.agent.stream(message, {
|
|
524
|
+
memory: {
|
|
525
|
+
thread: threadId,
|
|
526
|
+
resource: this.agentResourceId(agentId),
|
|
527
|
+
},
|
|
528
|
+
maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
529
|
+
abortSignal: options?.abortSignal,
|
|
530
|
+
requireToolApproval: options?.requireToolApproval,
|
|
531
|
+
});
|
|
532
|
+
return this.toChatStreamResult(agentState, threadId, output);
|
|
533
|
+
}
|
|
534
|
+
// ── Tool Execution ───────────────────────────────────────────────
|
|
535
|
+
/**
|
|
536
|
+
* @requirements
|
|
537
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
538
|
+
* - MUST throw an error if no active run exists for `threadId` (the consumer must
|
|
539
|
+
* have started a stream via `stream()` first).
|
|
540
|
+
* - MUST drain the previous stream before resuming to avoid reader-lock conflicts.
|
|
541
|
+
* - MUST call `agent.resumeStream()` with the client-provided tool result packed
|
|
542
|
+
* into `resumeData` (`{ approved: true, result, isError }`). The bridge execute
|
|
543
|
+
* handler registered in the tool mapper reads this data and returns it, allowing
|
|
544
|
+
* the result to flow through Mastra's native agentic loop.
|
|
545
|
+
* - MUST update `activeRuns` with the new `runId` from the continuation output.
|
|
546
|
+
* - MUST return a `ChatStreamResult` with `textStream` and `eventStream`.
|
|
547
|
+
* - On pre-stream rejection from `agent.resumeStream()`, MUST re-throw without emitting
|
|
548
|
+
* telemetry — the SDK's chat-session boundary catches the rejection and emits
|
|
549
|
+
* `chat-stream-error`.
|
|
550
|
+
*/
|
|
551
|
+
async submitToolResult(agentId, threadId, toolResult) {
|
|
552
|
+
const agentState = this.getAgentStateOrThrow(agentId);
|
|
553
|
+
const activeRun = agentState.activeRuns.get(threadId);
|
|
554
|
+
if (!activeRun) {
|
|
555
|
+
throw new Error(`MastraHarness.submitToolResult(): no active run found for thread "${threadId}" on agent "${agentId}"`);
|
|
556
|
+
}
|
|
557
|
+
await this.drainReadableStream(activeRun.fullStream);
|
|
558
|
+
const output = await agentState.agent.resumeStream({ approved: true, result: toolResult.result, isError: toolResult.isError }, {
|
|
559
|
+
runId: activeRun.runId,
|
|
560
|
+
toolCallId: toolResult.toolCallId,
|
|
561
|
+
memory: {
|
|
562
|
+
thread: threadId,
|
|
563
|
+
resource: this.agentResourceId(agentId),
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
return this.toChatStreamResult(agentState, threadId, output);
|
|
567
|
+
}
|
|
568
|
+
// ── Tool Approval ────────────────────────────────────────────────
|
|
569
|
+
/**
|
|
570
|
+
* @requirements
|
|
571
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
572
|
+
* - MUST throw an error if no active `runId` is stored for `threadId` in `activeRuns`
|
|
573
|
+
* (i.e. the caller has not yet started a stream on that thread).
|
|
574
|
+
* - MUST invoke `agent.approveToolCall()` with the stored `runId`, the `toolCallId`, and
|
|
575
|
+
* the thread's memory context (`thread: threadId`, `resource: AGENT_INSTANCE:<agentId>`).
|
|
576
|
+
* Mastra then unblocks the suspended tool execution, runs the tool, and generates the
|
|
577
|
+
* continuation response.
|
|
578
|
+
* - MUST update `activeRuns` with the new `runId` from the continuation output so that
|
|
579
|
+
* any subsequent `approveToolCall()` / `declineToolCall()` calls within the same thread
|
|
580
|
+
* reference the most recent run.
|
|
581
|
+
* - MUST return a `ChatStreamResult` with `textStream` and `eventStream` from the
|
|
582
|
+
* continuation output, following the same pattern as AI SDK, ACP, and Mastra's own
|
|
583
|
+
* streaming APIs where approval produces a new continuation stream.
|
|
584
|
+
* - On pre-stream rejection from `agent.approveToolCall()`, MUST re-throw without emitting
|
|
585
|
+
* telemetry — the SDK's chat-session boundary catches the rejection and emits
|
|
586
|
+
* `chat-stream-error`.
|
|
587
|
+
*/
|
|
588
|
+
async approveToolCall(agentId, threadId, toolCallId) {
|
|
589
|
+
const agentState = this.getAgentStateOrThrow(agentId);
|
|
590
|
+
const activeRun = agentState.activeRuns.get(threadId);
|
|
591
|
+
if (!activeRun) {
|
|
592
|
+
throw new Error(`MastraHarness.approveToolCall(): no active run found for thread "${threadId}" on agent "${agentId}"`);
|
|
593
|
+
}
|
|
594
|
+
await this.drainReadableStream(activeRun.fullStream);
|
|
595
|
+
const output = await agentState.agent.approveToolCall({
|
|
596
|
+
runId: activeRun.runId,
|
|
597
|
+
toolCallId,
|
|
598
|
+
memory: {
|
|
599
|
+
thread: threadId,
|
|
600
|
+
resource: this.agentResourceId(agentId),
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
return this.toChatStreamResult(agentState, threadId, output);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* @requirements
|
|
607
|
+
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
608
|
+
* - MUST throw an error if no active `runId` is stored for `threadId` in `activeRuns`.
|
|
609
|
+
* - MUST invoke `agent.declineToolCall()` with the stored `runId`, the `toolCallId`, and
|
|
610
|
+
* the thread's memory context. Mastra cancels the pending tool call and generates a
|
|
611
|
+
* continuation response acknowledging the decline.
|
|
612
|
+
* - MUST update `activeRuns` with the new `runId` from the continuation output.
|
|
613
|
+
* - MUST return a `ChatStreamResult` with `textStream` and `eventStream` from the
|
|
614
|
+
* continuation output.
|
|
615
|
+
* - On pre-stream rejection from `agent.declineToolCall()`, MUST re-throw without emitting
|
|
616
|
+
* telemetry — the SDK's chat-session boundary catches the rejection and emits
|
|
617
|
+
* `chat-stream-error`.
|
|
618
|
+
*/
|
|
619
|
+
async declineToolCall(agentId, threadId, toolCallId) {
|
|
620
|
+
const agentState = this.getAgentStateOrThrow(agentId);
|
|
621
|
+
const activeRun = agentState.activeRuns.get(threadId);
|
|
622
|
+
if (!activeRun) {
|
|
623
|
+
throw new Error(`MastraHarness.declineToolCall(): no active run found for thread "${threadId}" on agent "${agentId}"`);
|
|
624
|
+
}
|
|
625
|
+
await this.drainReadableStream(activeRun.fullStream);
|
|
626
|
+
const output = await agentState.agent.declineToolCall({
|
|
627
|
+
runId: activeRun.runId,
|
|
628
|
+
toolCallId,
|
|
629
|
+
memory: {
|
|
630
|
+
thread: threadId,
|
|
631
|
+
resource: this.agentResourceId(agentId),
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
return this.toChatStreamResult(agentState, threadId, output);
|
|
635
|
+
}
|
|
636
|
+
// ── Message History ──────────────────────────────────────────────
|
|
637
|
+
/**
|
|
638
|
+
* @requirements
|
|
639
|
+
* - MUST throw an error if the specified `agentId` is not found.
|
|
640
|
+
* - MUST retrieve messages via `memory.recall()` and map to our `Message` format.
|
|
641
|
+
*/
|
|
642
|
+
async getMessages(agentId, threadId) {
|
|
643
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
644
|
+
const { messages } = await memory.recall({
|
|
645
|
+
threadId,
|
|
646
|
+
resourceId: this.agentResourceId(agentId),
|
|
647
|
+
perPage: false,
|
|
648
|
+
});
|
|
649
|
+
const logBus = this.buses.getLogBus();
|
|
650
|
+
return messages
|
|
651
|
+
.map((m) => mapMastraToMessage(m, logBus))
|
|
652
|
+
.sort((a, b) => (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0));
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* @requirements
|
|
656
|
+
* - MUST throw an error if the specified `agentId` is not found.
|
|
657
|
+
* - MUST delete all messages in the thread.
|
|
658
|
+
*/
|
|
659
|
+
async clearMessages(agentId, threadId) {
|
|
660
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
661
|
+
const { messages } = await memory.recall({
|
|
662
|
+
threadId,
|
|
663
|
+
resourceId: this.agentResourceId(agentId),
|
|
664
|
+
perPage: false,
|
|
665
|
+
});
|
|
666
|
+
if (messages.length > 0) {
|
|
667
|
+
await memory.deleteMessages(messages.map((m) => m.id));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* @requirements
|
|
672
|
+
* - MUST throw an error if the specified `agentId` is not found.
|
|
673
|
+
* - MUST save context messages without triggering an LLM response.
|
|
674
|
+
*/
|
|
675
|
+
async addContext(agentId, threadId, messages) {
|
|
676
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
677
|
+
const resourceId = this.agentResourceId(agentId);
|
|
678
|
+
const mapped = messages.map((m) => mapMessageToMastra(m, threadId, resourceId, this.clock));
|
|
679
|
+
await memory.saveMessages({ messages: mapped });
|
|
680
|
+
}
|
|
681
|
+
// ── Private Helpers ──────────────────────────────────────────────
|
|
682
|
+
/**
|
|
683
|
+
* Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
|
|
684
|
+
* summarization. Used internally by `compactThread()`; not part of the
|
|
685
|
+
* `AgentHarness` contract because the SDK never calls summarization
|
|
686
|
+
* directly.
|
|
687
|
+
*/
|
|
688
|
+
async summarize(agentId, prompt) {
|
|
689
|
+
const agentState = this.getAgentStateOrThrow(agentId);
|
|
690
|
+
const result = await agentState.agent.generate(prompt, { maxSteps: 1 });
|
|
691
|
+
return result.text;
|
|
692
|
+
}
|
|
693
|
+
buildSummaryPrompt(transcript) {
|
|
694
|
+
return ('Summarize the following conversation into a concise context summary. ' +
|
|
695
|
+
'Preserve key decisions, facts, and action items. ' +
|
|
696
|
+
'Write in third person as a factual record.\n\n' +
|
|
697
|
+
transcript);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Two-pass summarization for transcripts above the char budget. The
|
|
701
|
+
* transcript is split into roughly-equal chunks each ≤ MAX_TRANSCRIPT_CHARS,
|
|
702
|
+
* each chunk is summarized, and the concatenated chunk summaries are
|
|
703
|
+
* summarized again into the final summary.
|
|
704
|
+
*/
|
|
705
|
+
async summarizeInChunks(agentId, transcript) {
|
|
706
|
+
const chunks = this.splitIntoChunks(transcript, MAX_TRANSCRIPT_CHARS);
|
|
707
|
+
const chunkSummaries = [];
|
|
708
|
+
for (const chunk of chunks) {
|
|
709
|
+
const summary = await this.summarize(agentId, this.buildSummaryPrompt(chunk));
|
|
710
|
+
chunkSummaries.push(summary);
|
|
711
|
+
}
|
|
712
|
+
const merged = chunkSummaries.map((s, i) => `Part ${i + 1}:\n${s}`).join('\n\n');
|
|
713
|
+
// The merged summaries will almost always fit, but a pathologically
|
|
714
|
+
// long chat could in principle still exceed budget. If so, recurse;
|
|
715
|
+
// otherwise final-summarize.
|
|
716
|
+
return merged.length <= MAX_TRANSCRIPT_CHARS
|
|
717
|
+
? this.summarize(agentId, this.buildSummaryPrompt(merged))
|
|
718
|
+
: this.summarizeInChunks(agentId, merged);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Splits a string into ≤ `maxChars`-sized chunks, preferring line
|
|
722
|
+
* boundaries so message turns are not torn across chunks. Falls back to
|
|
723
|
+
* a hard slice if a single line itself exceeds the budget.
|
|
724
|
+
*/
|
|
725
|
+
splitIntoChunks(text, maxChars) {
|
|
726
|
+
const chunks = [];
|
|
727
|
+
const lines = text.split('\n');
|
|
728
|
+
let current = '';
|
|
729
|
+
for (const line of lines) {
|
|
730
|
+
if (line.length > maxChars) {
|
|
731
|
+
if (current.length > 0) {
|
|
732
|
+
chunks.push(current);
|
|
733
|
+
current = '';
|
|
734
|
+
}
|
|
735
|
+
for (let i = 0; i < line.length; i += maxChars) {
|
|
736
|
+
chunks.push(line.slice(i, i + maxChars));
|
|
737
|
+
}
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
const candidate = current.length === 0 ? line : current + '\n' + line;
|
|
741
|
+
if (candidate.length > maxChars) {
|
|
742
|
+
chunks.push(current);
|
|
743
|
+
current = line;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
current = candidate;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (current.length > 0) {
|
|
750
|
+
chunks.push(current);
|
|
751
|
+
}
|
|
752
|
+
return chunks;
|
|
753
|
+
}
|
|
754
|
+
getAgentStateOrThrow(agentId) {
|
|
755
|
+
const state = this.activeAgents.get(agentId);
|
|
756
|
+
if (!state) {
|
|
757
|
+
throw new Error(`Agent with id "${agentId}" is not registered`);
|
|
758
|
+
}
|
|
759
|
+
return state;
|
|
760
|
+
}
|
|
761
|
+
toChatStreamResult(agentState, threadId, output) {
|
|
762
|
+
// Entries are keyed by threadId so each thread holds at most one entry.
|
|
763
|
+
// Stale entries are overwritten here on the next stream; explicit cleanup
|
|
764
|
+
// happens via destroyThread() / destroyAgent() / shutdown(). Proactive
|
|
765
|
+
// removal on stream completion is not safe because the suspend/resume
|
|
766
|
+
// flow (submitToolResult, approveToolCall) needs the entry after the stream closes.
|
|
767
|
+
agentState.activeRuns.set(threadId, { runId: output.runId, fullStream: output.fullStream });
|
|
768
|
+
const adapter = new MastraEventAdapter(this.buses.getLogBus());
|
|
769
|
+
return {
|
|
770
|
+
eventStream: this.createEventStream(output.fullStream, adapter),
|
|
771
|
+
textStream: this.readableStreamToAsyncGenerator(output.textStream),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
async getMemoryOrThrow(agentId) {
|
|
775
|
+
const agentState = this.getAgentStateOrThrow(agentId);
|
|
776
|
+
const memory = await agentState.agent.getMemory();
|
|
777
|
+
if (!memory) {
|
|
778
|
+
throw new Error(`Agent "${agentId}" has no memory configured`);
|
|
779
|
+
}
|
|
780
|
+
return memory;
|
|
781
|
+
}
|
|
782
|
+
agentResourceId(agentId) {
|
|
783
|
+
return `AGENT_INSTANCE:${agentId}`;
|
|
784
|
+
}
|
|
785
|
+
async *createEventStream(fullStream, adapter) {
|
|
786
|
+
for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
|
|
787
|
+
const event = adapter.toChatEvent(chunk);
|
|
788
|
+
if (!event)
|
|
789
|
+
continue;
|
|
790
|
+
yield event;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
async *readableStreamToAsyncGenerator(stream) {
|
|
794
|
+
const reader = stream.getReader();
|
|
795
|
+
try {
|
|
796
|
+
while (true) {
|
|
797
|
+
const { done, value } = await reader.read();
|
|
798
|
+
if (done)
|
|
799
|
+
return;
|
|
800
|
+
yield value;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
reader.releaseLock();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Consume any remaining chunks from a ReadableStream, discarding them.
|
|
809
|
+
*
|
|
810
|
+
* Mastra persists workflow snapshots asynchronously after emitting the
|
|
811
|
+
* last stream chunk. If the consumer broke out of the event stream early
|
|
812
|
+
* (e.g. on `tool-approval-request`), the reader lock was released but
|
|
813
|
+
* the snapshot may not yet be persisted. Draining the remaining chunks
|
|
814
|
+
* guarantees the snapshot is ready before we call `agent.approveToolCall()`
|
|
815
|
+
* or `agent.declineToolCall()`.
|
|
816
|
+
*
|
|
817
|
+
* If the stream was already fully consumed (normal finish, no break),
|
|
818
|
+
* this is a no-op — the reader gets `done` immediately.
|
|
819
|
+
*
|
|
820
|
+
* No timeout guard: this stream is always the `fullStream` from a Mastra
|
|
821
|
+
* agent call backed by a bounded LLM completion. The model will always
|
|
822
|
+
* emit a stop reason and Mastra will close the stream. A true hang (e.g.,
|
|
823
|
+
* network drop with no gateway timeout) would surface as a rejected
|
|
824
|
+
* promise from `reader.read()`, not an infinite loop.
|
|
825
|
+
*/
|
|
826
|
+
async drainReadableStream(stream) {
|
|
827
|
+
const reader = stream.getReader();
|
|
828
|
+
try {
|
|
829
|
+
while (!(await reader.read()).done) {
|
|
830
|
+
/* discard */
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
reader.releaseLock();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
//# sourceMappingURL=mastra-harness.js.map
|