@salesforce/sfdx-agent-harness-mastra 0.48.0 → 0.49.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 +5 -0
- package/dist/mastra-harness.d.ts +34 -1
- package/dist/mastra-harness.js +66 -8
- package/dist/mastra-message-mapper.d.ts +4 -2
- package/dist/mastra-message-mapper.js +6 -4
- package/dist/session-context-store.d.ts +87 -0
- package/dist/session-context-store.js +183 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
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.49.0] - 2026-08-17
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
- **agent-sdk,harness-mastra,harness-claude,harness-openai**: session-context contract + conformance gate @W-23632685@ ([#747](https://github.com/forcedotcom/agentic-dx/pull/747))
|
|
10
|
+
|
|
6
11
|
## [0.48.0] - 2026-08-14
|
|
7
12
|
|
|
8
13
|
_No changes — released alongside dependent packages._
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Agent } from '@mastra/core/agent';
|
|
2
2
|
import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
|
|
3
3
|
import { MCPClient } from '@mastra/mcp';
|
|
4
|
-
import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
|
|
4
|
+
import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type SessionContext, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
|
|
5
5
|
import { type AgentHooks } from '@salesforce/sfdx-agent-sdk/harness';
|
|
6
6
|
import type { MastraLanguageModelBuilderContext } from './index.js';
|
|
7
7
|
import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
|
|
@@ -248,6 +248,22 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
248
248
|
private readonly memory;
|
|
249
249
|
private readonly mastra;
|
|
250
250
|
private readonly buses;
|
|
251
|
+
/**
|
|
252
|
+
* Harness-owned disk persistence for the per-thread session-context channel.
|
|
253
|
+
* A dedicated JSON sidecar disjoint from the LibSQL-backed message store —
|
|
254
|
+
* a `setSessionContext` object MUST NOT appear in `getMessages()` output.
|
|
255
|
+
* Rooted at the same `storageRootFolder` the constructor receives.
|
|
256
|
+
*
|
|
257
|
+
* INTERIM: the session-context spike prescribes Mastra persist this over
|
|
258
|
+
* Working Memory (`memory.updateWorkingMemory` / `getWorkingMemory`, JSON
|
|
259
|
+
* mode — its native, already-enabled channel), not a sidecar. The sidecar
|
|
260
|
+
* ships here so the shared `runSessionContextConformance` persistence gate is
|
|
261
|
+
* green on all three harnesses in this milestone; W-23632686 replaces this
|
|
262
|
+
* store with Working Memory (Claude/OpenAI keep their sidecars). The
|
|
263
|
+
* consumer-visible contract — whole-object set, `{}`-on-miss, absent from
|
|
264
|
+
* `getMessages` — is identical across both, so the swap is internal.
|
|
265
|
+
*/
|
|
266
|
+
private readonly sessionContext;
|
|
251
267
|
/**
|
|
252
268
|
* Mastra-specific extension surface, typed off `MastraAgentHarness`. The
|
|
253
269
|
* SDK never reads this; consumers reach through `manager.extensions.mastra`.
|
|
@@ -529,7 +545,24 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
529
545
|
* - MUST throw an error if the specified `agentId` is not found.
|
|
530
546
|
* - MUST save context messages without triggering an LLM response.
|
|
531
547
|
*/
|
|
548
|
+
addMessages(agentId: string, threadId: string, messages: Message[]): Promise<void>;
|
|
549
|
+
/** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
|
|
532
550
|
addContext(agentId: string, threadId: string, messages: Message[]): Promise<void>;
|
|
551
|
+
/**
|
|
552
|
+
* Replace this thread's session-context object in full. Whole-object set
|
|
553
|
+
* (not a merge); persisted verbatim to the harness-owned sidecar, disjoint
|
|
554
|
+
* from the message transcript — the set object never appears in
|
|
555
|
+
* `getMessages()`. Throws `AGENT_NOT_FOUND` for an unknown agent before any
|
|
556
|
+
* store access.
|
|
557
|
+
*/
|
|
558
|
+
setSessionContext(agentId: string, threadId: string, content: SessionContext): Promise<void>;
|
|
559
|
+
/**
|
|
560
|
+
* This thread's persisted session-context object, or `{}` when nothing has
|
|
561
|
+
* been set (or the sidecar is unparseable). Never `null`/`undefined`, never
|
|
562
|
+
* throws on a miss. Throws `AGENT_NOT_FOUND` for an unknown agent before any
|
|
563
|
+
* store access.
|
|
564
|
+
*/
|
|
565
|
+
getSessionContext(agentId: string, threadId: string): Promise<SessionContext>;
|
|
533
566
|
/**
|
|
534
567
|
* Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
|
|
535
568
|
* summarization. Used internally by `compactThread()`; not part of the
|
package/dist/mastra-harness.js
CHANGED
|
@@ -15,6 +15,7 @@ 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';
|
|
18
19
|
import { SKILL_SEARCH_META_TOOL_NAMES, TOOL_SEARCH_META_TOOL_NAMES } from './skill-bridge.js';
|
|
19
20
|
import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
|
|
20
21
|
import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
|
|
@@ -366,6 +367,22 @@ export class MastraHarness {
|
|
|
366
367
|
memory;
|
|
367
368
|
mastra;
|
|
368
369
|
buses = new HarnessBusOwner();
|
|
370
|
+
/**
|
|
371
|
+
* Harness-owned disk persistence for the per-thread session-context channel.
|
|
372
|
+
* A dedicated JSON sidecar disjoint from the LibSQL-backed message store —
|
|
373
|
+
* a `setSessionContext` object MUST NOT appear in `getMessages()` output.
|
|
374
|
+
* Rooted at the same `storageRootFolder` the constructor receives.
|
|
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.
|
|
384
|
+
*/
|
|
385
|
+
sessionContext;
|
|
369
386
|
/**
|
|
370
387
|
* Mastra-specific extension surface, typed off `MastraAgentHarness`. The
|
|
371
388
|
* SDK never reads this; consumers reach through `manager.extensions.mastra`.
|
|
@@ -426,6 +443,7 @@ export class MastraHarness {
|
|
|
426
443
|
storage: this.storage,
|
|
427
444
|
memory: { shared: this.memory },
|
|
428
445
|
});
|
|
446
|
+
this.sessionContext = new SessionContextStore(this.storageRootFolder, this.buses.getLogBus());
|
|
429
447
|
}
|
|
430
448
|
/**
|
|
431
449
|
* @requirements
|
|
@@ -719,6 +737,10 @@ export class MastraHarness {
|
|
|
719
737
|
}));
|
|
720
738
|
state.skillSearchProcessor?.dispose();
|
|
721
739
|
await state.mastraWorkspace?.destroy();
|
|
740
|
+
// Remove every session-context sidecar for this agent so teardown leaves
|
|
741
|
+
// no orphaned context behind (per-thread destroyThread isn't called for
|
|
742
|
+
// each thread on agent destruction).
|
|
743
|
+
await this.sessionContext.deleteAgent(agentId);
|
|
722
744
|
this.mastra.removeAgent(agentId);
|
|
723
745
|
this.modelConnectivityByAgent.delete(agentId);
|
|
724
746
|
return this.activeAgents.delete(agentId);
|
|
@@ -1172,6 +1194,10 @@ export class MastraHarness {
|
|
|
1172
1194
|
}
|
|
1173
1195
|
const memory = await this.getMemoryOrThrow(agentId);
|
|
1174
1196
|
await memory.deleteThread(threadId);
|
|
1197
|
+
// Drop the session-context sidecar too, so a destroyed thread leaves no
|
|
1198
|
+
// orphaned context that a same-id recreate would resurrect. Runs on the
|
|
1199
|
+
// store's own serialize queue; ENOENT-tolerant for a never-set thread.
|
|
1200
|
+
await this.sessionContext.delete(agentId, threadId);
|
|
1175
1201
|
}
|
|
1176
1202
|
/**
|
|
1177
1203
|
* @requirements
|
|
@@ -1243,7 +1269,7 @@ export class MastraHarness {
|
|
|
1243
1269
|
newThreadId = await this.createThread(agentId);
|
|
1244
1270
|
const userTs = this.clock.now();
|
|
1245
1271
|
const assistantTs = this.clock.nextAfter(userTs);
|
|
1246
|
-
await this.
|
|
1272
|
+
await this.addMessages(agentId, newThreadId, [
|
|
1247
1273
|
{
|
|
1248
1274
|
id: this.idGenerator.getUniqueId(),
|
|
1249
1275
|
role: 'user',
|
|
@@ -1598,11 +1624,16 @@ export class MastraHarness {
|
|
|
1598
1624
|
perPage: false,
|
|
1599
1625
|
});
|
|
1600
1626
|
const logBus = this.buses.getLogBus();
|
|
1601
|
-
// Drop
|
|
1602
|
-
//
|
|
1603
|
-
//
|
|
1604
|
-
// harness internals up
|
|
1605
|
-
//
|
|
1627
|
+
// Drop roles with no consumer-visible SDK equivalent before mapping:
|
|
1628
|
+
// - `'signal'` — Mastra's experimental self-synthesized system reminders
|
|
1629
|
+
// (`createSignal()`, see `@mastra/core/dist/agent/signals.d.ts`, all
|
|
1630
|
+
// `@experimental`); surfacing them would leak harness internals up.
|
|
1631
|
+
// - `'system'` — not a member of the SDK's `MessageRole` (`'user' |
|
|
1632
|
+
// 'assistant' | 'tool'`). System state rides `instructions` / session
|
|
1633
|
+
// context, never the transcript, so a persisted `system` row (if one
|
|
1634
|
+
// ever exists) must not surface on `getMessages`.
|
|
1635
|
+
// If Mastra promotes signals to stable and we want them, the SDK contract
|
|
1636
|
+
// needs to add the role first.
|
|
1606
1637
|
// Per-position fallback for any persisted row whose `createdAt` is
|
|
1607
1638
|
// null/undefined (legacy rows from before the optional-on-write
|
|
1608
1639
|
// contract, partial-write recovery, or a libsql schema change).
|
|
@@ -1613,7 +1644,9 @@ export class MastraHarness {
|
|
|
1613
1644
|
// silently bucket null-timestamp rows at epoch and propagate
|
|
1614
1645
|
// `undefined` to consumers.
|
|
1615
1646
|
let lastFallback = this.clock.now();
|
|
1616
|
-
const mapped = messages
|
|
1647
|
+
const mapped = messages
|
|
1648
|
+
.filter((m) => m.role !== 'signal' && m.role !== 'system')
|
|
1649
|
+
.map((m) => mapMastraToMessage(m, logBus));
|
|
1617
1650
|
// Hoist every completed tool call's `tool-result` part onto its own
|
|
1618
1651
|
// `role: 'tool'` message so the consumer-visible layout matches the
|
|
1619
1652
|
// cross-harness `getMessages` contract (assistant carries the
|
|
@@ -1652,7 +1685,7 @@ export class MastraHarness {
|
|
|
1652
1685
|
* - MUST throw an error if the specified `agentId` is not found.
|
|
1653
1686
|
* - MUST save context messages without triggering an LLM response.
|
|
1654
1687
|
*/
|
|
1655
|
-
async
|
|
1688
|
+
async addMessages(agentId, threadId, messages) {
|
|
1656
1689
|
const memory = await this.getMemoryOrThrow(agentId);
|
|
1657
1690
|
const resourceId = this.agentResourceId(agentId);
|
|
1658
1691
|
// Backfill `createdAt` on any caller-supplied message that omits it.
|
|
@@ -1674,6 +1707,31 @@ export class MastraHarness {
|
|
|
1674
1707
|
const mapped = filled.map((m) => mapMessageToMastra(m, threadId, resourceId));
|
|
1675
1708
|
await memory.saveMessages({ messages: mapped });
|
|
1676
1709
|
}
|
|
1710
|
+
/** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
|
|
1711
|
+
async addContext(agentId, threadId, messages) {
|
|
1712
|
+
await this.addMessages(agentId, threadId, messages);
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Replace this thread's session-context object in full. Whole-object set
|
|
1716
|
+
* (not a merge); persisted verbatim to the harness-owned sidecar, disjoint
|
|
1717
|
+
* from the message transcript — the set object never appears in
|
|
1718
|
+
* `getMessages()`. Throws `AGENT_NOT_FOUND` for an unknown agent before any
|
|
1719
|
+
* store access.
|
|
1720
|
+
*/
|
|
1721
|
+
async setSessionContext(agentId, threadId, content) {
|
|
1722
|
+
this.getAgentStateOrThrow(agentId);
|
|
1723
|
+
await this.sessionContext.write(agentId, threadId, content);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* This thread's persisted session-context object, or `{}` when nothing has
|
|
1727
|
+
* been set (or the sidecar is unparseable). Never `null`/`undefined`, never
|
|
1728
|
+
* throws on a miss. Throws `AGENT_NOT_FOUND` for an unknown agent before any
|
|
1729
|
+
* store access.
|
|
1730
|
+
*/
|
|
1731
|
+
async getSessionContext(agentId, threadId) {
|
|
1732
|
+
this.getAgentStateOrThrow(agentId);
|
|
1733
|
+
return this.sessionContext.read(agentId, threadId);
|
|
1734
|
+
}
|
|
1677
1735
|
// ── Private Helpers ──────────────────────────────────────────────
|
|
1678
1736
|
/**
|
|
1679
1737
|
* Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
|
|
@@ -4,8 +4,10 @@ import type { LogBus } from '@salesforce/agentic-common';
|
|
|
4
4
|
/**
|
|
5
5
|
* Converts a Mastra persisted message into the SDK's {@link Message} type.
|
|
6
6
|
*
|
|
7
|
-
* Callers must filter out
|
|
8
|
-
*
|
|
7
|
+
* Callers must filter out roles with no SDK `Message` equivalent before invoking:
|
|
8
|
+
* Mastra's experimental `'signal'` role and `'system'` (which is not a
|
|
9
|
+
* consumer-visible SDK {@link MessageRole} — system state rides `instructions` /
|
|
10
|
+
* session context, never the transcript). Neither must surface in `Message[]`.
|
|
9
11
|
*
|
|
10
12
|
* Parts that have no SDK equivalent (source, file, step-start, and any
|
|
11
13
|
* unrecognized upstream types including AI SDK v5 `data-*` metadata parts)
|
|
@@ -59,8 +59,10 @@ function buildCompletedToolInvocation(toolCallId, toolName, args, result) {
|
|
|
59
59
|
/**
|
|
60
60
|
* Converts a Mastra persisted message into the SDK's {@link Message} type.
|
|
61
61
|
*
|
|
62
|
-
* Callers must filter out
|
|
63
|
-
*
|
|
62
|
+
* Callers must filter out roles with no SDK `Message` equivalent before invoking:
|
|
63
|
+
* Mastra's experimental `'signal'` role and `'system'` (which is not a
|
|
64
|
+
* consumer-visible SDK {@link MessageRole} — system state rides `instructions` /
|
|
65
|
+
* session context, never the transcript). Neither must surface in `Message[]`.
|
|
64
66
|
*
|
|
65
67
|
* Parts that have no SDK equivalent (source, file, step-start, and any
|
|
66
68
|
* unrecognized upstream types including AI SDK v5 `data-*` metadata parts)
|
|
@@ -69,8 +71,8 @@ function buildCompletedToolInvocation(toolCallId, toolName, args, result) {
|
|
|
69
71
|
* message history retrieval.
|
|
70
72
|
*/
|
|
71
73
|
export function mapMastraToMessage(msg, logBus) {
|
|
72
|
-
if (msg.role === 'signal') {
|
|
73
|
-
throw new Error(
|
|
74
|
+
if (msg.role === 'signal' || msg.role === 'system') {
|
|
75
|
+
throw new Error(`mapMastraToMessage: '${msg.role}' role messages must be filtered out before mapping`);
|
|
74
76
|
}
|
|
75
77
|
const parts = [];
|
|
76
78
|
for (const part of msg.content.parts) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type LogBus } from '@salesforce/agentic-common';
|
|
2
|
+
import type { SessionContext } from '@salesforce/sfdx-agent-sdk';
|
|
3
|
+
/**
|
|
4
|
+
* Harness-owned disk persistence for per-thread session context. One JSON file
|
|
5
|
+
* per `(agentId, threadId)` under `${storageRootFolder}/session-context/`:
|
|
6
|
+
*
|
|
7
|
+
* ${storageRootFolder}/session-context/
|
|
8
|
+
* <agentId>/
|
|
9
|
+
* <threadId>.json ← a single {@link SessionContext} object
|
|
10
|
+
*
|
|
11
|
+
* A dedicated sidecar channel, disjoint from the message transcript (the
|
|
12
|
+
* LibSQL-backed `Memory` store) — the session-context contract requires this
|
|
13
|
+
* object NOT appear in `getMessages` output. The store persists the object
|
|
14
|
+
* verbatim (the full `JsonValue` union) so a nested / array / null-bearing
|
|
15
|
+
* context round-trips unchanged.
|
|
16
|
+
*
|
|
17
|
+
* **Whole-file rewrite via `.tmp` + `rename`.** Each `write` is a whole-object
|
|
18
|
+
* replacement (`setSessionContext` is a whole-object set, not a merge) written
|
|
19
|
+
* to a `.tmp` sibling then `rename`d over the target — atomic on POSIX; the
|
|
20
|
+
* per-`(agentId, threadId)` in-flight queue handles the Windows
|
|
21
|
+
* concurrent-rename EPERM and serializes concurrent read/write cycles on the
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
export declare class SessionContextStore {
|
|
39
|
+
private readonly storageRootFolder;
|
|
40
|
+
private readonly logBus;
|
|
41
|
+
private readonly inflight;
|
|
42
|
+
constructor(storageRootFolder: string, logBus: LogBus | undefined);
|
|
43
|
+
/**
|
|
44
|
+
* This thread's persisted session-context object, or `{}` when nothing has
|
|
45
|
+
* been set (missing file) or the file is unparseable. Never `null`/`undefined`,
|
|
46
|
+
* never throws on a miss.
|
|
47
|
+
*/
|
|
48
|
+
read(agentId: string, threadId: string): Promise<SessionContext>;
|
|
49
|
+
/**
|
|
50
|
+
* Replace this thread's session-context object in full. Persisted verbatim
|
|
51
|
+
* so the whole `JsonValue` union survives a round-trip.
|
|
52
|
+
*/
|
|
53
|
+
write(agentId: string, threadId: string, content: SessionContext): Promise<void>;
|
|
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.
|
|
61
|
+
*/
|
|
62
|
+
delete(agentId: string, threadId: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Delete every persisted session-context file for an agent (its whole
|
|
65
|
+
* `session-context/<agentId>/` directory). Called from agent teardown so a
|
|
66
|
+
* destroyed agent leaves no orphaned sidecars behind — covers all threads in
|
|
67
|
+
* one op without enumerating them. ENOENT-tolerant (an agent that never set
|
|
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.
|
|
71
|
+
*/
|
|
72
|
+
deleteAgent(agentId: string): Promise<void>;
|
|
73
|
+
private readContext;
|
|
74
|
+
private writeContext;
|
|
75
|
+
private deleteContext;
|
|
76
|
+
private dirFor;
|
|
77
|
+
private fileFor;
|
|
78
|
+
/**
|
|
79
|
+
* Per-`(agentId, threadId)` in-flight queue. Ported from the OpenAI
|
|
80
|
+
* harness's `SessionContextStore.serialize` — serializes read-mutate-write
|
|
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).
|
|
85
|
+
*/
|
|
86
|
+
private serialize;
|
|
87
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026, Salesforce, Inc. All rights reserved.
|
|
3
|
+
* See LICENSE.txt for license terms.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { getErrorMessage } from '@salesforce/agentic-common';
|
|
8
|
+
const SESSION_CONTEXT_SUBDIR = 'session-context';
|
|
9
|
+
const FILE_EXT = '.json';
|
|
10
|
+
/**
|
|
11
|
+
* Harness-owned disk persistence for per-thread session context. One JSON file
|
|
12
|
+
* per `(agentId, threadId)` under `${storageRootFolder}/session-context/`:
|
|
13
|
+
*
|
|
14
|
+
* ${storageRootFolder}/session-context/
|
|
15
|
+
* <agentId>/
|
|
16
|
+
* <threadId>.json ← a single {@link SessionContext} object
|
|
17
|
+
*
|
|
18
|
+
* A dedicated sidecar channel, disjoint from the message transcript (the
|
|
19
|
+
* LibSQL-backed `Memory` store) — the session-context contract requires this
|
|
20
|
+
* object NOT appear in `getMessages` output. The store persists the object
|
|
21
|
+
* verbatim (the full `JsonValue` union) so a nested / array / null-bearing
|
|
22
|
+
* context round-trips unchanged.
|
|
23
|
+
*
|
|
24
|
+
* **Whole-file rewrite via `.tmp` + `rename`.** Each `write` is a whole-object
|
|
25
|
+
* replacement (`setSessionContext` is a whole-object set, not a merge) written
|
|
26
|
+
* to a `.tmp` sibling then `rename`d over the target — atomic on POSIX; the
|
|
27
|
+
* per-`(agentId, threadId)` in-flight queue handles the Windows
|
|
28
|
+
* concurrent-rename EPERM and serializes concurrent read/write cycles on the
|
|
29
|
+
* same thread. Mirrors the SDK `AgentIdentityStore` atomic-write shape.
|
|
30
|
+
*
|
|
31
|
+
* **`{}`-on-miss, never null, never throw on a miss.** `read` returns `{}` when
|
|
32
|
+
* nothing was ever set (`ENOENT`) — an empty and a never-set context are
|
|
33
|
+
* behaviorally identical, so callers need no null-check. A corrupt file is
|
|
34
|
+
* logged (`LogBus.warn`) and treated as empty rather than failing every read
|
|
35
|
+
* (the `AgentIdentityStore` soft-skip posture); the corrupt file stays on disk
|
|
36
|
+
* and the next `write` overwrites it cleanly.
|
|
37
|
+
*
|
|
38
|
+
* A deliberate behavioral copy of the OpenAI harness's `SessionContextStore`.
|
|
39
|
+
* Each harness keeps its own copy rather than importing a sibling's — harness
|
|
40
|
+
* independence is enforced by no harness `package.json` declaring another
|
|
41
|
+
* harness as a dependency (the same reason each harness carries its own
|
|
42
|
+
* `mcp-error-classifier`). Drift is guarded by the shared
|
|
43
|
+
* `runSessionContextConformance` table.
|
|
44
|
+
*/
|
|
45
|
+
export class SessionContextStore {
|
|
46
|
+
storageRootFolder;
|
|
47
|
+
logBus;
|
|
48
|
+
inflight = new Map();
|
|
49
|
+
constructor(storageRootFolder, logBus) {
|
|
50
|
+
this.storageRootFolder = storageRootFolder;
|
|
51
|
+
this.logBus = logBus;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* This thread's persisted session-context object, or `{}` when nothing has
|
|
55
|
+
* been set (missing file) or the file is unparseable. Never `null`/`undefined`,
|
|
56
|
+
* never throws on a miss.
|
|
57
|
+
*/
|
|
58
|
+
async read(agentId, threadId) {
|
|
59
|
+
return this.serialize(agentId, threadId, () => this.readContext(agentId, threadId));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Replace this thread's session-context object in full. Persisted verbatim
|
|
63
|
+
* so the whole `JsonValue` union survives a round-trip.
|
|
64
|
+
*/
|
|
65
|
+
async write(agentId, threadId, content) {
|
|
66
|
+
await this.serialize(agentId, threadId, () => this.writeContext(agentId, threadId, content));
|
|
67
|
+
}
|
|
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
|
+
*/
|
|
76
|
+
async delete(agentId, threadId) {
|
|
77
|
+
await this.serialize(agentId, threadId, () => this.deleteContext(agentId, threadId));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Delete every persisted session-context file for an agent (its whole
|
|
81
|
+
* `session-context/<agentId>/` directory). Called from agent teardown so a
|
|
82
|
+
* destroyed agent leaves no orphaned sidecars behind — covers all threads in
|
|
83
|
+
* one op without enumerating them. ENOENT-tolerant (an agent that never set
|
|
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.
|
|
87
|
+
*/
|
|
88
|
+
async deleteAgent(agentId) {
|
|
89
|
+
await rm(this.dirFor(agentId), { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
// ── Internals (called only inside `serialize`) ───────────────────────────
|
|
92
|
+
async readContext(agentId, threadId) {
|
|
93
|
+
const file = this.fileFor(agentId, threadId);
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = await readFile(file, 'utf8');
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err.code === 'ENOENT')
|
|
100
|
+
return {};
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
this.logBus?.warn('skipping unparseable Mastra session-context file', {
|
|
109
|
+
file,
|
|
110
|
+
error: getErrorMessage(err),
|
|
111
|
+
});
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
// A bare array / scalar / null parsed cleanly but is not the on-disk
|
|
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.
|
|
118
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
119
|
+
this.logBus?.warn('skipping non-object Mastra session-context file', { file });
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
async writeContext(agentId, threadId, content) {
|
|
125
|
+
const file = this.fileFor(agentId, threadId);
|
|
126
|
+
await mkdir(dirname(file), { recursive: true });
|
|
127
|
+
const tmp = `${file}.tmp`;
|
|
128
|
+
await writeFile(tmp, JSON.stringify(content), 'utf8');
|
|
129
|
+
await rename(tmp, file);
|
|
130
|
+
}
|
|
131
|
+
async deleteContext(agentId, threadId) {
|
|
132
|
+
// `force: true` makes a missing file a no-op (no ENOENT throw) — deleting
|
|
133
|
+
// a thread that never had session context set is not an error.
|
|
134
|
+
await rm(this.fileFor(agentId, threadId), { force: true });
|
|
135
|
+
}
|
|
136
|
+
dirFor(agentId) {
|
|
137
|
+
return join(this.storageRootFolder, SESSION_CONTEXT_SUBDIR, encodeSegment(agentId));
|
|
138
|
+
}
|
|
139
|
+
fileFor(agentId, threadId) {
|
|
140
|
+
return join(this.dirFor(agentId), `${encodeSegment(threadId)}${FILE_EXT}`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Per-`(agentId, threadId)` in-flight queue. Ported from the OpenAI
|
|
144
|
+
* harness's `SessionContextStore.serialize` — serializes read-mutate-write
|
|
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).
|
|
149
|
+
*/
|
|
150
|
+
serialize(agentId, threadId, fn) {
|
|
151
|
+
const k = `${encodeSegment(agentId)} ${encodeSegment(threadId)}`;
|
|
152
|
+
const previous = this.inflight.get(k) ?? Promise.resolve();
|
|
153
|
+
const next = previous.catch(() => undefined).then(fn);
|
|
154
|
+
this.inflight.set(k, next);
|
|
155
|
+
next.finally(() => {
|
|
156
|
+
if (this.inflight.get(k) === next)
|
|
157
|
+
this.inflight.delete(k);
|
|
158
|
+
}).catch(() => undefined);
|
|
159
|
+
return next;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Encode an agent/thread id into a single safe path segment via base64url.
|
|
164
|
+
*
|
|
165
|
+
* Injective over well-formed strings — distinct ids map to distinct segments —
|
|
166
|
+
* for two reasons a lossy `[^A-Za-z0-9._-] → _` collapse got wrong: (1) it
|
|
167
|
+
* aliased distinct threads (`a/b` and `a?b` both became `a_b`), so one chat
|
|
168
|
+
* could read/overwrite another's session context and share its serialize-queue
|
|
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.
|
|
179
|
+
*/
|
|
180
|
+
function encodeSegment(s) {
|
|
181
|
+
return Buffer.from(s, 'utf8').toString('base64url');
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=session-context-store.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/sfdx-agent-harness-mastra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
"openai": "^7.0.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
45
|
+
"@salesforce/sfdx-agent-sdk": "0.50.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.50.0",
|
|
51
51
|
"@types/node": "^22.20.1",
|
|
52
52
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
53
53
|
"@vitest/eslint-plugin": "^1.6.26",
|