Skip to content

Commit cabb553

Browse files
steipeteefpiva
andcommitted
feat(plugin-sdk): add session entry workflow helpers
Co-authored-by: Eduardo Piva <[email protected]>
1 parent 0ab1449 commit cabb553

39 files changed

Lines changed: 1361 additions & 221 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
77
### Changes
88

99
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
10+
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
1011
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
1112
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
1213
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
68bf012e03431fa1e29416489b829be3d2b6897f732d45de5b20fa30eb754119 plugin-sdk-api-baseline.json
2-
7c6aa6ff2b42935228cc5cba1c2dd963af7534ac37daec3a5a8d0b9b3ba1620d plugin-sdk-api-baseline.jsonl
1+
bb0da3ba4560521d2c9725cd96429f64ce8e6150972ba77be71fdf8ea03e0234 plugin-sdk-api-baseline.json
2+
4d951b989cc00a86f64907bb28d52a950a466116382c9877f24807b0fba3df44 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-runtime.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,18 @@ two-party event loops that do not go through the shared channel-turn kernel.
153153
**Session store helpers** are under `api.runtime.agent.session`:
154154

155155
```typescript
156-
const storePath = api.runtime.agent.session.resolveStorePath(cfg);
157-
const store = api.runtime.agent.session.loadSessionStore(storePath);
158-
await api.runtime.agent.session.updateSessionStore(storePath, (nextStore) => {
159-
// Patch one entry without replacing the whole file from stale state.
160-
nextStore[sessionKey] = { ...nextStore[sessionKey], thinkingLevel: "high" };
156+
const entry = api.runtime.agent.session.getSessionEntry({ agentId, sessionKey });
157+
for (const { sessionKey, entry } of api.runtime.agent.session.listSessionEntries({ agentId })) {
158+
// Iterate session rows without depending on the legacy sessions.json shape.
159+
}
160+
await api.runtime.agent.session.patchSessionEntry({
161+
agentId,
162+
sessionKey,
163+
update: (entry) => ({ thinkingLevel: "high" }),
161164
});
162-
const filePath = api.runtime.agent.session.resolveSessionFilePath(cfg, sessionId);
163165
```
164166

165-
Prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)` for runtime writes. They route through the Gateway-owned session-store writer, preserve concurrent updates, and reuse the hot cache. `saveSessionStore(...)` remains available for compatibility and offline maintenance-style rewrites.
167+
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone.
166168

167169
</Accordion>
168170
<Accordion title="api.runtime.agent.defaults">

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
248248
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
249249
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
250250
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
251-
| `plugin-sdk/session-store-runtime` | Session store path, session-key, updated-at, and store mutation helpers |
251+
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
252252
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
253253
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
254254
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |

extensions/active-memory/index.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ const hoisted = vi.hoisted(() => {
3030
closeActiveMemorySearchManager: vi.fn(async () => {}),
3131
sessionStore,
3232
updateSessionStore: vi.fn(
33-
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
33+
async (
34+
_storePath: string,
35+
updater: (store: Record<string, Record<string, unknown>>) => void,
36+
) => {
3437
updater(sessionStore);
3538
},
3639
),
@@ -113,6 +116,42 @@ describe("active-memory plugin", () => {
113116
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
114117
loadSessionStore: vi.fn(() => hoisted.sessionStore),
115118
saveSessionStore: vi.fn(async () => {}),
119+
getSessionEntry: vi.fn(
120+
(params: { sessionKey: string }) => hoisted.sessionStore[params.sessionKey],
121+
),
122+
listSessionEntries: vi.fn(() =>
123+
Object.entries(hoisted.sessionStore).map(([sessionKey, entry]) => ({
124+
sessionKey,
125+
entry,
126+
})),
127+
),
128+
patchSessionEntry: vi.fn(
129+
async (params: {
130+
sessionKey: string;
131+
fallbackEntry?: Record<string, unknown>;
132+
update: (entry: Record<string, unknown>) => Record<string, unknown> | null;
133+
}) => {
134+
let result: Record<string, unknown> | null = null;
135+
await hoisted.updateSessionStore(
136+
"/tmp/openclaw-session-store.json",
137+
(store: Record<string, Record<string, unknown>>) => {
138+
const existing = store[params.sessionKey] ?? params.fallbackEntry;
139+
if (!existing) {
140+
return;
141+
}
142+
const patch = params.update({ ...existing });
143+
if (!patch) {
144+
result = existing;
145+
return;
146+
}
147+
const next = { ...existing, ...patch };
148+
store[params.sessionKey] = next;
149+
result = next;
150+
},
151+
);
152+
return result;
153+
},
154+
),
116155
},
117156
},
118157
state: {
@@ -2961,7 +3000,7 @@ describe("active-memory plugin", () => {
29613000

29623001
it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => {
29633002
const CONFIGURED_TIMEOUT_MS = 25;
2964-
const HARD_DEADLINE_MARGIN_MS = 500;
3003+
const HARD_DEADLINE_MARGIN_MS = 1_500;
29653004
testing.setMinimumTimeoutMsForTests(1);
29663005
testing.setSetupGraceTimeoutMsForTests(0);
29673006
api.pluginConfig = {
@@ -2999,6 +3038,7 @@ describe("active-memory plugin", () => {
29993038
const CONFIGURED_TIMEOUT_MS = 50;
30003039
testing.setMinimumTimeoutMsForTests(1);
30013040
testing.setSetupGraceTimeoutMsForTests(0);
3041+
testing.setTimeoutPartialDataGraceMsForTests(50);
30023042
api.pluginConfig = {
30033043
agents: ["main"],
30043044
timeoutMs: CONFIGURED_TIMEOUT_MS,
@@ -3142,6 +3182,10 @@ describe("active-memory plugin", () => {
31423182
timeoutMs: 100,
31433183
};
31443184
plugin.register(api as unknown as OpenClawPluginApi);
3185+
hoisted.sessionStore["agent:main:memory-get-miss"] = {
3186+
sessionId: "s-memory-get-miss",
3187+
updatedAt: 0,
3188+
};
31453189
runEmbeddedPiAgent.mockImplementationOnce(async (params: { sessionFile: string }) => {
31463190
await writeTranscriptJsonl(params.sessionFile, [
31473191
{

extensions/active-memory/index.ts

Lines changed: 42 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ import {
2020
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2121
import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
2222
import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
23-
import {
24-
resolveSessionStoreEntry,
25-
updateSessionStore,
26-
} from "openclaw/plugin-sdk/session-store-runtime";
2723
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
2824

2925
const DEFAULT_TIMEOUT_MS = 15_000;
@@ -542,20 +538,15 @@ function resolveCanonicalSessionKeyFromSessionId(params: {
542538
return undefined;
543539
}
544540
try {
545-
const storePath = params.api.runtime.agent.session.resolveStorePath(
546-
params.api.config.session?.store,
547-
{
548-
agentId: params.agentId,
549-
},
550-
);
551-
const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false });
552541
let bestMatch:
553542
| {
554543
sessionKey: string;
555544
updatedAt: number;
556545
}
557546
| undefined;
558-
for (const [sessionKey, entry] of Object.entries(store)) {
547+
for (const { sessionKey, entry } of params.api.runtime.agent.session.listSessionEntries({
548+
agentId: params.agentId,
549+
})) {
559550
if (!entry || typeof entry !== "object") {
560551
continue;
561552
}
@@ -673,17 +664,10 @@ function resolveRecallRunChannelContext(params: {
673664
}
674665

675666
try {
676-
const storePath = params.api.runtime.agent.session.resolveStorePath(
677-
params.api.config.session?.store,
678-
{
679-
agentId: params.agentId,
680-
},
681-
);
682-
const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false });
683-
const sessionEntry = resolveSessionStoreEntry({
684-
store,
667+
const sessionEntry = params.api.runtime.agent.session.getSessionEntry({
668+
agentId: params.agentId,
685669
sessionKey: resolvedSessionKey,
686-
}).existing;
670+
});
687671
const rawStrongEntryChannel =
688672
normalizeOptionalString(sessionEntry?.lastChannel) ??
689673
normalizeOptionalString(sessionEntry?.channel);
@@ -1594,53 +1578,50 @@ async function persistPluginStatusLines(params: {
15941578
return;
15951579
}
15961580
try {
1597-
const storePath = params.api.runtime.agent.session.resolveStorePath(
1598-
params.api.config.session?.store,
1599-
agentId ? { agentId } : undefined,
1600-
);
16011581
if (!params.statusLine && !debugLine) {
1602-
const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false });
1603-
const existingEntry = resolveSessionStoreEntry({ store, sessionKey }).existing;
1582+
const existingEntry = params.api.runtime.agent.session.getSessionEntry({
1583+
agentId,
1584+
sessionKey,
1585+
});
16041586
const hasActiveMemoryEntry = Array.isArray(existingEntry?.pluginDebugEntries)
16051587
? existingEntry.pluginDebugEntries.some((entry) => entry?.pluginId === "active-memory")
16061588
: false;
16071589
if (!hasActiveMemoryEntry) {
16081590
return;
16091591
}
16101592
}
1611-
await updateSessionStore(storePath, (store) => {
1612-
const resolved = resolveSessionStoreEntry({ store, sessionKey });
1613-
const existing = resolved.existing;
1614-
if (!existing) {
1615-
return;
1616-
}
1617-
const previousEntries = Array.isArray(existing.pluginDebugEntries)
1618-
? existing.pluginDebugEntries
1619-
: [];
1620-
const nextEntries = previousEntries.filter(
1621-
(entry): entry is PluginDebugEntry =>
1622-
Boolean(entry) &&
1623-
typeof entry === "object" &&
1624-
typeof entry.pluginId === "string" &&
1625-
entry.pluginId !== "active-memory",
1626-
);
1627-
const nextLines: string[] = [];
1628-
if (params.statusLine) {
1629-
nextLines.push(params.statusLine);
1630-
}
1631-
if (debugLine) {
1632-
nextLines.push(debugLine);
1633-
}
1634-
if (nextLines.length > 0) {
1635-
nextEntries.push({
1636-
pluginId: "active-memory",
1637-
lines: nextLines,
1638-
});
1639-
}
1640-
store[resolved.normalizedKey] = {
1641-
...existing,
1642-
pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined,
1643-
};
1593+
await params.api.runtime.agent.session.patchSessionEntry({
1594+
agentId,
1595+
sessionKey,
1596+
preserveActivity: true,
1597+
update: (existing) => {
1598+
const previousEntries = Array.isArray(existing.pluginDebugEntries)
1599+
? existing.pluginDebugEntries
1600+
: [];
1601+
const nextEntries = previousEntries.filter(
1602+
(entry): entry is PluginDebugEntry =>
1603+
Boolean(entry) &&
1604+
typeof entry === "object" &&
1605+
typeof entry.pluginId === "string" &&
1606+
entry.pluginId !== "active-memory",
1607+
);
1608+
const nextLines: string[] = [];
1609+
if (params.statusLine) {
1610+
nextLines.push(params.statusLine);
1611+
}
1612+
if (debugLine) {
1613+
nextLines.push(debugLine);
1614+
}
1615+
if (nextLines.length > 0) {
1616+
nextEntries.push({
1617+
pluginId: "active-memory",
1618+
lines: nextLines,
1619+
});
1620+
}
1621+
return {
1622+
pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined,
1623+
};
1624+
},
16441625
});
16451626
} catch (error) {
16461627
params.api.logger.debug?.(

extensions/voice-call/src/response-generator.test.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ type TestSessionEntry = {
99
providerOverride?: string;
1010
modelOverride?: string;
1111
modelOverrideSource?: string;
12+
model?: string;
13+
modelProvider?: string;
14+
contextTokens?: number;
15+
authProfileOverride?: string;
1216
};
1317

1418
type EmbeddedAgentArgs = {
@@ -32,6 +36,34 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
3236
return await mutator(sessionStore);
3337
},
3438
);
39+
const getSessionEntry = vi.fn(
40+
(params: { sessionKey: string }) => sessionStore[params.sessionKey],
41+
);
42+
const patchSessionEntry = vi.fn(
43+
async (params: {
44+
sessionKey: string;
45+
fallbackEntry?: TestSessionEntry;
46+
replaceEntry?: boolean;
47+
update: (entry: TestSessionEntry) => Partial<TestSessionEntry> | null;
48+
}) => {
49+
const existing = sessionStore[params.sessionKey] ?? params.fallbackEntry;
50+
if (!existing) {
51+
return null;
52+
}
53+
const patch = params.update({ ...existing });
54+
if (!patch) {
55+
return existing;
56+
}
57+
const next = params.replaceEntry ? (patch as TestSessionEntry) : { ...existing, ...patch };
58+
sessionStore[params.sessionKey] = next;
59+
return next;
60+
},
61+
);
62+
const upsertSessionEntry = vi.fn(
63+
async (params: { sessionKey: string; entry: TestSessionEntry }) => {
64+
sessionStore[params.sessionKey] = { ...params.entry };
65+
},
66+
);
3567
const runEmbeddedPiAgent = vi.fn(async () => ({
3668
payloads,
3769
meta: { durationMs: 12, aborted: false },
@@ -71,6 +103,9 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
71103
loadSessionStore: () => sessionStore,
72104
saveSessionStore,
73105
updateSessionStore,
106+
getSessionEntry,
107+
patchSessionEntry,
108+
upsertSessionEntry,
74109
resolveSessionFilePath,
75110
},
76111
} as unknown as CoreAgentDeps;
@@ -80,6 +115,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
80115
runEmbeddedPiAgent,
81116
saveSessionStore,
82117
updateSessionStore,
118+
patchSessionEntry,
83119
sessionStore,
84120
resolveAgentDir,
85121
resolveAgentWorkspaceDir,
@@ -187,9 +223,17 @@ describe("generateVoiceResponse", () => {
187223
});
188224

189225
it("pins the voice session to responseModel before running the embedded agent", async () => {
190-
const { runtime, runEmbeddedPiAgent, updateSessionStore, sessionStore } = createAgentRuntime([
226+
const { runtime, runEmbeddedPiAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
191227
{ text: '{"spoken":"Pinned model works."}' },
192228
]);
229+
sessionStore["voice:15550001111"] = {
230+
sessionId: "existing-session",
231+
updatedAt: 100,
232+
model: "old-model",
233+
modelProvider: "old-provider",
234+
contextTokens: 123,
235+
authProfileOverride: "old-auth-profile",
236+
};
193237
const voiceConfig = VoiceCallConfigSchema.parse({
194238
responseModel: "openai/gpt-4.1-nano",
195239
responseTimeoutMs: 5000,
@@ -210,12 +254,20 @@ describe("generateVoiceResponse", () => {
210254
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
211255
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
212256
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
213-
const updateSessionStoreCall = requireFirstMockCall(
214-
updateSessionStore.mock.calls,
215-
"session store update",
257+
expect(pinnedSessionEntry?.model).toBeUndefined();
258+
expect(pinnedSessionEntry?.modelProvider).toBeUndefined();
259+
expect(pinnedSessionEntry?.contextTokens).toBeUndefined();
260+
expect(pinnedSessionEntry?.authProfileOverride).toBeUndefined();
261+
const patchSessionEntryCall = requireFirstMockCall(
262+
patchSessionEntry.mock.calls,
263+
"session entry patch",
216264
);
217-
expect(updateSessionStoreCall[0]).toBe("/tmp/openclaw/main/sessions.json");
218-
expect(updateSessionStoreCall[1]).toBeTypeOf("function");
265+
expect(patchSessionEntryCall[0]).toMatchObject({
266+
storePath: "/tmp/openclaw/main/sessions.json",
267+
sessionKey: "voice:15550001111",
268+
replaceEntry: true,
269+
});
270+
expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function");
219271
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
220272
expect(args.provider).toBe("openai");
221273
expect(args.model).toBe("gpt-4.1-nano");

0 commit comments

Comments
 (0)