@salesforce/sfdx-agent-harness-mastra 0.10.0 → 0.11.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 CHANGED
@@ -46,14 +46,15 @@ Linux and macOS release the handle eagerly; the retry options are no-ops on thos
46
46
 
47
47
  ### Core
48
48
 
49
- | Export | Kind | Description |
50
- | ------------------------------------- | --------- | ----------------------------------------------------------------------------------------- |
51
- | `MastraHarnessFactory` | class | `HarnessFactory` that produces a Mastra-backed `AgentHarness` bound to a storage root. |
52
- | `MastraModelConfigFactory` | interface | Strategy for creating model configurations; injected into `MastraHarnessFactory`. |
53
- | `ResolvedModelConfig` | type | Return type of `MastraModelConfigFactory.create()` — wraps a `MastraModelConfig`. |
54
- | `DefaultMastraModelConfigFactory` | class | Default factory using the Salesforce LLM Gateway (`SalesforceMastraModelGateway`). |
55
- | `LLMGExpressMastraModelConfigFactory` | class | Factory for LLM Gateway Express (API-key-authenticated, OpenAI-compatible endpoint). |
56
- | `LLMGExpressConfig` | type | Configuration for `LLMGExpressMastraModelConfigFactory` (`apiKey`, `baseUrl`, `modelId`). |
49
+ | Export | Kind | Description |
50
+ | ------------------------------------- | --------- | ------------------------------------------------------------------------------------------- |
51
+ | `MastraHarnessFactory` | class | `HarnessFactory` that produces a Mastra-backed `AgentHarness` bound to a storage root. |
52
+ | `MastraHarnessFactoryConfig` | interface | Configuration for `MastraHarnessFactory` (`modelConfigFactory?`, `toolApprovalTimeoutMs?`). |
53
+ | `MastraModelConfigFactory` | interface | Strategy for creating model configurations; injected into `MastraHarnessFactory`. |
54
+ | `ResolvedModelConfig` | type | Return type of `MastraModelConfigFactory.create()` — wraps a `MastraModelConfig`. |
55
+ | `DefaultMastraModelConfigFactory` | class | Default factory using the Salesforce LLM Gateway (`SalesforceMastraModelGateway`). |
56
+ | `LLMGExpressMastraModelConfigFactory` | class | Factory for LLM Gateway Express (API-key-authenticated, OpenAI-compatible endpoint). |
57
+ | `LLMGExpressConfig` | type | Configuration for `LLMGExpressMastraModelConfigFactory` (`apiKey`, `baseUrl`, `modelId`). |
57
58
 
58
59
  ### Extension surface
59
60
 
@@ -151,19 +152,55 @@ for await (const event of result.eventStream) {
151
152
  }
152
153
  ```
153
154
 
155
+ ### Tool approval timeout
156
+
157
+ When `requireToolApproval: true` is set on a `chat()` call, the harness suspends the workflow on each
158
+ `tool-approval-request` and waits for the consumer to call `approveToolCall(...)` / `declineToolCall(...)` /
159
+ `submitToolResult(...)`. To bound how long a pending approval can wait — for example, when a UI process crashes or a
160
+ user walks away — the factory accepts a `toolApprovalTimeoutMs` option. It defaults to `600_000` (10 minutes) and
161
+ matches the `ClaudeHarnessFactoryConfig` knob of the same name.
162
+
163
+ ```ts
164
+ const harnessFactory = new MastraHarnessFactory({
165
+ toolApprovalTimeoutMs: 60_000, // auto-deny pending approvals after 60s
166
+ });
167
+ ```
168
+
169
+ When the timer fires, the harness aborts the suspended Mastra run, drops the `activeRuns` entry for the thread, and
170
+ yields a synthetic terminal pair on the active `eventStream`:
171
+
172
+ ```ts
173
+ { type: 'error', error: Error('Tool approval timed out after 60000ms.'), code: 'tool-approval-timeout' }
174
+ { type: 'finish', finishReason: 'error' }
175
+ ```
176
+
177
+ Subsequent `approveToolCall` / `declineToolCall` / `submitToolResult` calls for the timed-out tool reject with
178
+ `no active run found for thread "<id>"`. The granularity is per thread — each suspended `ActiveRun` arms its own timer
179
+ because Mastra suspends one tool approval at a time per thread; multi-tool turns get a fresh timer with each
180
+ continuation. To observe the timeout, keep iterating the `eventStream` past the `tool-approval-request` event so the
181
+ synthetic terminal pair lands in your loop:
182
+
183
+ ```ts
184
+ for await (const event of result.eventStream) {
185
+ if (event.type === 'error' && event.code === 'tool-approval-timeout') {
186
+ // Pending approval was never answered.
187
+ }
188
+ }
189
+ ```
190
+
154
191
  ### Using LLM Gateway Express
155
192
 
156
193
  ```ts
157
194
  import { createAgentManager } from '@salesforce/sfdx-agent-sdk';
158
195
  import { MastraHarnessFactory, LLMGExpressMastraModelConfigFactory } from '@salesforce/sfdx-agent-harness-mastra';
159
196
 
160
- const harnessFactory = new MastraHarnessFactory(
161
- new LLMGExpressMastraModelConfigFactory({
197
+ const harnessFactory = new MastraHarnessFactory({
198
+ modelConfigFactory: new LLMGExpressMastraModelConfigFactory({
162
199
  apiKey: process.env.LLMG_EXPRESS_API_KEY!,
163
200
  baseUrl: process.env.LLMG_EXPRESS_BASE_URL!,
164
201
  modelId: 'claude-sonnet-4-20250514',
165
202
  }),
166
- );
203
+ });
167
204
 
168
205
  const manager = await createAgentManager('/path/to/storage', harnessFactory);
169
206
  ```
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { MastraHarnessFactory } from './mastra-harness-factory.js';
1
+ export { MastraHarnessFactory, type MastraHarnessFactoryConfig } from './mastra-harness-factory.js';
2
2
  export type { MastraAgentHarness } from './mastra-harness.js';
3
3
  export { type MastraAgentConfig, type MastraToolSearchConfig, type SkillSearchHandle, type ToolSearchHandle, formatMcpToolName, } from './tool-search.js';
4
4
  export type { MastraModelConfigFactory, ResolvedModelConfig } from './model-config-factory.js';
@@ -1,11 +1,28 @@
1
1
  import type { HarnessFactory } from '@salesforce/sfdx-agent-sdk';
2
2
  import { type MastraAgentHarness } from './mastra-harness.js';
3
3
  import type { MastraModelConfigFactory } from './model-config-factory.js';
4
+ export interface MastraHarnessFactoryConfig {
5
+ /**
6
+ * Strategy for resolving the Mastra model configuration. Defaults to
7
+ * `DefaultMastraModelConfigFactory`, which targets the standard LLM
8
+ * Gateway path.
9
+ */
10
+ modelConfigFactory?: MastraModelConfigFactory;
11
+ /**
12
+ * How long to wait for a `requireToolApproval` consumer to call
13
+ * `approveToolCall` / `declineToolCall` before auto-denying via
14
+ * `agent.abortRunStream(...)` and yielding a synthetic `error`
15
+ * (`code: 'tool-approval-timeout'`) + `finish` (`finishReason: 'error'`)
16
+ * terminal pair. Defaults to `600_000` (10 minutes), matching
17
+ * `ClaudeHarnessFactoryConfig.toolApprovalTimeoutMs`.
18
+ */
19
+ toolApprovalTimeoutMs?: number;
20
+ }
4
21
  /** Mastra-backed factory for creating AgentHarness instances. */
5
22
  export declare class MastraHarnessFactory implements HarnessFactory<MastraAgentHarness> {
6
- private readonly modelConfigFactory?;
23
+ private readonly config;
7
24
  readonly harnessId = "mastra";
8
25
  readonly protocolVersion = 1;
9
- constructor(modelConfigFactory?: MastraModelConfigFactory | undefined);
26
+ constructor(config?: MastraHarnessFactoryConfig);
10
27
  create(storageRootFolder: string): Promise<MastraAgentHarness>;
11
28
  }
@@ -3,16 +3,20 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import { MastraHarness } from './mastra-harness.js';
6
+ const DEFAULT_TOOL_APPROVAL_TIMEOUT_MS = 600_000;
6
7
  /** Mastra-backed factory for creating AgentHarness instances. */
7
8
  export class MastraHarnessFactory {
8
- modelConfigFactory;
9
+ config;
9
10
  harnessId = 'mastra';
10
11
  protocolVersion = 1;
11
- constructor(modelConfigFactory) {
12
- this.modelConfigFactory = modelConfigFactory;
12
+ constructor(config = {}) {
13
+ this.config = config;
13
14
  }
14
15
  async create(storageRootFolder) {
15
- return new MastraHarness(storageRootFolder, this.modelConfigFactory);
16
+ return new MastraHarness(storageRootFolder, {
17
+ modelConfigFactory: this.config.modelConfigFactory,
18
+ toolApprovalTimeoutMs: this.config.toolApprovalTimeoutMs ?? DEFAULT_TOOL_APPROVAL_TIMEOUT_MS,
19
+ });
16
20
  }
17
21
  }
18
22
  //# sourceMappingURL=mastra-harness-factory.js.map
@@ -33,6 +33,31 @@ interface ActiveRun {
33
33
  runId: string;
34
34
  fullStream: ReadableStream<ChunkType>;
35
35
  disposeAbortListener?: () => void;
36
+ /**
37
+ * Clears the per-run tool-approval timeout (`setTimeout` handle wrapped in
38
+ * a closure). Invoked at every site that overwrites or removes the entry,
39
+ * mirroring `disposeAbortListener` semantics. See ARCHITECTURE.md →
40
+ * "Approval timeout (#408)".
41
+ */
42
+ disposeTimeout?: () => void;
43
+ /**
44
+ * Whether the originating `stream()` call was made with
45
+ * `requireToolApproval: true`. Carried forward across continuations
46
+ * (`approveToolCall`, `declineToolCall`, `submitToolResult`) so each
47
+ * continuation arms a fresh approval-timeout timer.
48
+ */
49
+ requireToolApproval?: boolean;
50
+ /**
51
+ * Set by `handleApprovalTimeout` and read by `createEventStream`'s
52
+ * `Promise.race` against the next `reader.read()`. When the race
53
+ * resolves to this signal, the generator yields the synthetic `error`
54
+ * (`code: 'tool-approval-timeout'`) + `finish` terminal pair without
55
+ * needing the underlying `fullStream` to close — Mastra's
56
+ * `abortRunStream` is a silent no-op for suspended runs, so we cannot
57
+ * count on the stream closing on its own.
58
+ */
59
+ timedOutSignal?: Promise<'timed-out'>;
60
+ timedOut?: boolean;
36
61
  }
37
62
  /**
38
63
  * Per-server MCP state. Each enabled server gets its own MCPClient and
@@ -134,11 +159,20 @@ export type MastraAgentHarness = WithAgentConfig<AgentHarness & {
134
159
  };
135
160
  };
136
161
  }, MastraAgentConfig>;
162
+ /**
163
+ * Construction options for {@link MastraHarness}. Internal — consumers
164
+ * configure the harness via {@link MastraHarnessFactory} (which forwards a
165
+ * curated subset of these fields). Tests pass stubs (`clock`, `idGenerator`)
166
+ * directly.
167
+ */
168
+ export interface MastraHarnessOptions {
169
+ modelConfigFactory?: MastraModelConfigFactory;
170
+ clock?: Clock;
171
+ idGenerator?: UniqueIDGenerator;
172
+ toolApprovalTimeoutMs?: number;
173
+ }
137
174
  export declare class MastraHarness implements MastraAgentHarness {
138
175
  private readonly storageRootFolder;
139
- private readonly modelConfigFactory;
140
- private readonly clock;
141
- private readonly idGenerator;
142
176
  readonly harnessId = "mastra";
143
177
  readonly protocolVersion = 1;
144
178
  /**
@@ -164,7 +198,11 @@ export declare class MastraHarness implements MastraAgentHarness {
164
198
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
165
199
  */
166
200
  readonly extensions: MastraAgentHarness['extensions'];
167
- constructor(storageRootFolder: string, modelConfigFactory?: MastraModelConfigFactory, clock?: Clock, idGenerator?: UniqueIDGenerator);
201
+ private readonly modelConfigFactory;
202
+ private readonly clock;
203
+ private readonly idGenerator;
204
+ private readonly toolApprovalTimeoutMs;
205
+ constructor(storageRootFolder: string, opts?: MastraHarnessOptions);
168
206
  /**
169
207
  * @requirements
170
208
  * - MUST disconnect the mcpClient for every active agent before clearing state.
@@ -429,13 +467,13 @@ export declare class MastraHarness implements MastraAgentHarness {
429
467
  private toChatStreamResult;
430
468
  /**
431
469
  * Replace (or set) the active run for a thread, disposing any prior
432
- * entry's per-run abort listener so the prior signal cannot fire on the
433
- * new run.
470
+ * entry's per-run abort listener and approval-timeout timer so neither
471
+ * can fire on the new run.
434
472
  */
435
473
  private replaceActiveRun;
436
474
  /**
437
475
  * Remove the active run for a thread, disposing its per-run abort listener
438
- * if present. Safe to call when no entry exists.
476
+ * and approval-timeout timer if present. Safe to call when no entry exists.
439
477
  */
440
478
  private removeActiveRun;
441
479
  /**
@@ -457,6 +495,46 @@ export declare class MastraHarness implements MastraAgentHarness {
457
495
  */
458
496
  private installPerRunAbortListener;
459
497
  private handlePerRunAbort;
498
+ /**
499
+ * Arm a one-shot tool-approval timer for `run`. Returns a disposer that
500
+ * clears the timer; callers must invoke it whenever the entry is replaced
501
+ * or removed so a stale timer cannot fire on an unrelated continuation.
502
+ *
503
+ * Closes #408 — without this, an unanswered `requireToolApproval: true`
504
+ * request leaves Mastra's prepared run alive and `activeRuns[threadId]`
505
+ * pointing at a `runId` the consumer never settles, so the suspended
506
+ * workflow leaks until the manager shuts down. Mirrors the
507
+ * `ClaudeStreamBroker` per-pending-approval timer in
508
+ * `@salesforce/sfdx-agent-harness-claude`; the granularity differs (per
509
+ * thread here, per `toolCallId` there) because Mastra suspends one tool
510
+ * approval at a time per thread.
511
+ */
512
+ private installApprovalTimeout;
513
+ /**
514
+ * Fire-once handler invoked when `installApprovalTimeout`'s timer expires
515
+ * before the consumer calls `approveToolCall` / `declineToolCall` /
516
+ * `submitToolResult`.
517
+ *
518
+ * Identity guard: if the entry has already been replaced or removed
519
+ * (consumer settled in flight, abort fired, agent destroyed), no-op. Same
520
+ * race posture as `handlePerRunAbort` and as Claude's
521
+ * `ClaudeStreamBroker.handleApprovalTimeout` early-return.
522
+ *
523
+ * On fire: flag the run, drop the entry from `activeRuns` (so subsequent
524
+ * `approveToolCall` etc. surface the SDK-level "no active run found"
525
+ * guard), call `agent.abortRunStream(runId)` (best-effort — for a
526
+ * suspended run this is a no-op since the prepared-runs registry has
527
+ * already released it), and resolve the `timedOutSignal` so
528
+ * `createEventStream`'s `Promise.race` against the next `reader.read()`
529
+ * picks the timeout signal and yields the synthetic `error`
530
+ * (`code: 'tool-approval-timeout'`) + `finish` terminal pair. The #409
531
+ * abort path doesn't need a side signal because Mastra closes the
532
+ * fullStream natively when the caller's `abortSignal` fires; the timeout
533
+ * path has no such signal and Mastra's `abortRunStream` is a silent
534
+ * no-op for suspended runs, so the generator must learn about the
535
+ * timeout out-of-band.
536
+ */
537
+ private handleApprovalTimeout;
460
538
  private getMemoryOrThrow;
461
539
  private agentResourceId;
462
540
  /**
@@ -472,6 +550,14 @@ export declare class MastraHarness implements MastraAgentHarness {
472
550
  * actually match what the processor stored.
473
551
  */
474
552
  private buildRequestContext;
553
+ /**
554
+ * Resolve when `signal` aborts; resolves immediately if already aborted.
555
+ * Used in `createEventStream`'s suspended-wait branch so the generator
556
+ * wakes on `controller.abort()` even though the underlying `fullStream`
557
+ * has already returned `done` (Mastra closes the stream right after
558
+ * `tool-call-approval`).
559
+ */
560
+ private waitForAbort;
475
561
  private createEventStream;
476
562
  private readableStreamToAsyncGenerator;
477
563
  /**
@@ -302,9 +302,6 @@ function mapMastraToolsToMcpToolInfo(tools, logBus) {
302
302
  }
303
303
  export class MastraHarness {
304
304
  storageRootFolder;
305
- modelConfigFactory;
306
- clock;
307
- idGenerator;
308
305
  harnessId = 'mastra';
309
306
  protocolVersion = 1;
310
307
  /**
@@ -343,11 +340,16 @@ export class MastraHarness {
343
340
  },
344
341
  },
345
342
  };
346
- constructor(storageRootFolder, modelConfigFactory = new DefaultMastraModelConfigFactory(), clock = new RealClock(), idGenerator = new UUIDGenerator()) {
343
+ modelConfigFactory;
344
+ clock;
345
+ idGenerator;
346
+ toolApprovalTimeoutMs;
347
+ constructor(storageRootFolder, opts = {}) {
347
348
  this.storageRootFolder = storageRootFolder;
348
- this.modelConfigFactory = modelConfigFactory;
349
- this.clock = clock;
350
- this.idGenerator = idGenerator;
349
+ this.modelConfigFactory = opts.modelConfigFactory ?? new DefaultMastraModelConfigFactory();
350
+ this.clock = opts.clock ?? new RealClock();
351
+ this.idGenerator = opts.idGenerator ?? new UUIDGenerator();
352
+ this.toolApprovalTimeoutMs = opts.toolApprovalTimeoutMs ?? 600_000;
351
353
  // Construct the libsql Client ourselves and inject it into LibSQLStore
352
354
  // via the `{ client }` constructor form. We keep our own reference so
353
355
  // shutdown() can call client.close() — Mastra's shutdown() does not
@@ -403,8 +405,10 @@ export class MastraHarness {
403
405
  */
404
406
  async shutdown() {
405
407
  await Promise.all([...this.activeAgents.values()].map(async (state) => {
406
- for (const run of state.activeRuns.values())
408
+ for (const run of state.activeRuns.values()) {
407
409
  run.disposeAbortListener?.();
410
+ run.disposeTimeout?.();
411
+ }
408
412
  state.activeRuns.clear();
409
413
  await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
410
414
  await s.ready.catch(() => undefined);
@@ -620,8 +624,10 @@ export class MastraHarness {
620
624
  */
621
625
  async destroyAgent(agentId) {
622
626
  const state = this.getAgentStateOrThrow(agentId);
623
- for (const run of state.activeRuns.values())
627
+ for (const run of state.activeRuns.values()) {
624
628
  run.disposeAbortListener?.();
629
+ run.disposeTimeout?.();
630
+ }
625
631
  state.activeRuns.clear();
626
632
  await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
627
633
  await s.ready.catch(() => undefined);
@@ -946,7 +952,7 @@ export class MastraHarness {
946
952
  abortSignal: options?.abortSignal,
947
953
  requireToolApproval: options?.requireToolApproval,
948
954
  });
949
- return this.toChatStreamResult(agentState, threadId, output, options?.abortSignal);
955
+ return this.toChatStreamResult(agentState, threadId, output, options?.abortSignal, options?.requireToolApproval);
950
956
  }
951
957
  // ── Tool Execution ───────────────────────────────────────────────
952
958
  /**
@@ -971,6 +977,7 @@ export class MastraHarness {
971
977
  if (!activeRun) {
972
978
  throw new Error(`MastraHarness.submitToolResult(): no active run found for thread "${threadId}" on agent "${agentId}"`);
973
979
  }
980
+ const requireToolApproval = activeRun.requireToolApproval;
974
981
  await this.drainReadableStream(activeRun.fullStream);
975
982
  const output = await agentState.agent.resumeStream({ approved: true, result: toolResult.result, isError: toolResult.isError }, {
976
983
  runId: activeRun.runId,
@@ -981,7 +988,7 @@ export class MastraHarness {
981
988
  },
982
989
  requestContext: this.buildRequestContext(agentId, threadId),
983
990
  });
984
- return this.toChatStreamResult(agentState, threadId, output);
991
+ return this.toChatStreamResult(agentState, threadId, output, undefined, requireToolApproval);
985
992
  }
986
993
  // ── Tool Approval ────────────────────────────────────────────────
987
994
  /**
@@ -1009,6 +1016,7 @@ export class MastraHarness {
1009
1016
  if (!activeRun) {
1010
1017
  throw new Error(`MastraHarness.approveToolCall(): no active run found for thread "${threadId}" on agent "${agentId}"`);
1011
1018
  }
1019
+ const requireToolApproval = activeRun.requireToolApproval;
1012
1020
  await this.drainReadableStream(activeRun.fullStream);
1013
1021
  const output = await agentState.agent.approveToolCall({
1014
1022
  runId: activeRun.runId,
@@ -1019,7 +1027,7 @@ export class MastraHarness {
1019
1027
  },
1020
1028
  requestContext: this.buildRequestContext(agentId, threadId),
1021
1029
  });
1022
- return this.toChatStreamResult(agentState, threadId, output);
1030
+ return this.toChatStreamResult(agentState, threadId, output, undefined, requireToolApproval);
1023
1031
  }
1024
1032
  /**
1025
1033
  * @requirements
@@ -1041,6 +1049,7 @@ export class MastraHarness {
1041
1049
  if (!activeRun) {
1042
1050
  throw new Error(`MastraHarness.declineToolCall(): no active run found for thread "${threadId}" on agent "${agentId}"`);
1043
1051
  }
1052
+ const requireToolApproval = activeRun.requireToolApproval;
1044
1053
  await this.drainReadableStream(activeRun.fullStream);
1045
1054
  const output = await agentState.agent.declineToolCall({
1046
1055
  runId: activeRun.runId,
@@ -1051,7 +1060,7 @@ export class MastraHarness {
1051
1060
  },
1052
1061
  requestContext: this.buildRequestContext(agentId, threadId),
1053
1062
  });
1054
- return this.toChatStreamResult(agentState, threadId, output);
1063
+ return this.toChatStreamResult(agentState, threadId, output, undefined, requireToolApproval);
1055
1064
  }
1056
1065
  // ── Message History ──────────────────────────────────────────────
1057
1066
  /**
@@ -1349,40 +1358,53 @@ export class MastraHarness {
1349
1358
  }
1350
1359
  });
1351
1360
  }
1352
- toChatStreamResult(agentState, threadId, output, abortSignal) {
1361
+ toChatStreamResult(agentState, threadId, output, abortSignal, requireToolApproval) {
1353
1362
  // Entries are keyed by threadId so each thread holds at most one entry.
1354
1363
  // Stale entries are overwritten here on the next stream; explicit cleanup
1355
1364
  // happens via destroyThread() / destroyAgent() / shutdown(). Proactive
1356
1365
  // removal on stream completion is not safe because the suspend/resume
1357
1366
  // flow (submitToolResult, approveToolCall) needs the entry after the stream closes.
1358
1367
  const newRun = { runId: output.runId, fullStream: output.fullStream };
1368
+ if (requireToolApproval) {
1369
+ newRun.requireToolApproval = true;
1370
+ // Deferred promise the generator races against reader.read() so the
1371
+ // timer wins without depending on the stream closing — Mastra's
1372
+ // abortRunStream is a silent no-op for suspended runs.
1373
+ let resolveTimedOut;
1374
+ newRun.timedOutSignal = new Promise((resolve) => {
1375
+ resolveTimedOut = resolve;
1376
+ });
1377
+ newRun.disposeTimeout = this.installApprovalTimeout(agentState, threadId, newRun, resolveTimedOut);
1378
+ }
1359
1379
  if (abortSignal) {
1360
1380
  newRun.disposeAbortListener = this.installPerRunAbortListener(agentState, threadId, newRun, abortSignal);
1361
1381
  }
1362
1382
  this.replaceActiveRun(agentState, threadId, newRun);
1363
1383
  const adapter = new MastraEventAdapter(this.buses.getLogBus());
1364
1384
  return {
1365
- eventStream: this.createEventStream(output.fullStream, adapter, agentState, abortSignal),
1385
+ eventStream: this.createEventStream(output.fullStream, adapter, agentState, newRun, abortSignal),
1366
1386
  textStream: this.readableStreamToAsyncGenerator(output.textStream),
1367
1387
  };
1368
1388
  }
1369
1389
  /**
1370
1390
  * Replace (or set) the active run for a thread, disposing any prior
1371
- * entry's per-run abort listener so the prior signal cannot fire on the
1372
- * new run.
1391
+ * entry's per-run abort listener and approval-timeout timer so neither
1392
+ * can fire on the new run.
1373
1393
  */
1374
1394
  replaceActiveRun(agentState, threadId, run) {
1375
1395
  const prior = agentState.activeRuns.get(threadId);
1376
1396
  prior?.disposeAbortListener?.();
1397
+ prior?.disposeTimeout?.();
1377
1398
  agentState.activeRuns.set(threadId, run);
1378
1399
  }
1379
1400
  /**
1380
1401
  * Remove the active run for a thread, disposing its per-run abort listener
1381
- * if present. Safe to call when no entry exists.
1402
+ * and approval-timeout timer if present. Safe to call when no entry exists.
1382
1403
  */
1383
1404
  removeActiveRun(agentState, threadId) {
1384
1405
  const prior = agentState.activeRuns.get(threadId);
1385
1406
  prior?.disposeAbortListener?.();
1407
+ prior?.disposeTimeout?.();
1386
1408
  agentState.activeRuns.delete(threadId);
1387
1409
  }
1388
1410
  /**
@@ -1432,6 +1454,7 @@ export class MastraHarness {
1432
1454
  // is responsible for its run.
1433
1455
  if (agentState.activeRuns.get(threadId) !== run)
1434
1456
  return;
1457
+ run.disposeTimeout?.();
1435
1458
  agentState.activeRuns.delete(threadId);
1436
1459
  agentState.agent.abortRunStream(run.runId);
1437
1460
  }
@@ -1449,6 +1472,63 @@ export class MastraHarness {
1449
1472
  }
1450
1473
  }
1451
1474
  }
1475
+ /**
1476
+ * Arm a one-shot tool-approval timer for `run`. Returns a disposer that
1477
+ * clears the timer; callers must invoke it whenever the entry is replaced
1478
+ * or removed so a stale timer cannot fire on an unrelated continuation.
1479
+ *
1480
+ * Closes #408 — without this, an unanswered `requireToolApproval: true`
1481
+ * request leaves Mastra's prepared run alive and `activeRuns[threadId]`
1482
+ * pointing at a `runId` the consumer never settles, so the suspended
1483
+ * workflow leaks until the manager shuts down. Mirrors the
1484
+ * `ClaudeStreamBroker` per-pending-approval timer in
1485
+ * `@salesforce/sfdx-agent-harness-claude`; the granularity differs (per
1486
+ * thread here, per `toolCallId` there) because Mastra suspends one tool
1487
+ * approval at a time per thread.
1488
+ */
1489
+ installApprovalTimeout(agentState, threadId, run, signalTimedOut) {
1490
+ const timer = setTimeout(() => this.handleApprovalTimeout(agentState, threadId, run, signalTimedOut), this.toolApprovalTimeoutMs);
1491
+ return () => clearTimeout(timer);
1492
+ }
1493
+ /**
1494
+ * Fire-once handler invoked when `installApprovalTimeout`'s timer expires
1495
+ * before the consumer calls `approveToolCall` / `declineToolCall` /
1496
+ * `submitToolResult`.
1497
+ *
1498
+ * Identity guard: if the entry has already been replaced or removed
1499
+ * (consumer settled in flight, abort fired, agent destroyed), no-op. Same
1500
+ * race posture as `handlePerRunAbort` and as Claude's
1501
+ * `ClaudeStreamBroker.handleApprovalTimeout` early-return.
1502
+ *
1503
+ * On fire: flag the run, drop the entry from `activeRuns` (so subsequent
1504
+ * `approveToolCall` etc. surface the SDK-level "no active run found"
1505
+ * guard), call `agent.abortRunStream(runId)` (best-effort — for a
1506
+ * suspended run this is a no-op since the prepared-runs registry has
1507
+ * already released it), and resolve the `timedOutSignal` so
1508
+ * `createEventStream`'s `Promise.race` against the next `reader.read()`
1509
+ * picks the timeout signal and yields the synthetic `error`
1510
+ * (`code: 'tool-approval-timeout'`) + `finish` terminal pair. The #409
1511
+ * abort path doesn't need a side signal because Mastra closes the
1512
+ * fullStream natively when the caller's `abortSignal` fires; the timeout
1513
+ * path has no such signal and Mastra's `abortRunStream` is a silent
1514
+ * no-op for suspended runs, so the generator must learn about the
1515
+ * timeout out-of-band.
1516
+ */
1517
+ handleApprovalTimeout(agentState, threadId, run, signalTimedOut) {
1518
+ if (agentState.activeRuns.get(threadId) !== run)
1519
+ return;
1520
+ run.timedOut = true;
1521
+ agentState.activeRuns.delete(threadId);
1522
+ try {
1523
+ agentState.agent.abortRunStream(run.runId);
1524
+ }
1525
+ catch (err) {
1526
+ this.buses
1527
+ .getLogBus()
1528
+ ?.warn(`Failed to abort suspended run "${run.runId}" on tool-approval timeout for thread "${threadId}": ${getErrorMessage(err)}`, { threadId, runId: run.runId, timeoutMs: this.toolApprovalTimeoutMs }, err instanceof Error ? err : new Error(String(err)));
1529
+ }
1530
+ signalTimedOut('timed-out');
1531
+ }
1452
1532
  async getMemoryOrThrow(agentId) {
1453
1533
  const agentState = this.getAgentStateOrThrow(agentId);
1454
1534
  const memory = await agentState.agent.getMemory();
@@ -1478,7 +1558,21 @@ export class MastraHarness {
1478
1558
  ctx.set(MASTRA_RESOURCE_ID_KEY, this.agentResourceId(agentId));
1479
1559
  return ctx;
1480
1560
  }
1481
- async *createEventStream(fullStream, adapter, agentState, abortSignal) {
1561
+ /**
1562
+ * Resolve when `signal` aborts; resolves immediately if already aborted.
1563
+ * Used in `createEventStream`'s suspended-wait branch so the generator
1564
+ * wakes on `controller.abort()` even though the underlying `fullStream`
1565
+ * has already returned `done` (Mastra closes the stream right after
1566
+ * `tool-call-approval`).
1567
+ */
1568
+ waitForAbort(signal) {
1569
+ if (signal.aborted)
1570
+ return Promise.resolve('aborted');
1571
+ return new Promise((resolve) => {
1572
+ signal.addEventListener('abort', () => resolve('aborted'), { once: true });
1573
+ });
1574
+ }
1575
+ async *createEventStream(fullStream, adapter, agentState, run, abortSignal) {
1482
1576
  // Tracks whether the consumer has observed a terminal event already.
1483
1577
  // Either `finish` (natural completion) or any `error` event counts —
1484
1578
  // the SDK's `wrapEventStream` treats any error as terminal and
@@ -1487,33 +1581,122 @@ export class MastraHarness {
1487
1581
  // per turn. The narrower "only abort-coded errors count" predicate
1488
1582
  // would mis-fire on tripwire/regular errors that race the abort.
1489
1583
  let sawTerminal = false;
1490
- for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
1491
- const event = adapter.toChatEvent(chunk);
1492
- if (!event)
1493
- continue;
1494
- if (event.type === 'finish' || event.type === 'error') {
1495
- sawTerminal = true;
1496
- }
1497
- if (isEnrichableToolEvent(event)) {
1498
- const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
1499
- const metadata = lookupMcpToolMetadata(agentState, toolName);
1500
- if (metadata) {
1501
- yield {
1502
- ...event,
1503
- serverName: metadata.serverName,
1504
- ...(metadata.annotations ? { annotations: metadata.annotations } : {}),
1505
- };
1584
+ // Tracks whether the consumer is currently sitting on a yielded
1585
+ // `tool-approval-request` with no settle/abort/timeout decision yet.
1586
+ // When Mastra suspends a run it closes the underlying `fullStream`
1587
+ // immediately after the `tool-call-approval` chunk — `reader.read()`
1588
+ // returns `{ done: true }` synchronously. If we treated that as
1589
+ // "stream completed", the timeout timer would never get a chance to
1590
+ // fire (the generator would exit before the timer's macrotask), and
1591
+ // the consumer's `for await` would terminate with no terminal event.
1592
+ // While suspended, wait on the per-run timeout signal instead — the
1593
+ // timer is the only thing that can move the run forward.
1594
+ let approvalPending = false;
1595
+ const reader = fullStream.getReader();
1596
+ // Wraps a `read()` so `Promise.race` can disambiguate which side won.
1597
+ // The timeout signal resolves to the literal `'timed-out'`; a `read()`
1598
+ // rejection is reported as `{ kind: 'rejected', err }` and the loop
1599
+ // body decides how to interpret it: silence ONLY when `run.timedOut`
1600
+ // is set (the lock-release race after the timer fires is benign);
1601
+ // anything else is a real upstream error that must propagate so the
1602
+ // SDK's `wrapEventStream` boundary can synthesize the terminal pair.
1603
+ const readChunk = () => reader.read().then((r) => (r.done ? { kind: 'done' } : { kind: 'chunk', value: r.value }), (err) => ({ kind: 'rejected', err }));
1604
+ try {
1605
+ while (true) {
1606
+ let outcome;
1607
+ if (approvalPending) {
1608
+ // Stream is suspended on a tool-approval. The underlying
1609
+ // `fullStream` returns `done` immediately after Mastra
1610
+ // emits `tool-call-approval` (snapshot is persisted, then
1611
+ // the stream closes), so we cannot rely on it for further
1612
+ // signal — wait for whichever side decides the run's fate
1613
+ // (timeout fire or caller's abortSignal). Continued reads
1614
+ // would loop returning `done` and burn CPU. If neither
1615
+ // signal is present (e.g. consumer set `requireToolApproval:
1616
+ // true` without an `abortSignal` on a harness with no
1617
+ // timeout), fall through to a plain `readChunk` — the
1618
+ // generator will exit cleanly on `done` and the consumer
1619
+ // gets no terminal, matching the pre-fix behavior for
1620
+ // that misconfiguration.
1621
+ const waiters = [];
1622
+ if (run.timedOutSignal)
1623
+ waiters.push(run.timedOutSignal);
1624
+ if (abortSignal)
1625
+ waiters.push(this.waitForAbort(abortSignal));
1626
+ outcome = waiters.length > 0 ? await Promise.race(waiters) : await readChunk();
1627
+ }
1628
+ else {
1629
+ // Race the next read against the per-run timeout signal so
1630
+ // the generator wakes up immediately when the timer fires —
1631
+ // Mastra's `abortRunStream` is a silent no-op for suspended
1632
+ // runs, so the underlying stream may never close on its
1633
+ // own.
1634
+ outcome = run.timedOutSignal
1635
+ ? await Promise.race([readChunk(), run.timedOutSignal])
1636
+ : await readChunk();
1637
+ }
1638
+ if (outcome === 'timed-out')
1639
+ break;
1640
+ if (outcome === 'aborted')
1641
+ break;
1642
+ if (outcome.kind === 'done')
1643
+ break;
1644
+ if (outcome.kind === 'rejected') {
1645
+ // Only the lock-release race after the timer fires should
1646
+ // be silenced; a real upstream error must surface so
1647
+ // `wrapEventStream` can emit `chat-stream-error` + finish.
1648
+ if (run.timedOut)
1649
+ break;
1650
+ throw outcome.err instanceof Error ? outcome.err : new Error(String(outcome.err));
1651
+ }
1652
+ const event = adapter.toChatEvent(outcome.value);
1653
+ if (!event)
1506
1654
  continue;
1655
+ if (event.type === 'finish' || event.type === 'error') {
1656
+ sawTerminal = true;
1657
+ // Stream is on its way out — disposing the timer here
1658
+ // prevents the 10-min armed-but-unused fire on streams
1659
+ // that completed naturally without ever pending an
1660
+ // approval. See ARCHITECTURE.md → "Approval timeout (#408)".
1661
+ run.disposeTimeout?.();
1507
1662
  }
1663
+ if (event.type === 'tool-approval-request') {
1664
+ // Mastra suspends the run after this chunk; the
1665
+ // underlying `fullStream` will return `done` next read.
1666
+ // Switch to "wait for timeout" mode so we don't exit the
1667
+ // generator before the timer can fire.
1668
+ approvalPending = true;
1669
+ }
1670
+ if (isEnrichableToolEvent(event)) {
1671
+ const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
1672
+ const metadata = lookupMcpToolMetadata(agentState, toolName);
1673
+ if (metadata) {
1674
+ yield {
1675
+ ...event,
1676
+ serverName: metadata.serverName,
1677
+ ...(metadata.annotations ? { annotations: metadata.annotations } : {}),
1678
+ };
1679
+ continue;
1680
+ }
1681
+ }
1682
+ yield event;
1508
1683
  }
1509
- yield event;
1510
1684
  }
1511
- // The fullStream closed. If abort fired during a suspended-run path
1512
- // (consumer aborted while a tool-approval was pending), Mastra
1513
- // releases the prepared run silently — the fullStream just ends with
1514
- // no `abort` chunk — so synthesize the terminal pair the SDK
1515
- // invariant requires. See ARCHITECTURE.md → "Tool Approval Flow →
1516
- // Abort during pending approval (#409)".
1685
+ finally {
1686
+ reader.releaseLock();
1687
+ }
1688
+ // The fullStream closed. If a suspended-run terminal cause fired —
1689
+ // consumer never answered a `requireToolApproval` request (#408) or
1690
+ // consumer aborted while a tool-approval was pending (#409) — Mastra
1691
+ // releases the prepared run silently, so the fullStream just ends
1692
+ // with no terminal chunk. Synthesize the terminal pair the SDK
1693
+ // invariant requires. See ARCHITECTURE.md → "Tool Approval Flow".
1694
+ //
1695
+ // Ordering: check `run.timedOut` BEFORE `abortSignal?.aborted` because
1696
+ // `handleApprovalTimeout` calls `agent.abortRunStream(...)`, which on
1697
+ // some Mastra paths flips the abort signal. Pinning timeout-first
1698
+ // means the consumer's `error.code` is the most informative one
1699
+ // (`'tool-approval-timeout'` rather than `'abort'`).
1517
1700
  //
1518
1701
  // Adapter-coupling note: this gate depends on `MastraEventAdapter`
1519
1702
  // not yielding a `finish` chunk between `tool-call-approval` and
@@ -1523,7 +1706,15 @@ export class MastraHarness {
1523
1706
  // would set `sawTerminal` early and silently suppress the synth.
1524
1707
  // If that lands, switch the predicate to track only the terminal
1525
1708
  // `finish` (e.g. via a per-stream "suspended" flag).
1526
- if (!sawTerminal && abortSignal?.aborted) {
1709
+ if (!sawTerminal && run.timedOut) {
1710
+ yield {
1711
+ type: 'error',
1712
+ error: new Error(`Tool approval timed out after ${this.toolApprovalTimeoutMs}ms.`),
1713
+ code: 'tool-approval-timeout',
1714
+ };
1715
+ yield { type: 'finish', finishReason: 'error' };
1716
+ }
1717
+ else if (!sawTerminal && abortSignal?.aborted) {
1527
1718
  yield {
1528
1719
  type: 'error',
1529
1720
  error: new DOMException('The stream was aborted.', 'AbortError'),
@@ -13,7 +13,10 @@ export declare function hasEnabledServers(config: MCPConfiguration | undefined):
13
13
  * - `MCPStdioServerConfig` maps to a Mastra stdio definition (`command`, `args`, `env`).
14
14
  * - `MCPRemoteServerConfig` maps to a Mastra HTTP definition (`url` as a `URL` object,
15
15
  * `headers` forwarded via `requestInit`).
16
- * - Per-server `timeout` is preserved when provided.
16
+ * - Per-server `timeout` is preserved when provided. For remote servers it is bound to both
17
+ * Mastra's `timeout` (post-connect request lifetime) and `connectTimeout` (initial transport
18
+ * handshake) so the configured value covers both phases — Mastra's hardcoded 3s
19
+ * `connectTimeout` default would otherwise abort cold-start handshakes before `timeout` applied.
17
20
  * - When `orgJwt` is provided, remote servers get a `fetch` that delegates to
18
21
  * {@link resolveMcpServerHeaders} per-request to inject auth for Salesforce Platform URLs.
19
22
  */
@@ -19,7 +19,10 @@ export function hasEnabledServers(config) {
19
19
  * - `MCPStdioServerConfig` maps to a Mastra stdio definition (`command`, `args`, `env`).
20
20
  * - `MCPRemoteServerConfig` maps to a Mastra HTTP definition (`url` as a `URL` object,
21
21
  * `headers` forwarded via `requestInit`).
22
- * - Per-server `timeout` is preserved when provided.
22
+ * - Per-server `timeout` is preserved when provided. For remote servers it is bound to both
23
+ * Mastra's `timeout` (post-connect request lifetime) and `connectTimeout` (initial transport
24
+ * handshake) so the configured value covers both phases — Mastra's hardcoded 3s
25
+ * `connectTimeout` default would otherwise abort cold-start handshakes before `timeout` applied.
23
26
  * - When `orgJwt` is provided, remote servers get a `fetch` that delegates to
24
27
  * {@link resolveMcpServerHeaders} per-request to inject auth for Salesforce Platform URLs.
25
28
  */
@@ -46,7 +49,10 @@ export function mapToMastraMCPServers(config, orgJwt) {
46
49
  requestInit: { headers: serverConfig.headers },
47
50
  }),
48
51
  ...(orgJwt && { fetch: createAuthFetch(serverConfig.url, orgJwt, serverConfig.headers) }),
49
- ...(serverConfig.timeout !== undefined && { timeout: serverConfig.timeout }),
52
+ ...(serverConfig.timeout !== undefined && {
53
+ timeout: serverConfig.timeout,
54
+ connectTimeout: serverConfig.timeout,
55
+ }),
50
56
  };
51
57
  result[name] = def;
52
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "@salesforce/llm-gateway-sdk": "0.8.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@salesforce/sfdx-agent-sdk": "0.11.0"
41
+ "@salesforce/sfdx-agent-sdk": "0.12.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "^10.0.1",
45
- "@salesforce/sfdx-agent-sdk": "0.11.0",
45
+ "@salesforce/sfdx-agent-sdk": "0.12.0",
46
46
  "@types/node": "^22.19.17",
47
47
  "@vitest/coverage-istanbul": "^4.1.7",
48
48
  "@vitest/eslint-plugin": "^1.6.17",