Skip to content

Commit d1f4cb4

Browse files
committed
Cache pre-tool metadata resolution
1 parent 158b7a1 commit d1f4cb4

9 files changed

Lines changed: 472 additions & 30 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcon
3535

3636
When the gateway starts, the plugin loads the OpenClaw tool catalog and syncs it to Agent Control. On every tool call, the plugin intercepts the invocation through a `before_tool_call` hook, builds an evaluation context (session, channel, provider, agent identity), and sends it to Agent Control for a policy decision. If the evaluation comes back safe the call proceeds normally. If it comes back denied the call is blocked and the user sees a rejection message.
3737

38-
The plugin handles multiple agents, tracks tool catalog changes between calls, and re-syncs automatically when the catalog drifts.
38+
The plugin handles multiple agents, caches the resolved tool catalog briefly to keep the pre-tool hook fast, and re-syncs automatically when the catalog drifts.
3939

4040
## Quick start
4141

src/agent-control-plugin.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "./observability.ts";
1111
import { resolveStepsForContext } from "./tool-catalog.ts";
1212
import { buildEvaluationContext } from "./session-context.ts";
13+
import { warmSessionIdentityResolver } from "./session-store.ts";
1314
import {
1415
asPositiveInt,
1516
asString,
@@ -106,6 +107,7 @@ export default function register(api: OpenClawPluginApi) {
106107
const states = new Map<string, AgentState>();
107108
let gatewayWarmupPromise: Promise<void> | null = null;
108109
let gatewayWarmupStatus: "idle" | "running" | "done" | "failed" = "idle";
110+
let sessionIdentityWarmupPromise: Promise<void> | null = null;
109111

110112
const getOrCreateState = (sourceAgentId: string): AgentState => {
111113
const existing = states.get(sourceAgentId);
@@ -160,6 +162,30 @@ export default function register(api: OpenClawPluginApi) {
160162
return gatewayWarmupPromise;
161163
};
162164

165+
const ensureSessionIdentityWarmup = (): Promise<void> => {
166+
if (sessionIdentityWarmupPromise) {
167+
return sessionIdentityWarmupPromise;
168+
}
169+
170+
const warmupStartedAt = process.hrtime.bigint();
171+
sessionIdentityWarmupPromise = warmSessionIdentityResolver({
172+
api,
173+
sourceAgentId: BOOT_WARMUP_AGENT_ID,
174+
})
175+
.then(() => {
176+
logger.debug(
177+
`agent-control: session_identity_warmup done duration_sec=${secondsSince(warmupStartedAt)}`,
178+
);
179+
})
180+
.catch((err) => {
181+
logger.debug(
182+
`agent-control: session_identity_warmup failed duration_sec=${secondsSince(warmupStartedAt)} error=${formatAgentControlError(err)}`,
183+
);
184+
});
185+
186+
return sessionIdentityWarmupPromise;
187+
};
188+
163189
const syncAgent = async (state: AgentState): Promise<void> => {
164190
if (state.syncPromise) {
165191
await state.syncPromise;
@@ -205,6 +231,7 @@ export default function register(api: OpenClawPluginApi) {
205231
};
206232

207233
api.on("gateway_start", async () => {
234+
void ensureSessionIdentityWarmup();
208235
await ensureGatewayWarmup();
209236
});
210237

src/session-context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ export async function buildEvaluationContext(params: {
8383
configuredAgentVersion?: string;
8484
}): Promise<Record<string, unknown>> {
8585
const channelFromSessionKey = deriveChannelContext(params.ctx.sessionKey);
86-
const sessionIdentity = await resolveSessionIdentity(params.ctx.sessionKey);
86+
const sessionIdentity = await resolveSessionIdentity({
87+
api: params.api,
88+
sessionKey: params.ctx.sessionKey,
89+
sourceAgentId: params.sourceAgentId,
90+
});
8791
const mergedChannelType =
8892
sessionIdentity.type !== "unknown" ? sessionIdentity.type : channelFromSessionKey.type;
8993
const mergedChannelProvider = sessionIdentity.provider ?? channelFromSessionKey.provider;

src/session-store.ts

Lines changed: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,49 @@
11
import type { SessionIdentitySnapshot, SessionMetadataCacheEntry, SessionStoreInternals } from "./types.ts";
22
import { asString, isRecord } from "./shared.ts";
33
import { getResolvedOpenClawRootDir, importOpenClawInternalModule } from "./openclaw-runtime.ts";
4+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
45

5-
const SESSION_META_CACHE_TTL_MS = 2_000;
6+
const SESSION_META_KNOWN_CACHE_TTL_MS = 60_000;
7+
const SESSION_META_UNKNOWN_CACHE_TTL_MS = 2_000;
68
const SESSION_META_CACHE_MAX = 512;
79

810
let sessionStoreInternalsPromise: Promise<SessionStoreInternals> | null = null;
911
const sessionMetadataCache = new Map<string, SessionMetadataCacheEntry>();
1012

11-
async function loadSessionStoreInternals(): Promise<SessionStoreInternals> {
13+
function resolveRuntimeSessionStoreInternals(
14+
api: OpenClawPluginApi | undefined,
15+
): SessionStoreInternals | null {
16+
const runtime = isRecord(api?.runtime) ? api.runtime : undefined;
17+
const runtimeConfig = isRecord(runtime?.config) ? runtime.config : undefined;
18+
const runtimeAgent = isRecord(runtime?.agent) ? runtime.agent : undefined;
19+
const runtimeAgentSession = isRecord(runtimeAgent?.session) ? runtimeAgent.session : undefined;
20+
21+
const resolveStorePath = runtimeAgentSession?.resolveStorePath;
22+
const loadSessionStore = runtimeAgentSession?.loadSessionStore;
23+
if (typeof resolveStorePath !== "function" || typeof loadSessionStore !== "function") {
24+
return null;
25+
}
26+
27+
const loadConfig = runtimeConfig?.loadConfig;
28+
const fallbackConfig = isRecord(api?.config) ? api.config : {};
29+
return {
30+
loadConfig:
31+
typeof loadConfig === "function"
32+
? (loadConfig as SessionStoreInternals["loadConfig"])
33+
: () => fallbackConfig,
34+
resolveStorePath: resolveStorePath as SessionStoreInternals["resolveStorePath"],
35+
loadSessionStore: loadSessionStore as SessionStoreInternals["loadSessionStore"],
36+
};
37+
}
38+
39+
async function loadSessionStoreInternals(
40+
api: OpenClawPluginApi | undefined,
41+
): Promise<SessionStoreInternals> {
42+
const runtimeInternals = resolveRuntimeSessionStoreInternals(api);
43+
if (runtimeInternals) {
44+
return runtimeInternals;
45+
}
46+
1247
if (sessionStoreInternalsPromise) {
1348
return sessionStoreInternalsPromise;
1449
}
@@ -50,6 +85,19 @@ async function loadSessionStoreInternals(): Promise<SessionStoreInternals> {
5085
return sessionStoreInternalsPromise;
5186
}
5287

88+
export async function warmSessionIdentityResolver(params: {
89+
api: OpenClawPluginApi;
90+
sourceAgentId?: string;
91+
}): Promise<void> {
92+
const internals = await loadSessionStoreInternals(params.api);
93+
const cfg = internals.loadConfig();
94+
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
95+
const storePath = internals.resolveStorePath(asString(sessionCfg?.store), {
96+
agentId: asString(params.sourceAgentId),
97+
});
98+
internals.loadSessionStore(storePath);
99+
}
100+
53101
function unknownSessionIdentity(): SessionIdentitySnapshot {
54102
return {
55103
provider: null,
@@ -80,6 +128,17 @@ function resolveBaseSessionKey(sessionKey: string): string {
80128
return base || sessionKey;
81129
}
82130

131+
function resolveSessionAgentId(
132+
normalizedSessionKey: string,
133+
sourceAgentId: string | undefined,
134+
): string | undefined {
135+
const parts = normalizedSessionKey.split(":").filter(Boolean);
136+
if (parts.length >= 2 && parts[0] === "agent" && parts[1]) {
137+
return parts[1];
138+
}
139+
return asString(sourceAgentId);
140+
}
141+
83142
function readSessionIdentityFromEntry(entry: Record<string, unknown>): SessionIdentitySnapshot {
84143
const origin = isRecord(entry.origin) ? entry.origin : undefined;
85144
const deliveryContext = isRecord(entry.deliveryContext) ? entry.deliveryContext : undefined;
@@ -120,7 +179,16 @@ function readSessionIdentityFromEntry(entry: Record<string, unknown>): SessionId
120179
}
121180

122181
function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): void {
123-
sessionMetadataCache.set(key, { at: Date.now(), data });
182+
const now = Date.now();
183+
sessionMetadataCache.set(key, {
184+
at: now,
185+
data,
186+
expiresAt:
187+
now +
188+
(data.source === "sessionStore"
189+
? SESSION_META_KNOWN_CACHE_TTL_MS
190+
: SESSION_META_UNKNOWN_CACHE_TTL_MS),
191+
});
124192
if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) {
125193
const oldest = sessionMetadataCache.keys().next().value;
126194
if (typeof oldest === "string") {
@@ -129,36 +197,76 @@ function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): vo
129197
}
130198
}
131199

132-
export async function resolveSessionIdentity(
133-
sessionKey: string | undefined,
134-
): Promise<SessionIdentitySnapshot> {
135-
const normalizedKey = normalizeSessionStoreKey(sessionKey);
136-
if (!normalizedKey) {
137-
return unknownSessionIdentity();
138-
}
139-
140-
const cached = sessionMetadataCache.get(normalizedKey);
141-
if (cached && Date.now() - cached.at < SESSION_META_CACHE_TTL_MS) {
142-
return cached.data;
200+
function setSessionMetadataCachePromise(
201+
key: string,
202+
promise: Promise<SessionIdentitySnapshot>,
203+
): void {
204+
sessionMetadataCache.set(key, { at: Date.now(), promise });
205+
if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) {
206+
const oldest = sessionMetadataCache.keys().next().value;
207+
if (typeof oldest === "string" && oldest !== key) {
208+
sessionMetadataCache.delete(oldest);
209+
}
143210
}
211+
}
144212

213+
async function readSessionIdentity(params: {
214+
api?: OpenClawPluginApi;
215+
normalizedKey: string;
216+
sourceAgentId?: string;
217+
}): Promise<SessionIdentitySnapshot> {
145218
try {
146-
const internals = await loadSessionStoreInternals();
219+
const internals = await loadSessionStoreInternals(params.api);
147220
const cfg = internals.loadConfig();
148221
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
149-
const storePath = internals.resolveStorePath(asString(sessionCfg?.store));
222+
const storeAgentId = resolveSessionAgentId(params.normalizedKey, params.sourceAgentId);
223+
const storePath = internals.resolveStorePath(asString(sessionCfg?.store), {
224+
agentId: storeAgentId,
225+
});
150226
const store = internals.loadSessionStore(storePath);
151-
const directEntry = store[normalizedKey];
152-
const baseEntry = store[resolveBaseSessionKey(normalizedKey)];
227+
const directEntry = store[params.normalizedKey];
228+
const baseEntry = store[resolveBaseSessionKey(params.normalizedKey)];
153229
const entry: Record<string, unknown> | undefined = isRecord(directEntry)
154230
? directEntry
155231
: isRecord(baseEntry)
156232
? baseEntry
157233
: undefined;
158-
const data = entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity();
159-
setSessionMetadataCache(normalizedKey, data);
160-
return data;
234+
return entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity();
161235
} catch {
162236
return unknownSessionIdentity();
163237
}
164238
}
239+
240+
export async function resolveSessionIdentity(
241+
input:
242+
| string
243+
| undefined
244+
| {
245+
api?: OpenClawPluginApi;
246+
sessionKey?: string;
247+
sourceAgentId?: string;
248+
},
249+
): Promise<SessionIdentitySnapshot> {
250+
const sessionKey = typeof input === "object" ? input.sessionKey : input;
251+
const api = typeof input === "object" ? input.api : undefined;
252+
const sourceAgentId = typeof input === "object" ? input.sourceAgentId : undefined;
253+
const normalizedKey = normalizeSessionStoreKey(sessionKey);
254+
if (!normalizedKey) {
255+
return unknownSessionIdentity();
256+
}
257+
258+
const cached = sessionMetadataCache.get(normalizedKey);
259+
if (cached?.data && cached.expiresAt && Date.now() < cached.expiresAt) {
260+
return cached.data;
261+
}
262+
if (cached?.promise) {
263+
return cached.promise;
264+
}
265+
266+
const promise = readSessionIdentity({ api, normalizedKey, sourceAgentId }).then((data) => {
267+
setSessionMetadataCache(normalizedKey, data);
268+
return data;
269+
});
270+
setSessionMetadataCachePromise(normalizedKey, promise);
271+
return promise;
272+
}

0 commit comments

Comments
 (0)