@salesforce/sfdx-agent-harness-mastra 0.52.0 → 0.54.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 +11 -0
- package/dist/mastra-harness.d.ts +62 -23
- package/dist/mastra-harness.js +153 -35
- package/dist/mastra-message-mapper.d.ts +4 -2
- package/dist/mastra-message-mapper.js +11 -4
- package/dist/session-context-store.d.ts +31 -58
- package/dist/session-context-store.js +88 -79
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.54.0] - 2026-08-25
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
- **harness-mastra**: store session context in a harness-owned per-thread store @W-23632686@ ([#765](https://github.com/forcedotcom/agentic-dx/pull/765))
|
|
10
|
+
|
|
11
|
+
## [0.53.0] - 2026-08-25
|
|
12
|
+
|
|
13
|
+
### Chores
|
|
14
|
+
- **deps-dev**: bump @vitest/eslint-plugin from 1.6.26 to 1.6.27 in the vitest group across 1 directory ([#762](https://github.com/forcedotcom/agentic-dx/pull/762))
|
|
15
|
+
- **deps-dev**: bump globals from 17.9.0 to 17.11.0 in the dev-dependencies group ([#761](https://github.com/forcedotcom/agentic-dx/pull/761))
|
|
16
|
+
|
|
6
17
|
## [0.52.0] - 2026-08-24
|
|
7
18
|
|
|
8
19
|
_No changes — released alongside dependent packages._
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -12,6 +12,19 @@ import type { LanguageModelV3 } from '@ai-sdk/provider';
|
|
|
12
12
|
import { Clock, type LogRecord, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
|
|
13
13
|
import { type ToolsInput } from './utils.js';
|
|
14
14
|
import { Workspace } from '@mastra/core/workspace';
|
|
15
|
+
/**
|
|
16
|
+
* Render a session-context object into the system text injected via `stream()`'s
|
|
17
|
+
* `system:` option — Mastra folds it into a `"user-provided"` system block,
|
|
18
|
+
* disjoint from `instructions` and the model's Working Memory `"memory"` block.
|
|
19
|
+
* Canonicalizes through the store's `sortJsonValue` so equal contexts render
|
|
20
|
+
* identically; `{}` renders to `''` so `stream()` omits the option entirely.
|
|
21
|
+
*
|
|
22
|
+
* The canonical JSON is emitted with `<`/`>` unicode-escaped (`\u003c` /
|
|
23
|
+
* `\u003e`). This lands in a system message, so a consumer value can't forge the
|
|
24
|
+
* `</session_context>` boundary — escaping the rendered form only, never the
|
|
25
|
+
* stored object's round-trip fidelity.
|
|
26
|
+
*/
|
|
27
|
+
export declare function renderSessionContextSystemText(context: SessionContext): string;
|
|
15
28
|
/**
|
|
16
29
|
* Per-server MCP state. Each enabled server gets its own MCPClient and
|
|
17
30
|
* independent discovery promise so readiness is tracked individually.
|
|
@@ -250,21 +263,12 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
250
263
|
private readonly mastra;
|
|
251
264
|
private readonly buses;
|
|
252
265
|
/**
|
|
253
|
-
* Harness-owned
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* INTERIM: the session-context spike prescribes Mastra persist this over
|
|
259
|
-
* Working Memory (`memory.updateWorkingMemory` / `getWorkingMemory`, JSON
|
|
260
|
-
* mode — its native, already-enabled channel), not a sidecar. The sidecar
|
|
261
|
-
* ships here so the shared `runSessionContextConformance` persistence gate is
|
|
262
|
-
* green on all three harnesses in this milestone; W-23632686 replaces this
|
|
263
|
-
* store with Working Memory (Claude/OpenAI keep their sidecars). The
|
|
264
|
-
* consumer-visible contract — whole-object set, `{}`-on-miss, absent from
|
|
265
|
-
* `getMessages` — is identical across both, so the swap is internal.
|
|
266
|
+
* Harness-owned store backing `setSessionContext` / `getSessionContext` —
|
|
267
|
+
* disjoint from Mastra's Working Memory (`this.memory`), so it shares no
|
|
268
|
+
* value with the model's native `updateWorkingMemory` writes and the two
|
|
269
|
+
* can't race. See the session-context Critical Invariant in ARCHITECTURE.md.
|
|
266
270
|
*/
|
|
267
|
-
private readonly
|
|
271
|
+
private readonly sessionContextStore;
|
|
268
272
|
/**
|
|
269
273
|
* Mastra-specific extension surface, typed off `MastraAgentHarness`. The
|
|
270
274
|
* SDK never reads this; consumers reach through `manager.extensions.mastra`.
|
|
@@ -395,6 +399,9 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
395
399
|
* @requirements
|
|
396
400
|
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
397
401
|
* - MUST clone the source thread's messages into a new thread.
|
|
402
|
+
* - MUST copy the source thread's stored session context onto the clone —
|
|
403
|
+
* Mastra's native clone copies the thread row but not the harness-owned
|
|
404
|
+
* store, so without this the clone would silently start `{}`.
|
|
398
405
|
*/
|
|
399
406
|
cloneThread(agentId: string, sourceThreadId: string): Promise<string>;
|
|
400
407
|
/**
|
|
@@ -552,20 +559,52 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
552
559
|
/** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
|
|
553
560
|
addContext(agentId: string, threadId: string, messages: Message[]): Promise<void>;
|
|
554
561
|
/**
|
|
555
|
-
* Replace this thread's session-context object in full
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* `
|
|
559
|
-
*
|
|
562
|
+
* Replace this thread's consumer session-context object in full (whole-object
|
|
563
|
+
* set, not a merge), persisted to the harness-owned store — disjoint from
|
|
564
|
+
* Mastra's Working Memory, so it never races the model's native
|
|
565
|
+
* `updateWorkingMemory` writes and never surfaces in `getMessages()`.
|
|
566
|
+
* `getAgentStateOrThrow` first (`AGENT_NOT_FOUND`); then create-first via
|
|
567
|
+
* {@link assertThreadExists} (the store itself has no thread concept), which
|
|
568
|
+
* throws `THREAD_NOT_FOUND` for an uncreated thread. Size/depth guards live in
|
|
569
|
+
* `SessionContextStore.write`.
|
|
560
570
|
*/
|
|
561
571
|
setSessionContext(agentId: string, threadId: string, content: SessionContext): Promise<void>;
|
|
562
572
|
/**
|
|
563
|
-
* This thread's
|
|
564
|
-
*
|
|
565
|
-
* throws on a miss
|
|
566
|
-
* store
|
|
573
|
+
* This thread's consumer session-context object read back from the
|
|
574
|
+
* harness-owned session context store, or `{}` when nothing has been set.
|
|
575
|
+
* Never `null`/`undefined`, never throws on a miss (corrupt files are
|
|
576
|
+
* soft-skipped to `{}` and logged inside the store). `getAgentStateOrThrow`
|
|
577
|
+
* runs first so an unregistered agent surfaces `AGENT_NOT_FOUND` before any
|
|
578
|
+
* store touch.
|
|
567
579
|
*/
|
|
568
580
|
getSessionContext(agentId: string, threadId: string): Promise<SessionContext>;
|
|
581
|
+
/**
|
|
582
|
+
* Create-first guard for `setSessionContext`: throws typed `THREAD_NOT_FOUND`
|
|
583
|
+
* when `threadId` was never created *for this agent* (Mastra's `Memory` is the
|
|
584
|
+
* thread registry). We must enforce the `resourceId` scope ourselves — Mastra's
|
|
585
|
+
* `getThreadById({ threadId, resourceId })` wrapper (`@mastra/core` 1.55) drops
|
|
586
|
+
* the `resourceId` arg, so on a shared `Memory` agent A's thread would satisfy
|
|
587
|
+
* a raw check for agent B. The returned `StorageThreadType` carries
|
|
588
|
+
* `resourceId`, so we assert it matches (consistent with `getThreadIds`'
|
|
589
|
+
* scoping). Only reachable when a caller reuses an id across agents; the SDK's
|
|
590
|
+
* `ChatSession` path always mints UUIDs.
|
|
591
|
+
*/
|
|
592
|
+
private assertThreadExists;
|
|
593
|
+
/**
|
|
594
|
+
* The system text this thread's stored session context would inject on the
|
|
595
|
+
* next turn — renders via the SAME {@link renderSessionContextSystemText}
|
|
596
|
+
* `stream()` calls, so this seam observes exactly what a turn would inject.
|
|
597
|
+
* Not on the `AgentHarness` contract: its only caller is this harness's
|
|
598
|
+
* conformance seam (`test/conformance` supplies `renderSystemContext`).
|
|
599
|
+
*/
|
|
600
|
+
renderSessionContextSystemContext(agentId: string, threadId: string): Promise<string>;
|
|
601
|
+
/**
|
|
602
|
+
* Copy a thread's stored session context onto another thread — used by
|
|
603
|
+
* `compactThread` (onto the compacted thread before the source retires) and
|
|
604
|
+
* `cloneThread` (Mastra's native clone doesn't know about this store). Skips
|
|
605
|
+
* the write when the source is `{}`, since a missing file already reads `{}`.
|
|
606
|
+
*/
|
|
607
|
+
private carrySessionContextForward;
|
|
569
608
|
/**
|
|
570
609
|
* Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
|
|
571
610
|
* summarization. Used internally by `compactThread()`; not part of the
|
package/dist/mastra-harness.js
CHANGED
|
@@ -15,10 +15,10 @@ import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, McpServerStatus, r
|
|
|
15
15
|
import { HarnessBusOwner, lowerStreamInput, mcpServerConfigEqual, mergeToolResultsIntoAssistant, splitToolResultsIntoToolMessages, } from '@salesforce/sfdx-agent-sdk/harness';
|
|
16
16
|
import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
|
|
17
17
|
import { MASTRA_BUILT_IN_TOOL_POLICIES } from './mastra-built-in-policies.js';
|
|
18
|
-
import { SessionContextStore } from './session-context-store.js';
|
|
19
18
|
import { SKILL_SEARCH_META_TOOL_NAMES, TOOL_SEARCH_META_TOOL_NAMES } from './skill-bridge.js';
|
|
20
19
|
import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
|
|
21
20
|
import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
|
|
21
|
+
import { SessionContextStore, sortJsonValue } from './session-context-store.js';
|
|
22
22
|
import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
|
|
23
23
|
import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
|
|
24
24
|
import { buildMcpErrorDetail, sanitizeMcpErrorMessage, synthesizeHttpTransportError } from './mcp-error-classifier.js';
|
|
@@ -64,6 +64,26 @@ function stripMastraWorkspacePrefix(name) {
|
|
|
64
64
|
function isDisposedError(err) {
|
|
65
65
|
return err instanceof AgentSDKError && err.type === AgentSDKErrorType.DISPOSED;
|
|
66
66
|
}
|
|
67
|
+
/** Opening/closing tags wrapping the rendered session-context block so the model can recognize it as standing context, distinct from its own Working Memory. */
|
|
68
|
+
const SESSION_CONTEXT_TAG = 'session_context';
|
|
69
|
+
/**
|
|
70
|
+
* Render a session-context object into the system text injected via `stream()`'s
|
|
71
|
+
* `system:` option — Mastra folds it into a `"user-provided"` system block,
|
|
72
|
+
* disjoint from `instructions` and the model's Working Memory `"memory"` block.
|
|
73
|
+
* Canonicalizes through the store's `sortJsonValue` so equal contexts render
|
|
74
|
+
* identically; `{}` renders to `''` so `stream()` omits the option entirely.
|
|
75
|
+
*
|
|
76
|
+
* The canonical JSON is emitted with `<`/`>` unicode-escaped (`\u003c` /
|
|
77
|
+
* `\u003e`). This lands in a system message, so a consumer value can't forge the
|
|
78
|
+
* `</session_context>` boundary — escaping the rendered form only, never the
|
|
79
|
+
* stored object's round-trip fidelity.
|
|
80
|
+
*/
|
|
81
|
+
export function renderSessionContextSystemText(context) {
|
|
82
|
+
if (Object.keys(context).length === 0)
|
|
83
|
+
return '';
|
|
84
|
+
const canonical = JSON.stringify(sortJsonValue(context, 0)).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
85
|
+
return `<${SESSION_CONTEXT_TAG}>\n${canonical}\n</${SESSION_CONTEXT_TAG}>`;
|
|
86
|
+
}
|
|
67
87
|
/**
|
|
68
88
|
* Mastra-backed implementation of {@link AgentHarness}.
|
|
69
89
|
*
|
|
@@ -368,21 +388,12 @@ export class MastraHarness {
|
|
|
368
388
|
mastra;
|
|
369
389
|
buses = new HarnessBusOwner();
|
|
370
390
|
/**
|
|
371
|
-
* Harness-owned
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* INTERIM: the session-context spike prescribes Mastra persist this over
|
|
377
|
-
* Working Memory (`memory.updateWorkingMemory` / `getWorkingMemory`, JSON
|
|
378
|
-
* mode — its native, already-enabled channel), not a sidecar. The sidecar
|
|
379
|
-
* ships here so the shared `runSessionContextConformance` persistence gate is
|
|
380
|
-
* green on all three harnesses in this milestone; W-23632686 replaces this
|
|
381
|
-
* store with Working Memory (Claude/OpenAI keep their sidecars). The
|
|
382
|
-
* consumer-visible contract — whole-object set, `{}`-on-miss, absent from
|
|
383
|
-
* `getMessages` — is identical across both, so the swap is internal.
|
|
391
|
+
* Harness-owned store backing `setSessionContext` / `getSessionContext` —
|
|
392
|
+
* disjoint from Mastra's Working Memory (`this.memory`), so it shares no
|
|
393
|
+
* value with the model's native `updateWorkingMemory` writes and the two
|
|
394
|
+
* can't race. See the session-context Critical Invariant in ARCHITECTURE.md.
|
|
384
395
|
*/
|
|
385
|
-
|
|
396
|
+
sessionContextStore;
|
|
386
397
|
/**
|
|
387
398
|
* Mastra-specific extension surface, typed off `MastraAgentHarness`. The
|
|
388
399
|
* SDK never reads this; consumers reach through `manager.extensions.mastra`.
|
|
@@ -435,7 +446,16 @@ export class MastraHarness {
|
|
|
435
446
|
semanticRecall: false,
|
|
436
447
|
workingMemory: {
|
|
437
448
|
enabled: true,
|
|
449
|
+
// Thread scope keeps each conversation's Working Memory
|
|
450
|
+
// isolated (Mastra's default is resource scope, which would
|
|
451
|
+
// share one slot across every thread on the agent).
|
|
438
452
|
scope: 'thread',
|
|
453
|
+
// `agentManaged: true` (Mastra's default) keeps the native
|
|
454
|
+
// `updateWorkingMemory` tool — the model's own scratchpad, and
|
|
455
|
+
// this slot alone. Consumer session context lives in the
|
|
456
|
+
// separate `this.sessionContextStore`, so the harness has
|
|
457
|
+
// nothing to partition out of here.
|
|
458
|
+
agentManaged: true,
|
|
439
459
|
},
|
|
440
460
|
},
|
|
441
461
|
});
|
|
@@ -443,7 +463,7 @@ export class MastraHarness {
|
|
|
443
463
|
storage: this.storage,
|
|
444
464
|
memory: { shared: this.memory },
|
|
445
465
|
});
|
|
446
|
-
this.
|
|
466
|
+
this.sessionContextStore = new SessionContextStore(this.storageRootFolder, this.buses.getLogBus());
|
|
447
467
|
}
|
|
448
468
|
/**
|
|
449
469
|
* @requirements
|
|
@@ -739,10 +759,9 @@ export class MastraHarness {
|
|
|
739
759
|
}));
|
|
740
760
|
state.skillSearchProcessor?.dispose();
|
|
741
761
|
await state.mastraWorkspace?.destroy();
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
await this.sessionContext.deleteAgent(agentId);
|
|
762
|
+
// The store is separate from Mastra's thread rows, so it needs its own
|
|
763
|
+
// teardown — remove every thread's stored context for this agent.
|
|
764
|
+
await this.sessionContextStore.deleteAgent(agentId);
|
|
746
765
|
this.mastra.removeAgent(agentId);
|
|
747
766
|
this.modelConnectivityByAgent.delete(agentId);
|
|
748
767
|
return this.activeAgents.delete(agentId);
|
|
@@ -1213,10 +1232,9 @@ export class MastraHarness {
|
|
|
1213
1232
|
}
|
|
1214
1233
|
const memory = await this.getMemoryOrThrow(agentId);
|
|
1215
1234
|
await memory.deleteThread(threadId);
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
await this.sessionContext.delete(agentId, threadId);
|
|
1235
|
+
// The store is separate from Mastra's thread row, so delete it too or a
|
|
1236
|
+
// same-id recreate would resurrect the destroyed thread's context.
|
|
1237
|
+
await this.sessionContextStore.delete(agentId, threadId);
|
|
1220
1238
|
}
|
|
1221
1239
|
/**
|
|
1222
1240
|
* @requirements
|
|
@@ -1235,10 +1253,23 @@ export class MastraHarness {
|
|
|
1235
1253
|
* @requirements
|
|
1236
1254
|
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
1237
1255
|
* - MUST clone the source thread's messages into a new thread.
|
|
1256
|
+
* - MUST copy the source thread's stored session context onto the clone —
|
|
1257
|
+
* Mastra's native clone copies the thread row but not the harness-owned
|
|
1258
|
+
* store, so without this the clone would silently start `{}`.
|
|
1238
1259
|
*/
|
|
1239
1260
|
async cloneThread(agentId, sourceThreadId) {
|
|
1240
1261
|
const memory = await this.getMemoryOrThrow(agentId);
|
|
1241
1262
|
const { thread } = await memory.cloneThread({ sourceThreadId });
|
|
1263
|
+
// Native clone already persisted the new thread; if the store copy then
|
|
1264
|
+
// fails (a genuine FS error), delete the orphan before rethrowing so a
|
|
1265
|
+
// failed clone leaves no thread the caller never got an id for.
|
|
1266
|
+
try {
|
|
1267
|
+
await this.carrySessionContextForward(agentId, sourceThreadId, thread.id);
|
|
1268
|
+
}
|
|
1269
|
+
catch (err) {
|
|
1270
|
+
await this.destroyThreadBestEffort(agentId, thread.id);
|
|
1271
|
+
throw err;
|
|
1272
|
+
}
|
|
1242
1273
|
return thread.id;
|
|
1243
1274
|
}
|
|
1244
1275
|
/**
|
|
@@ -1268,6 +1299,21 @@ export class MastraHarness {
|
|
|
1268
1299
|
const messages = await this.getMessages(agentId, threadId);
|
|
1269
1300
|
if (messages.length === 0) {
|
|
1270
1301
|
const newThreadId = await this.createThread(agentId);
|
|
1302
|
+
// An empty transcript can still carry session context (it's set
|
|
1303
|
+
// independently of messages), so carry it forward here too — before
|
|
1304
|
+
// the source is retired, and fail-closed (source left intact on a
|
|
1305
|
+
// copy failure) to match the main path's COMPACTION_FAILED contract.
|
|
1306
|
+
try {
|
|
1307
|
+
await this.carrySessionContextForward(agentId, threadId, newThreadId);
|
|
1308
|
+
}
|
|
1309
|
+
catch (err) {
|
|
1310
|
+
// The new thread was already created; a carry-forward failure
|
|
1311
|
+
// orphans it (never returned, never retired). Delete it before
|
|
1312
|
+
// surfacing COMPACTION_FAILED so a failed compaction leaves no
|
|
1313
|
+
// trace beyond the still-intact source thread.
|
|
1314
|
+
await this.destroyThreadBestEffort(agentId, newThreadId);
|
|
1315
|
+
throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
|
|
1316
|
+
}
|
|
1271
1317
|
// Empty source still gets compacted away — leaving it attached
|
|
1272
1318
|
// would be a leak. Mirrors the non-empty path's destroyThread
|
|
1273
1319
|
// call below; both paths exit with the source detached.
|
|
@@ -1280,6 +1326,9 @@ export class MastraHarness {
|
|
|
1280
1326
|
return `${m.role}: ${text}`;
|
|
1281
1327
|
})
|
|
1282
1328
|
.join('\n');
|
|
1329
|
+
// `undefined` until `createThread` succeeds, so the catch can tell a
|
|
1330
|
+
// pre-create failure (nothing to clean up) from a post-create failure
|
|
1331
|
+
// (the new thread is orphaned and must be deleted).
|
|
1283
1332
|
let newThreadId;
|
|
1284
1333
|
try {
|
|
1285
1334
|
const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
|
|
@@ -1302,8 +1351,19 @@ export class MastraHarness {
|
|
|
1302
1351
|
createdAt: assistantTs,
|
|
1303
1352
|
},
|
|
1304
1353
|
]);
|
|
1354
|
+
// Carry the source thread's session context onto the compacted thread
|
|
1355
|
+
// before the source is retired. Inside the try so a copy failure is
|
|
1356
|
+
// fail-closed (surfaces as COMPACTION_FAILED, source left intact).
|
|
1357
|
+
await this.carrySessionContextForward(agentId, threadId, newThreadId);
|
|
1305
1358
|
}
|
|
1306
1359
|
catch (err) {
|
|
1360
|
+
// If the failure came after `createThread`, the new thread is
|
|
1361
|
+
// orphaned (never returned to the caller, source not yet retired).
|
|
1362
|
+
// Delete it so a failed compaction leaves no trace beyond the
|
|
1363
|
+
// still-intact source thread.
|
|
1364
|
+
if (newThreadId !== undefined) {
|
|
1365
|
+
await this.destroyThreadBestEffort(agentId, newThreadId);
|
|
1366
|
+
}
|
|
1307
1367
|
throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
|
|
1308
1368
|
}
|
|
1309
1369
|
// Source-thread cleanup is best-effort. The user's data is already
|
|
@@ -1520,6 +1580,12 @@ export class MastraHarness {
|
|
|
1520
1580
|
systemPrompt: agentState.composedInstructions,
|
|
1521
1581
|
});
|
|
1522
1582
|
}
|
|
1583
|
+
// Deliver the consumer session context for this turn via the `system:`
|
|
1584
|
+
// option below (see `renderSessionContextSystemText`). `read` softens
|
|
1585
|
+
// never-set/corrupt to `{}` but rejects a genuine FS fault (EACCES/EIO),
|
|
1586
|
+
// failing the turn rather than silently dropping context for it.
|
|
1587
|
+
const sessionContext = await this.sessionContextStore.read(agentId, threadId);
|
|
1588
|
+
const systemContext = renderSessionContextSystemText(sessionContext);
|
|
1523
1589
|
const output = await agentState.agent.stream(streamInput, {
|
|
1524
1590
|
memory: {
|
|
1525
1591
|
thread: threadId,
|
|
@@ -1528,6 +1594,7 @@ export class MastraHarness {
|
|
|
1528
1594
|
requestContext: this.buildRequestContext(agentId, threadId),
|
|
1529
1595
|
maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
1530
1596
|
abortSignal: options?.abortSignal,
|
|
1597
|
+
...(systemContext.length > 0 ? { system: systemContext } : {}),
|
|
1531
1598
|
// Pass `requireToolApproval: true` to Mastra iff the gate is active
|
|
1532
1599
|
// so Mastra suspends every non-consumer tool with a
|
|
1533
1600
|
// `tool-call-approval` chunk and the coordinator can consult the
|
|
@@ -1731,27 +1798,78 @@ export class MastraHarness {
|
|
|
1731
1798
|
await this.addMessages(agentId, threadId, messages);
|
|
1732
1799
|
}
|
|
1733
1800
|
/**
|
|
1734
|
-
* Replace this thread's session-context object in full
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
* `
|
|
1738
|
-
*
|
|
1801
|
+
* Replace this thread's consumer session-context object in full (whole-object
|
|
1802
|
+
* set, not a merge), persisted to the harness-owned store — disjoint from
|
|
1803
|
+
* Mastra's Working Memory, so it never races the model's native
|
|
1804
|
+
* `updateWorkingMemory` writes and never surfaces in `getMessages()`.
|
|
1805
|
+
* `getAgentStateOrThrow` first (`AGENT_NOT_FOUND`); then create-first via
|
|
1806
|
+
* {@link assertThreadExists} (the store itself has no thread concept), which
|
|
1807
|
+
* throws `THREAD_NOT_FOUND` for an uncreated thread. Size/depth guards live in
|
|
1808
|
+
* `SessionContextStore.write`.
|
|
1739
1809
|
*/
|
|
1740
1810
|
async setSessionContext(agentId, threadId, content) {
|
|
1741
1811
|
this.getAgentStateOrThrow(agentId);
|
|
1742
|
-
|
|
1812
|
+
// Run the create-first check as a write precondition INSIDE the store's
|
|
1813
|
+
// serialize queue so check + write are one critical section against a
|
|
1814
|
+
// concurrent `destroyThread` → `store.delete`; a bare check-then-write
|
|
1815
|
+
// would let the delete land between them and recreate an orphan file.
|
|
1816
|
+
await this.sessionContextStore.write(agentId, threadId, content, () => this.assertThreadExists(agentId, threadId));
|
|
1743
1817
|
}
|
|
1744
1818
|
/**
|
|
1745
|
-
* This thread's
|
|
1746
|
-
*
|
|
1747
|
-
* throws on a miss
|
|
1748
|
-
* store
|
|
1819
|
+
* This thread's consumer session-context object read back from the
|
|
1820
|
+
* harness-owned session context store, or `{}` when nothing has been set.
|
|
1821
|
+
* Never `null`/`undefined`, never throws on a miss (corrupt files are
|
|
1822
|
+
* soft-skipped to `{}` and logged inside the store). `getAgentStateOrThrow`
|
|
1823
|
+
* runs first so an unregistered agent surfaces `AGENT_NOT_FOUND` before any
|
|
1824
|
+
* store touch.
|
|
1749
1825
|
*/
|
|
1750
1826
|
async getSessionContext(agentId, threadId) {
|
|
1751
1827
|
this.getAgentStateOrThrow(agentId);
|
|
1752
|
-
return this.
|
|
1828
|
+
return this.sessionContextStore.read(agentId, threadId);
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Create-first guard for `setSessionContext`: throws typed `THREAD_NOT_FOUND`
|
|
1832
|
+
* when `threadId` was never created *for this agent* (Mastra's `Memory` is the
|
|
1833
|
+
* thread registry). We must enforce the `resourceId` scope ourselves — Mastra's
|
|
1834
|
+
* `getThreadById({ threadId, resourceId })` wrapper (`@mastra/core` 1.55) drops
|
|
1835
|
+
* the `resourceId` arg, so on a shared `Memory` agent A's thread would satisfy
|
|
1836
|
+
* a raw check for agent B. The returned `StorageThreadType` carries
|
|
1837
|
+
* `resourceId`, so we assert it matches (consistent with `getThreadIds`'
|
|
1838
|
+
* scoping). Only reachable when a caller reuses an id across agents; the SDK's
|
|
1839
|
+
* `ChatSession` path always mints UUIDs.
|
|
1840
|
+
*/
|
|
1841
|
+
async assertThreadExists(agentId, threadId) {
|
|
1842
|
+
const memory = await this.getMemoryOrThrow(agentId);
|
|
1843
|
+
const thread = await memory.getThreadById({ threadId, resourceId: this.agentResourceId(agentId) });
|
|
1844
|
+
if (thread === null || thread.resourceId !== this.agentResourceId(agentId)) {
|
|
1845
|
+
throw new AgentSDKError(`Cannot set session context on thread "${threadId}": the thread was never created for this agent (create-first contract).`, AgentSDKErrorType.THREAD_NOT_FOUND);
|
|
1846
|
+
}
|
|
1753
1847
|
}
|
|
1754
1848
|
// ── Private Helpers ──────────────────────────────────────────────
|
|
1849
|
+
/**
|
|
1850
|
+
* The system text this thread's stored session context would inject on the
|
|
1851
|
+
* next turn — renders via the SAME {@link renderSessionContextSystemText}
|
|
1852
|
+
* `stream()` calls, so this seam observes exactly what a turn would inject.
|
|
1853
|
+
* Not on the `AgentHarness` contract: its only caller is this harness's
|
|
1854
|
+
* conformance seam (`test/conformance` supplies `renderSystemContext`).
|
|
1855
|
+
*/
|
|
1856
|
+
async renderSessionContextSystemContext(agentId, threadId) {
|
|
1857
|
+
this.getAgentStateOrThrow(agentId);
|
|
1858
|
+
const context = await this.sessionContextStore.read(agentId, threadId);
|
|
1859
|
+
return renderSessionContextSystemText(context);
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Copy a thread's stored session context onto another thread — used by
|
|
1863
|
+
* `compactThread` (onto the compacted thread before the source retires) and
|
|
1864
|
+
* `cloneThread` (Mastra's native clone doesn't know about this store). Skips
|
|
1865
|
+
* the write when the source is `{}`, since a missing file already reads `{}`.
|
|
1866
|
+
*/
|
|
1867
|
+
async carrySessionContextForward(agentId, sourceThreadId, targetThreadId) {
|
|
1868
|
+
const content = await this.sessionContextStore.read(agentId, sourceThreadId);
|
|
1869
|
+
if (Object.keys(content).length === 0)
|
|
1870
|
+
return;
|
|
1871
|
+
await this.sessionContextStore.write(agentId, targetThreadId, content);
|
|
1872
|
+
}
|
|
1755
1873
|
/**
|
|
1756
1874
|
* Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
|
|
1757
1875
|
* summarization. Used internally by `compactThread()`; not part of the
|
|
@@ -36,8 +36,10 @@ export declare function mapMastraToMessage(msg: MastraDBMessage, logBus?: LogBus
|
|
|
36
36
|
export declare function mapMastraPartToMessagePart(part: MastraMessagePart, logBus?: LogBus): MessagePart[];
|
|
37
37
|
/**
|
|
38
38
|
* Converts an SDK {@link Message} into the Mastra persisted message format.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* A bare `role: 'tool'` message is PRESERVED as `'tool'` (Mastra's
|
|
40
|
+
* `MastraDBMessage` role union includes `'tool'`), so a hand-authored tool
|
|
41
|
+
* message written via `addContext` reads back as `'tool'` rather than being
|
|
42
|
+
* rewritten to `'assistant'` — the cross-harness bare-`tool` fidelity contract.
|
|
41
43
|
*/
|
|
42
44
|
export declare function mapMessageToMastra(msg: Message, threadId: string, resourceId: string): MastraDBMessage;
|
|
43
45
|
/**
|
|
@@ -226,8 +226,10 @@ export function mapMastraPartToMessagePart(part, logBus) {
|
|
|
226
226
|
}
|
|
227
227
|
/**
|
|
228
228
|
* Converts an SDK {@link Message} into the Mastra persisted message format.
|
|
229
|
-
*
|
|
230
|
-
*
|
|
229
|
+
* A bare `role: 'tool'` message is PRESERVED as `'tool'` (Mastra's
|
|
230
|
+
* `MastraDBMessage` role union includes `'tool'`), so a hand-authored tool
|
|
231
|
+
* message written via `addContext` reads back as `'tool'` rather than being
|
|
232
|
+
* rewritten to `'assistant'` — the cross-harness bare-`tool` fidelity contract.
|
|
231
233
|
*/
|
|
232
234
|
export function mapMessageToMastra(msg, threadId, resourceId) {
|
|
233
235
|
const parts = [];
|
|
@@ -254,7 +256,6 @@ export function mapMessageToMastra(msg, threadId, resourceId) {
|
|
|
254
256
|
parts.push(mapMessagePartToMastra(part));
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
|
-
const role = msg.role === 'tool' ? 'assistant' : msg.role;
|
|
258
259
|
if (!msg.createdAt) {
|
|
259
260
|
// Internal contract: callers (the harness's `addContext` and
|
|
260
261
|
// `compactThread`) backfill `createdAt` before reaching this
|
|
@@ -267,7 +268,13 @@ export function mapMessageToMastra(msg, threadId, resourceId) {
|
|
|
267
268
|
}
|
|
268
269
|
return {
|
|
269
270
|
id: msg.id,
|
|
270
|
-
|
|
271
|
+
// Preserve `'tool'` rather than coercing it to `'assistant'`. Mastra's
|
|
272
|
+
// `MastraDBMessage.role` static type is `'user'|'assistant'|'system'|'signal'`
|
|
273
|
+
// (it omits `'tool'`), but LibSQL persists the role verbatim and the read
|
|
274
|
+
// path (`mapMastraToMessage`) maps `'tool'` straight back, so a bare tool
|
|
275
|
+
// message round-trips as `'tool'`. The narrow cast records that the runtime
|
|
276
|
+
// role space is wider than this Mastra version's write-side type.
|
|
277
|
+
role: msg.role,
|
|
271
278
|
createdAt: msg.createdAt,
|
|
272
279
|
threadId,
|
|
273
280
|
resourceId,
|
|
@@ -1,39 +1,24 @@
|
|
|
1
1
|
import { type LogBus } from '@salesforce/agentic-common';
|
|
2
|
-
import type
|
|
2
|
+
import { type JsonValue, type SessionContext } from '@salesforce/sfdx-agent-sdk';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* same thread. Mirrors the SDK `AgentIdentityStore` atomic-write shape.
|
|
23
|
-
*
|
|
24
|
-
* **`{}`-on-miss, never null, never throw on a miss.** `read` returns `{}` when
|
|
25
|
-
* nothing was ever set (`ENOENT`) — an empty and a never-set context are
|
|
26
|
-
* behaviorally identical, so callers need no null-check. A corrupt file is
|
|
27
|
-
* logged (`LogBus.warn`) and treated as empty rather than failing every read
|
|
28
|
-
* (the `AgentIdentityStore` soft-skip posture); the corrupt file stays on disk
|
|
29
|
-
* and the next `write` overwrites it cleanly.
|
|
30
|
-
*
|
|
31
|
-
* A deliberate behavioral copy of the OpenAI harness's `SessionContextStore`.
|
|
32
|
-
* Each harness keeps its own copy rather than importing a sibling's — harness
|
|
33
|
-
* independence is enforced by no harness `package.json` declaring another
|
|
34
|
-
* harness as a dependency (the same reason each harness carries its own
|
|
35
|
-
* `mcp-error-classifier`). Drift is guarded by the shared
|
|
36
|
-
* `runSessionContextConformance` table.
|
|
4
|
+
* Recursively key-sort a JSON value (objects only; arrays keep order) so
|
|
5
|
+
* structurally-equal contexts serialize byte-identically. Rejects objects
|
|
6
|
+
* deeper than {@link MAX_SESSION_CONTEXT_DEPTH} with a typed error instead of a
|
|
7
|
+
* raw stack-overflow `RangeError`. Exported for reuse by the per-turn `system:`
|
|
8
|
+
* renderer in `mastra-harness.ts`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sortJsonValue(value: JsonValue, depth: number): JsonValue;
|
|
11
|
+
/**
|
|
12
|
+
* Harness-owned disk persistence for per-thread session context — one JSON file
|
|
13
|
+
* per `(agentId, threadId)` under `${storageRootFolder}/session-context/`,
|
|
14
|
+
* physically disjoint from Mastra's Working Memory and absent from `getMessages`.
|
|
15
|
+
* As the sole writer of this channel, it shares no value with the model's native
|
|
16
|
+
* `updateWorkingMemory` writes, so the two never race. Whole-object `write` via
|
|
17
|
+
* `.tmp` + `rename` (atomic on POSIX); a per-`(agentId, threadId)` in-flight
|
|
18
|
+
* queue serializes read/write cycles and covers Windows rename EPERM. `read`
|
|
19
|
+
* returns `{}` on miss or corruption (logged), never null, never throws on a
|
|
20
|
+
* miss. A behavioral copy of the Claude / OpenAI harness stores (harnesses can't
|
|
21
|
+
* depend on each other); drift-guarded by `runSessionContextConformance`.
|
|
37
22
|
*/
|
|
38
23
|
export declare class SessionContextStore {
|
|
39
24
|
private readonly storageRootFolder;
|
|
@@ -47,27 +32,19 @@ export declare class SessionContextStore {
|
|
|
47
32
|
*/
|
|
48
33
|
read(agentId: string, threadId: string): Promise<SessionContext>;
|
|
49
34
|
/**
|
|
50
|
-
* Replace this thread's session-context object in full.
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Delete this thread's persisted session-context file. Called from thread
|
|
56
|
-
* (and agent) teardown so a destroyed thread leaves no orphaned sidecar that
|
|
57
|
-
* a same-id recreate would resurrect. ENOENT-tolerant — deleting a thread
|
|
58
|
-
* whose context was never set is a no-op, not an error. Runs on the same
|
|
59
|
-
* per-thread serialize queue as read/write so it can't race a concurrent
|
|
60
|
-
* write.
|
|
35
|
+
* Replace this thread's session-context object in full. The optional
|
|
36
|
+
* `precondition` runs inside the serialize queue just before the write, so a
|
|
37
|
+
* caller's create-first check and the write form one critical section against
|
|
38
|
+
* a concurrent `delete`; a throwing precondition writes nothing.
|
|
61
39
|
*/
|
|
40
|
+
write(agentId: string, threadId: string, content: SessionContext, precondition?: () => Promise<void>): Promise<void>;
|
|
41
|
+
/** Delete this thread's file (ENOENT-tolerant), on the serialize queue so it can't race a write. */
|
|
62
42
|
delete(agentId: string, threadId: string): Promise<void>;
|
|
63
43
|
/**
|
|
64
|
-
* Delete
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* any context has no directory). Not routed through the per-thread serialize
|
|
69
|
-
* queue: agent destruction is terminal, so there is no legitimate concurrent
|
|
70
|
-
* per-thread write to order against.
|
|
44
|
+
* Delete an agent's whole `session-context/<agentId>/` directory (ENOENT-tolerant).
|
|
45
|
+
* Drains in-flight per-thread ops first — the per-thread queue can't cover an
|
|
46
|
+
* agent-wide op, so without the drain a queued write could `rename` after the
|
|
47
|
+
* `rm` and leave an orphan dir a recreate would resurrect.
|
|
71
48
|
*/
|
|
72
49
|
deleteAgent(agentId: string): Promise<void>;
|
|
73
50
|
private readContext;
|
|
@@ -76,12 +53,8 @@ export declare class SessionContextStore {
|
|
|
76
53
|
private dirFor;
|
|
77
54
|
private fileFor;
|
|
78
55
|
/**
|
|
79
|
-
* Per-`(agentId, threadId)` in-flight queue
|
|
80
|
-
*
|
|
81
|
-
* cycles on the same thread so a concurrent `setSessionContext` and
|
|
82
|
-
* `getSessionContext` never interleave a read against a half-written file.
|
|
83
|
-
* Keyed by the SAME injective encoding as {@link fileFor}, so two ids that
|
|
84
|
-
* only differ in punctuation get distinct queues (not one shared queue).
|
|
56
|
+
* Per-`(agentId, threadId)` in-flight queue so read/write cycles on one thread
|
|
57
|
+
* never interleave. Keyed by the same injective encoding as {@link fileFor}.
|
|
85
58
|
*/
|
|
86
59
|
private serialize;
|
|
87
60
|
}
|
|
@@ -5,42 +5,64 @@
|
|
|
5
5
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { getErrorMessage } from '@salesforce/agentic-common';
|
|
8
|
+
import { AgentSDKError, AgentSDKErrorType } from '@salesforce/sfdx-agent-sdk';
|
|
8
9
|
const SESSION_CONTEXT_SUBDIR = 'session-context';
|
|
9
10
|
const FILE_EXT = '.json';
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
12
|
+
* Max serialized size (UTF-8 bytes) of a stored context. `write` throws above
|
|
13
|
+
* this rather than letting an oversized object ride into every turn's system
|
|
14
|
+
* context. Generous — context is for identifiers/flags, not payloads.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_SESSION_CONTEXT_BYTES = 256 * 1024;
|
|
17
|
+
/** Max object nesting depth `sortJsonValue` will recurse before rejecting. */
|
|
18
|
+
const MAX_SESSION_CONTEXT_DEPTH = 200;
|
|
19
|
+
/**
|
|
20
|
+
* Recursively key-sort a JSON value (objects only; arrays keep order) so
|
|
21
|
+
* structurally-equal contexts serialize byte-identically. Rejects objects
|
|
22
|
+
* deeper than {@link MAX_SESSION_CONTEXT_DEPTH} with a typed error instead of a
|
|
23
|
+
* raw stack-overflow `RangeError`. Exported for reuse by the per-turn `system:`
|
|
24
|
+
* renderer in `mastra-harness.ts`.
|
|
25
|
+
*/
|
|
26
|
+
export function sortJsonValue(value, depth) {
|
|
27
|
+
if (depth > MAX_SESSION_CONTEXT_DEPTH) {
|
|
28
|
+
throw new AgentSDKError(`Session context exceeds the maximum nesting depth of ${MAX_SESSION_CONTEXT_DEPTH}.`, AgentSDKErrorType.INVALID_MESSAGE_CONTENT);
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(value))
|
|
31
|
+
return value.map((v) => sortJsonValue(v, depth + 1));
|
|
32
|
+
if (value === null || typeof value !== 'object')
|
|
33
|
+
return value;
|
|
34
|
+
// Null-prototype, not `{}`: assigning a `"__proto__"` key (legal in JSON)
|
|
35
|
+
// on a plain object hits the prototype setter and silently drops the key.
|
|
36
|
+
const sorted = Object.create(null);
|
|
37
|
+
for (const key of Object.keys(value).sort()) {
|
|
38
|
+
sorted[key] = sortJsonValue(value[key], depth + 1);
|
|
39
|
+
}
|
|
40
|
+
return sorted;
|
|
41
|
+
}
|
|
42
|
+
/** Canonical (recursively key-sorted) JSON so equal contexts render identically. */
|
|
43
|
+
function canonicalizeSessionContext(value) {
|
|
44
|
+
return JSON.stringify(sortJsonValue(value, 0));
|
|
45
|
+
}
|
|
46
|
+
/** Canonicalize + enforce the size cap; throws `INVALID_MESSAGE_CONTENT` over the limit. */
|
|
47
|
+
function serializeSessionContext(value) {
|
|
48
|
+
const serialized = canonicalizeSessionContext(value);
|
|
49
|
+
const bytes = Buffer.byteLength(serialized, 'utf8');
|
|
50
|
+
if (bytes > MAX_SESSION_CONTEXT_BYTES) {
|
|
51
|
+
throw new AgentSDKError(`Session context is ${bytes} bytes, above the ${MAX_SESSION_CONTEXT_BYTES}-byte limit.`, AgentSDKErrorType.INVALID_MESSAGE_CONTENT);
|
|
52
|
+
}
|
|
53
|
+
return serialized;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Harness-owned disk persistence for per-thread session context — one JSON file
|
|
57
|
+
* per `(agentId, threadId)` under `${storageRootFolder}/session-context/`,
|
|
58
|
+
* physically disjoint from Mastra's Working Memory and absent from `getMessages`.
|
|
59
|
+
* As the sole writer of this channel, it shares no value with the model's native
|
|
60
|
+
* `updateWorkingMemory` writes, so the two never race. Whole-object `write` via
|
|
61
|
+
* `.tmp` + `rename` (atomic on POSIX); a per-`(agentId, threadId)` in-flight
|
|
62
|
+
* queue serializes read/write cycles and covers Windows rename EPERM. `read`
|
|
63
|
+
* returns `{}` on miss or corruption (logged), never null, never throws on a
|
|
64
|
+
* miss. A behavioral copy of the Claude / OpenAI harness stores (harnesses can't
|
|
65
|
+
* depend on each other); drift-guarded by `runSessionContextConformance`.
|
|
44
66
|
*/
|
|
45
67
|
export class SessionContextStore {
|
|
46
68
|
storageRootFolder;
|
|
@@ -59,33 +81,34 @@ export class SessionContextStore {
|
|
|
59
81
|
return this.serialize(agentId, threadId, () => this.readContext(agentId, threadId));
|
|
60
82
|
}
|
|
61
83
|
/**
|
|
62
|
-
* Replace this thread's session-context object in full.
|
|
63
|
-
*
|
|
84
|
+
* Replace this thread's session-context object in full. The optional
|
|
85
|
+
* `precondition` runs inside the serialize queue just before the write, so a
|
|
86
|
+
* caller's create-first check and the write form one critical section against
|
|
87
|
+
* a concurrent `delete`; a throwing precondition writes nothing.
|
|
64
88
|
*/
|
|
65
|
-
async write(agentId, threadId, content) {
|
|
66
|
-
await this.serialize(agentId, threadId, () =>
|
|
89
|
+
async write(agentId, threadId, content, precondition) {
|
|
90
|
+
await this.serialize(agentId, threadId, async () => {
|
|
91
|
+
if (precondition)
|
|
92
|
+
await precondition();
|
|
93
|
+
await this.writeContext(agentId, threadId, content);
|
|
94
|
+
});
|
|
67
95
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Delete this thread's persisted session-context file. Called from thread
|
|
70
|
-
* (and agent) teardown so a destroyed thread leaves no orphaned sidecar that
|
|
71
|
-
* a same-id recreate would resurrect. ENOENT-tolerant — deleting a thread
|
|
72
|
-
* whose context was never set is a no-op, not an error. Runs on the same
|
|
73
|
-
* per-thread serialize queue as read/write so it can't race a concurrent
|
|
74
|
-
* write.
|
|
75
|
-
*/
|
|
96
|
+
/** Delete this thread's file (ENOENT-tolerant), on the serialize queue so it can't race a write. */
|
|
76
97
|
async delete(agentId, threadId) {
|
|
77
98
|
await this.serialize(agentId, threadId, () => this.deleteContext(agentId, threadId));
|
|
78
99
|
}
|
|
79
100
|
/**
|
|
80
|
-
* Delete
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* any context has no directory). Not routed through the per-thread serialize
|
|
85
|
-
* queue: agent destruction is terminal, so there is no legitimate concurrent
|
|
86
|
-
* per-thread write to order against.
|
|
101
|
+
* Delete an agent's whole `session-context/<agentId>/` directory (ENOENT-tolerant).
|
|
102
|
+
* Drains in-flight per-thread ops first — the per-thread queue can't cover an
|
|
103
|
+
* agent-wide op, so without the drain a queued write could `rename` after the
|
|
104
|
+
* `rm` and leave an orphan dir a recreate would resurrect.
|
|
87
105
|
*/
|
|
88
106
|
async deleteAgent(agentId) {
|
|
107
|
+
const prefix = `${encodeSegment(agentId)} `;
|
|
108
|
+
const inFlight = [...this.inflight.entries()]
|
|
109
|
+
.filter(([k]) => k.startsWith(prefix))
|
|
110
|
+
.map(([, p]) => p.catch(() => undefined));
|
|
111
|
+
await Promise.all(inFlight);
|
|
89
112
|
await rm(this.dirFor(agentId), { recursive: true, force: true });
|
|
90
113
|
}
|
|
91
114
|
// ── Internals (called only inside `serialize`) ───────────────────────────
|
|
@@ -111,10 +134,7 @@ export class SessionContextStore {
|
|
|
111
134
|
});
|
|
112
135
|
return {};
|
|
113
136
|
}
|
|
114
|
-
//
|
|
115
|
-
// SessionContext object shape — as good as corrupt, so soft-skip to `{}`
|
|
116
|
-
// and log it (like the parse-failure branch) rather than discard the file
|
|
117
|
-
// silently.
|
|
137
|
+
// Parsed cleanly but not an object shape — treat as corrupt: soft-skip + log.
|
|
118
138
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
119
139
|
this.logBus?.warn('skipping non-object Mastra session-context file', { file });
|
|
120
140
|
return {};
|
|
@@ -122,10 +142,13 @@ export class SessionContextStore {
|
|
|
122
142
|
return parsed;
|
|
123
143
|
}
|
|
124
144
|
async writeContext(agentId, threadId, content) {
|
|
145
|
+
// Serialize (canonicalize + size-guard) before touching disk, so a bad
|
|
146
|
+
// object leaves no partial write behind.
|
|
147
|
+
const serialized = serializeSessionContext(content);
|
|
125
148
|
const file = this.fileFor(agentId, threadId);
|
|
126
149
|
await mkdir(dirname(file), { recursive: true });
|
|
127
150
|
const tmp = `${file}.tmp`;
|
|
128
|
-
await writeFile(tmp,
|
|
151
|
+
await writeFile(tmp, serialized, 'utf8');
|
|
129
152
|
await rename(tmp, file);
|
|
130
153
|
}
|
|
131
154
|
async deleteContext(agentId, threadId) {
|
|
@@ -140,12 +163,8 @@ export class SessionContextStore {
|
|
|
140
163
|
return join(this.dirFor(agentId), `${encodeSegment(threadId)}${FILE_EXT}`);
|
|
141
164
|
}
|
|
142
165
|
/**
|
|
143
|
-
* Per-`(agentId, threadId)` in-flight queue
|
|
144
|
-
*
|
|
145
|
-
* cycles on the same thread so a concurrent `setSessionContext` and
|
|
146
|
-
* `getSessionContext` never interleave a read against a half-written file.
|
|
147
|
-
* Keyed by the SAME injective encoding as {@link fileFor}, so two ids that
|
|
148
|
-
* only differ in punctuation get distinct queues (not one shared queue).
|
|
166
|
+
* Per-`(agentId, threadId)` in-flight queue so read/write cycles on one thread
|
|
167
|
+
* never interleave. Keyed by the same injective encoding as {@link fileFor}.
|
|
149
168
|
*/
|
|
150
169
|
serialize(agentId, threadId, fn) {
|
|
151
170
|
const k = `${encodeSegment(agentId)} ${encodeSegment(threadId)}`;
|
|
@@ -160,22 +179,12 @@ export class SessionContextStore {
|
|
|
160
179
|
}
|
|
161
180
|
}
|
|
162
181
|
/**
|
|
163
|
-
* Encode an agent/thread id into
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* key; (2) it passed `.` / `..` through untouched, so a threadId of `..` escaped
|
|
170
|
-
* the `session-context/` dir. base64url's alphabet is `[A-Za-z0-9_-]` — no `.`
|
|
171
|
-
* and no `/` — so an encoded segment can never be `..` or contain a separator.
|
|
172
|
-
*
|
|
173
|
-
* **Caveat — not injective over lone (unpaired) UTF-16 surrogates.** The `utf8`
|
|
174
|
-
* transcode replaces an unpaired surrogate with U+FFFD before encoding, so e.g.
|
|
175
|
-
* `"\uD800"` and `"\uD801"` collide. A non-issue in practice (agent/thread ids
|
|
176
|
-
* are UUIDs / gateway-issued ids, always well-formed); closing it would mean an
|
|
177
|
-
* encoding change that re-orphans every existing sidecar. Documented, not
|
|
178
|
-
* silently absolute. Byte-identical to the Claude / OpenAI harness copies.
|
|
182
|
+
* Encode an agent/thread id into one safe path segment via base64url. Injective
|
|
183
|
+
* (distinct ids → distinct segments) and traversal-safe: the `[A-Za-z0-9_-]`
|
|
184
|
+
* alphabet has no `.` or `/`, so a segment can never be `..` or contain a
|
|
185
|
+
* separator, and punctuation-only-distinct ids (`a/b` vs `a?b`) never alias.
|
|
186
|
+
* Caveat: lone UTF-16 surrogates transcode to U+FFFD and collide — a non-issue
|
|
187
|
+
* for UUID / gateway-issued ids.
|
|
179
188
|
*/
|
|
180
189
|
function encodeSegment(s) {
|
|
181
190
|
return Buffer.from(s, 'utf8').toString('base64url');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/sfdx-agent-harness-mastra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -37,26 +37,26 @@
|
|
|
37
37
|
"@mastra/mcp": "^1.15.0",
|
|
38
38
|
"@mastra/memory": "^1.24.0",
|
|
39
39
|
"@mastra/schema-compat": "^1.3.4",
|
|
40
|
-
"@salesforce/agentic-common": "0.
|
|
40
|
+
"@salesforce/agentic-common": "0.17.0",
|
|
41
41
|
"@smithy/eventstream-codec": "^2.2.0",
|
|
42
42
|
"openai": "^7.0.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
45
|
+
"@salesforce/sfdx-agent-sdk": "0.55.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.
|
|
50
|
+
"@salesforce/sfdx-agent-sdk": "0.55.0",
|
|
51
51
|
"@types/node": "^22.20.1",
|
|
52
52
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
53
|
-
"@vitest/eslint-plugin": "^1.6.
|
|
53
|
+
"@vitest/eslint-plugin": "^1.6.27",
|
|
54
54
|
"eslint": "^10.8.1",
|
|
55
55
|
"eslint-config-prettier": "^10.1.8",
|
|
56
56
|
"eslint-import-resolver-typescript": "^4.4.5",
|
|
57
57
|
"eslint-plugin-import": "^2.32.0",
|
|
58
58
|
"eslint-plugin-n": "^18.3.0",
|
|
59
|
-
"globals": "^17.
|
|
59
|
+
"globals": "^17.11.0",
|
|
60
60
|
"lint-staged": "^17.3.0",
|
|
61
61
|
"prettier": "^3.9.6",
|
|
62
62
|
"rimraf": "^6.1.3",
|