@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.
Files changed (40) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +66 -0
  3. package/dist/default-model-config-factory.d.ts +11 -0
  4. package/dist/default-model-config-factory.js +18 -0
  5. package/dist/default-model-config-factory.js.map +1 -0
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/llmg-express-model-config-factory.d.ts +23 -0
  10. package/dist/llmg-express-model-config-factory.js +28 -0
  11. package/dist/llmg-express-model-config-factory.js.map +1 -0
  12. package/dist/mastra-event-adapter.d.ts +73 -0
  13. package/dist/mastra-event-adapter.js +204 -0
  14. package/dist/mastra-event-adapter.js.map +1 -0
  15. package/dist/mastra-harness-factory.d.ts +9 -0
  16. package/dist/mastra-harness-factory.js +17 -0
  17. package/dist/mastra-harness-factory.js.map +1 -0
  18. package/dist/mastra-harness.d.ts +292 -0
  19. package/dist/mastra-harness.js +838 -0
  20. package/dist/mastra-harness.js.map +1 -0
  21. package/dist/mastra-message-mapper.d.ts +42 -0
  22. package/dist/mastra-message-mapper.js +195 -0
  23. package/dist/mastra-message-mapper.js.map +1 -0
  24. package/dist/mastra-tool-mapper.d.ts +29 -0
  25. package/dist/mastra-tool-mapper.js +55 -0
  26. package/dist/mastra-tool-mapper.js.map +1 -0
  27. package/dist/mcp-config-mapper.d.ts +20 -0
  28. package/dist/mcp-config-mapper.js +73 -0
  29. package/dist/mcp-config-mapper.js.map +1 -0
  30. package/dist/model-config-factory.d.ts +16 -0
  31. package/dist/model-config-factory.js +6 -0
  32. package/dist/model-config-factory.js.map +1 -0
  33. package/dist/salesforce-llm-gateway.d.ts +29 -0
  34. package/dist/salesforce-llm-gateway.js +266 -0
  35. package/dist/salesforce-llm-gateway.js.map +1 -0
  36. package/dist/test/tsconfig.tsbuildinfo +1 -0
  37. package/dist/utils.d.ts +10 -0
  38. package/dist/utils.js +29 -0
  39. package/dist/utils.js.map +1 -0
  40. 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