@salesforce/sfdx-agent-harness-mastra 0.89.0 → 0.91.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/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.91.0] - 2026-09-24
7
+
8
+ ### Features
9
+ - **agent-sdk,harness-mastra,harness-openai,harness-claude**: stable chatId across compaction — compact in place, never rotate the thread id @W-24153851@ ([#840](https://github.com/forcedotcom/agentic-dx/pull/840))
10
+
11
+ ## [0.90.0] - 2026-09-23
12
+
13
+ ### Fixes
14
+ - **harness-mastra**: emit full llm-request body in wire-communication events (remove 256KB truncation) @W-24280587@ ([#839](https://github.com/forcedotcom/agentic-dx/pull/839))
15
+
16
+ ### Chores
17
+ - **deps**: bump @ai-sdk/provider from 4.0.4 to 4.0.14 ([#838](https://github.com/forcedotcom/agentic-dx/pull/838))
18
+
6
19
  ## [0.89.0] - 2026-09-21
7
20
 
8
21
  ### Features
@@ -464,17 +464,17 @@ export declare class MastraHarness implements MastraAgentHarness {
464
464
  /**
465
465
  * @requirements
466
466
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
467
- * - MUST create a new thread seeded with an LLM-generated summary.
467
+ * - MUST compact the thread IN PLACE, keeping the same `threadId` (a SQLite
468
+ * row PK) so `getId()` never changes across compaction (W-24153851).
468
469
  * - MUST chunk-summarize transcripts that exceed `MAX_TRANSCRIPT_CHARS`
469
470
  * to keep any single model call inside the context window.
470
- * - MUST surface summarization or seeding failures as `AgentSDKError`
471
- * with type `COMPACTION_FAILED` and leave the source thread intact on
472
- * failure.
473
- * - MUST destroy the source thread on success so it no longer appears in
474
- * `getThreadIds()`. Cleanup of the source thread is best-effort: if the
475
- * delete fails the new thread is still returned, since the user's data
476
- * has already been persisted there. An orphaned source thread is
477
- * hygiene, not correctness.
471
+ * - MUST surface summarization or commit failures as `AgentSDKError`
472
+ * with type `COMPACTION_FAILED` and leave the transcript intact on failure.
473
+ * - MUST be fail-closed: summarize FIRST (read-only), then
474
+ * append-the-summary-then-delete-the-originals (append-then-delete-by-
475
+ * captured-id, NOT a re-recall). LibSQL has no atomic multi-row swap, so a
476
+ * crash after the append leaves BOTH the summary and the originals
477
+ * (retryable, larger-but-valid) — never an empty thread.
478
478
  * - MUST seed the summary as a user/assistant turn pair where the user
479
479
  * turn carries the summary content and the assistant turn is a brief
480
480
  * acknowledgment. Storing the summary as an assistant turn (even with
@@ -483,7 +483,7 @@ export declare class MastraHarness implements MastraAgentHarness {
483
483
  * it as fabricated, refusing to use the facts on follow-up turns.
484
484
  * User turns are treated as authoritative input.
485
485
  */
486
- compactThread(agentId: string, threadId: string): Promise<string>;
486
+ compactThread(agentId: string, threadId: string): Promise<void>;
487
487
  /**
488
488
  * Best-effort wrapper around `destroyThread`: swallows the error so the
489
489
  * caller's outer flow isn't aborted by a teardown blip, but logs the
@@ -1429,17 +1429,17 @@ export class MastraHarness {
1429
1429
  /**
1430
1430
  * @requirements
1431
1431
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
1432
- * - MUST create a new thread seeded with an LLM-generated summary.
1432
+ * - MUST compact the thread IN PLACE, keeping the same `threadId` (a SQLite
1433
+ * row PK) so `getId()` never changes across compaction (W-24153851).
1433
1434
  * - MUST chunk-summarize transcripts that exceed `MAX_TRANSCRIPT_CHARS`
1434
1435
  * to keep any single model call inside the context window.
1435
- * - MUST surface summarization or seeding failures as `AgentSDKError`
1436
- * with type `COMPACTION_FAILED` and leave the source thread intact on
1437
- * failure.
1438
- * - MUST destroy the source thread on success so it no longer appears in
1439
- * `getThreadIds()`. Cleanup of the source thread is best-effort: if the
1440
- * delete fails the new thread is still returned, since the user's data
1441
- * has already been persisted there. An orphaned source thread is
1442
- * hygiene, not correctness.
1436
+ * - MUST surface summarization or commit failures as `AgentSDKError`
1437
+ * with type `COMPACTION_FAILED` and leave the transcript intact on failure.
1438
+ * - MUST be fail-closed: summarize FIRST (read-only), then
1439
+ * append-the-summary-then-delete-the-originals (append-then-delete-by-
1440
+ * captured-id, NOT a re-recall). LibSQL has no atomic multi-row swap, so a
1441
+ * crash after the append leaves BOTH the summary and the originals
1442
+ * (retryable, larger-but-valid) — never an empty thread.
1443
1443
  * - MUST seed the summary as a user/assistant turn pair where the user
1444
1444
  * turn carries the summary content and the assistant turn is a brief
1445
1445
  * acknowledgment. Storing the summary as an assistant turn (even with
@@ -1449,49 +1449,54 @@ export class MastraHarness {
1449
1449
  * User turns are treated as authoritative input.
1450
1450
  */
1451
1451
  async compactThread(agentId, threadId) {
1452
- this.getAgentStateOrThrow(agentId);
1453
- const messages = await this.getMessages(agentId, threadId);
1454
- if (messages.length === 0) {
1455
- const newThreadId = await this.createThread(agentId);
1456
- // An empty transcript can still carry session context (it's set
1457
- // independently of messages), so carry it forward here too — before
1458
- // the source is retired, and fail-closed (source left intact on a
1459
- // copy failure) to match the main path's COMPACTION_FAILED contract.
1460
- try {
1461
- await this.carrySessionContextForward(agentId, threadId, newThreadId);
1462
- }
1463
- catch (err) {
1464
- // The new thread was already created; a carry-forward failure
1465
- // orphans it (never returned, never retired). Delete it before
1466
- // surfacing COMPACTION_FAILED so a failed compaction leaves no
1467
- // trace beyond the still-intact source thread.
1468
- await this.destroyThreadBestEffort(agentId, newThreadId);
1469
- throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1470
- }
1471
- // Empty source still gets compacted away — leaving it attached
1472
- // would be a leak. Mirrors the non-empty path's destroyThread
1473
- // call below; both paths exit with the source detached.
1474
- await this.destroyThreadBestEffort(agentId, threadId);
1475
- return newThreadId;
1452
+ const agentState = this.getAgentStateOrThrow(agentId);
1453
+ // Defense-in-depth against a direct-SDK caller: refuse to rewrite the
1454
+ // transcript out from under an in-flight turn. The SDK's one-turn guard
1455
+ // (auto path) and the service's 409 on an explicit /compact during a turn
1456
+ // already exclude this upstream. Fail fast rather than dispose a live
1457
+ // coordinator (that surfaces a spurious DISPOSED — W-24044572). Mirrors
1458
+ // stream()'s guard.
1459
+ const priorCoordinator = agentState.coordinators.get(threadId);
1460
+ if (priorCoordinator && !priorCoordinator.isSettled()) {
1461
+ throw new AgentSDKError(`A turn is already in progress for thread "${threadId}" on agent "${agentId}". ` +
1462
+ 'Compaction cannot run while a turn is active; await the in-flight turn first.', AgentSDKErrorType.SESSION_BUSY);
1476
1463
  }
1464
+ const messages = await this.getMessages(agentId, threadId);
1465
+ // Empty source: nothing to compact. In-place keeps the same threadId and
1466
+ // its (physically-disjoint) session-context store, so this is a no-op.
1467
+ if (messages.length === 0)
1468
+ return;
1477
1469
  const transcript = messages
1478
1470
  .map((m) => {
1479
1471
  const text = typeof m.content === 'string' ? m.content : m.content.map((p) => partToTranscriptText(p)).join(' ');
1480
1472
  return `${m.role}: ${text}`;
1481
1473
  })
1482
1474
  .join('\n');
1483
- // `undefined` until `createThread` succeeds, so the catch can tell a
1484
- // pre-create failure (nothing to clean up) from a post-create failure
1485
- // (the new thread is orphaned and must be deleted).
1486
- let newThreadId;
1487
1475
  try {
1476
+ // Capture the ORIGINAL persisted (raw) message ids to delete after
1477
+ // the summary is appended. Raw recall ids — not `getMessages()`'s
1478
+ // post-mapped ids, which split a tool call into two SDK messages —
1479
+ // are what `memory.deleteMessages` keys on. Captured before the
1480
+ // append so the summary pair's freshly-minted ids are never in the
1481
+ // delete set.
1482
+ const memory = await this.getMemoryOrThrow(agentId);
1483
+ const { messages: rawMessages } = await memory.recall({
1484
+ threadId,
1485
+ resourceId: this.agentResourceId(agentId),
1486
+ perPage: false,
1487
+ });
1488
+ const originalIds = rawMessages.map((m) => m.id);
1489
+ // Summarize FIRST (read-only). No mutation has happened yet, so a
1490
+ // summary failure leaves the transcript fully intact.
1488
1491
  const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
1489
1492
  ? await this.summarize(agentId, buildSummaryPrompt(transcript))
1490
1493
  : await this.summarizeInChunks(agentId, transcript);
1491
- newThreadId = await this.createThread(agentId);
1494
+ // Append the summary pair to the SAME thread, THEN delete the
1495
+ // originals by captured id. A crash between the two leaves both
1496
+ // (retryable), never an empty thread.
1492
1497
  const userTs = this.clock.now();
1493
1498
  const assistantTs = this.clock.nextAfter(userTs);
1494
- await this.addMessages(agentId, newThreadId, [
1499
+ await this.addMessages(agentId, threadId, [
1495
1500
  {
1496
1501
  id: this.idGenerator.getUniqueId(),
1497
1502
  role: 'user',
@@ -1505,26 +1510,18 @@ export class MastraHarness {
1505
1510
  createdAt: assistantTs,
1506
1511
  },
1507
1512
  ]);
1508
- // Carry the source thread's session context onto the compacted thread
1509
- // before the source is retired. Inside the try so a copy failure is
1510
- // fail-closed (surfaces as COMPACTION_FAILED, source left intact).
1511
- await this.carrySessionContextForward(agentId, threadId, newThreadId);
1513
+ if (originalIds.length > 0) {
1514
+ await memory.deleteMessages(originalIds);
1515
+ }
1512
1516
  }
1513
1517
  catch (err) {
1514
- // If the failure came after `createThread`, the new thread is
1515
- // orphaned (never returned to the caller, source not yet retired).
1516
- // Delete it so a failed compaction leaves no trace beyond the
1517
- // still-intact source thread.
1518
- if (newThreadId !== undefined) {
1519
- await this.destroyThreadBestEffort(agentId, newThreadId);
1520
- }
1521
1518
  throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1522
1519
  }
1523
- // Source-thread cleanup is best-effort. The user's data is already
1524
- // safely seeded into newThreadId; a failed delete leaves an orphan
1525
- // but does not invalidate the compaction outcome.
1526
- await this.destroyThreadBestEffort(agentId, threadId);
1527
- return newThreadId;
1520
+ // Reset the per-thread consumer-tool retry counter — the rotation-based
1521
+ // compaction used to get this for free via destroyThread; an in-place
1522
+ // compaction keeps the thread, so reset it explicitly. The transcript the
1523
+ // counts described is gone.
1524
+ this.toolArgsRetryCounts.get(agentId)?.delete(threadId);
1528
1525
  }
1529
1526
  /**
1530
1527
  * Best-effort wrapper around `destroyThread`: swallows the error so the
@@ -280,11 +280,13 @@ export type GatewayFetchWireHooks = {
280
280
  * fetch resolution to HTTP-body terminality: an owned tee branch observes only
281
281
  * recognized assistant text deltas without blocking the provider SDK's branch.
282
282
  * Wire-communication events are best-effort: a failed body decode does NOT
283
- * prevent the call from completing. Body capture is
284
- * intentionally bounded — only requests with a JSON body shorter than 256 KB
285
- * are decoded; longer bodies are recorded as `{ truncated: true, byteLength:
286
- * <n> }` so wire-communication captures don't blow the consumer's log file
287
- * budget.
283
+ * prevent the call from completing. When a subscriber is present the request
284
+ * body is captured in full with no size cap (W-24280587) — the channel is
285
+ * opt-in and diagnostic, so fidelity wins; with no subscriber the decode is
286
+ * skipped entirely (the event is dropped by the wire bus anyway), so a
287
+ * subscriber-less production process pays no full-body parse on the hot path.
288
+ * (The streaming *response* observer separately bounds its in-memory text
289
+ * capture at `TRACE_BODY_BYTE_BUDGET`.)
288
290
  */
289
291
  export declare function createGatewayFetch(getHeaders: () => Record<string, string> | Promise<Record<string, string>>, wireHooks?: GatewayFetchWireHooks, innerFetch?: typeof fetch,
290
292
  /**
@@ -353,11 +353,13 @@ export function mapOpenAIUsage(usage) {
353
353
  * fetch resolution to HTTP-body terminality: an owned tee branch observes only
354
354
  * recognized assistant text deltas without blocking the provider SDK's branch.
355
355
  * Wire-communication events are best-effort: a failed body decode does NOT
356
- * prevent the call from completing. Body capture is
357
- * intentionally bounded — only requests with a JSON body shorter than 256 KB
358
- * are decoded; longer bodies are recorded as `{ truncated: true, byteLength:
359
- * <n> }` so wire-communication captures don't blow the consumer's log file
360
- * budget.
356
+ * prevent the call from completing. When a subscriber is present the request
357
+ * body is captured in full with no size cap (W-24280587) — the channel is
358
+ * opt-in and diagnostic, so fidelity wins; with no subscriber the decode is
359
+ * skipped entirely (the event is dropped by the wire bus anyway), so a
360
+ * subscriber-less production process pays no full-body parse on the hot path.
361
+ * (The streaming *response* observer separately bounds its in-memory text
362
+ * capture at `TRACE_BODY_BYTE_BUDGET`.)
361
363
  */
362
364
  export function createGatewayFetch(getHeaders, wireHooks, innerFetch = globalThis.fetch,
363
365
  /**
@@ -411,6 +413,13 @@ logBus) {
411
413
  const startedAt = clock.now();
412
414
  const correlationId = (wireHooks.idGenerator ?? new UUIDGenerator()).getUniqueId();
413
415
  const requestStart = performance.now();
416
+ // Decode the (now uncapped) request body ONLY when a subscriber is
417
+ // listening — mirroring the response-observer gating below. In production
418
+ // `wireHooks` is always supplied but subscribers are usually absent, and a
419
+ // real turn's body is large, so an unconditional full-body JSON.parse would
420
+ // be pure hot-path waste for a subscriber-less process (the event is dropped
421
+ // by the wire bus anyway). Fidelity for actual subscribers is unchanged.
422
+ const hasWireSubscribers = wireHooks.hasWireCommunicationSubscribers?.() ?? true;
414
423
  wireHooks.emitWireCommunication({
415
424
  type: 'llm-request',
416
425
  timestamp: startedAt,
@@ -418,7 +427,7 @@ logBus) {
418
427
  method,
419
428
  model,
420
429
  correlationId,
421
- body: tryDecodeBody(finalInit.body),
430
+ body: hasWireSubscribers ? tryDecodeBody(finalInit.body) : undefined,
422
431
  });
423
432
  try {
424
433
  const response = await innerFetch(input, finalInit);
@@ -791,10 +800,11 @@ function tryDecodeBody(body) {
791
800
  return undefined;
792
801
  if (typeof body !== 'string')
793
802
  return undefined; // Streams / FormData / Blob: skip.
794
- const byteLength = Buffer.byteLength(body, 'utf8');
795
- if (byteLength > TRACE_BODY_BYTE_BUDGET) {
796
- return { truncated: true, byteLength };
797
- }
803
+ // No size cap: the wire-communication channel is opt-in, subscriber-gated, and
804
+ // purely diagnostic — its whole point is fidelity, so emit the full request body
805
+ // (system / messages / tools) verbatim. (W-24280587; a 256 KB cap here used to
806
+ // replace large bodies with a `{ truncated, byteLength }` stub, hiding exactly
807
+ // the fields the channel exists to show.)
798
808
  try {
799
809
  const parsed = JSON.parse(body);
800
810
  return typeof parsed === 'object' && parsed !== null ? parsed : { value: parsed };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "LICENSE.txt"
30
30
  ],
31
31
  "dependencies": {
32
- "@ai-sdk/provider": "4.0.4",
32
+ "@ai-sdk/provider": "4.0.14",
33
33
  "@anthropic-ai/bedrock-sdk": "^0.33.6",
34
34
  "@anthropic-ai/sdk": "^0.126.0",
35
35
  "@mastra/core": "^1.67.0",
@@ -42,12 +42,12 @@
42
42
  "openai": "^7.17.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.90.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.92.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",
49
49
  "@salesforce/harness-conformance": "0.0.1",
50
- "@salesforce/sfdx-agent-sdk": "0.90.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.92.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^5.0.1",
53
53
  "@vitest/eslint-plugin": "^1.6.27",