Skip to content

Commit 6818d1e

Browse files
authored
perf: cache pre-tool metadata resolution (#25)
* Cache pre-tool metadata resolution * Tighten pre-tool cache keys * Fix cache edge cases * ci: validate semantic PR title only
1 parent 158b7a1 commit 6818d1e

11 files changed

Lines changed: 840 additions & 46 deletions

.github/workflows/pr-title.yml

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,13 @@ jobs:
2020
semantic-pr-title:
2121
runs-on: ubuntu-latest
2222
steps:
23-
- name: Check out PR head
24-
uses: actions/checkout@v5
25-
with:
26-
ref: ${{ github.event.pull_request.head.sha }}
27-
fetch-depth: 1
28-
29-
- name: Validate semantic PR title and head commit subject
23+
- name: Validate semantic PR title
3024
env:
3125
PR_TITLE: ${{ github.event.pull_request.title }}
3226
run: |
3327
python3 - <<'PY'
3428
import os
3529
import re
36-
import subprocess
3730
import sys
3831
3932
pattern = re.compile(
@@ -43,7 +36,7 @@ jobs:
4336
4437
def validate(label: str, value: str) -> None:
4538
if pattern.match(value):
46-
print(f"{label} is a valid conventional commit title: {value}")
39+
print(f"{label} is a valid conventional title: {value}")
4740
return
4841
4942
print(f"Invalid {label}: {value}", file=sys.stderr)
@@ -52,10 +45,4 @@ jobs:
5245
raise SystemExit(1)
5346
5447
validate("PR title", os.environ["PR_TITLE"])
55-
56-
head_subject = subprocess.check_output(
57-
["git", "log", "-1", "--pretty=%s"],
58-
text=True,
59-
).strip()
60-
validate("head commit subject", head_subject)
6148
PY

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: 30 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,9 +162,36 @@ 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;
192+
if (state.lastSyncedStepsHash !== state.stepsHash) {
193+
await syncAgent(state);
194+
}
166195
return;
167196
}
168197
if (state.lastSyncedStepsHash === state.stepsHash) {
@@ -205,6 +234,7 @@ export default function register(api: OpenClawPluginApi) {
205234
};
206235

207236
api.on("gateway_start", async () => {
237+
void ensureSessionIdentityWarmup();
208238
await ensureGatewayWarmup();
209239
});
210240

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: 166 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
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

56
const SESSION_META_CACHE_TTL_MS = 2_000;
67
const SESSION_META_CACHE_MAX = 512;
78

89
let sessionStoreInternalsPromise: Promise<SessionStoreInternals> | null = null;
910
const sessionMetadataCache = new Map<string, SessionMetadataCacheEntry>();
1011

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

87+
export async function warmSessionIdentityResolver(params: {
88+
api: OpenClawPluginApi;
89+
sourceAgentId?: string;
90+
}): Promise<void> {
91+
const internals = await loadSessionStoreInternals(params.api);
92+
const cfg = internals.loadConfig();
93+
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
94+
const storePath = internals.resolveStorePath(asString(sessionCfg?.store), {
95+
agentId: asString(params.sourceAgentId),
96+
});
97+
internals.loadSessionStore(storePath);
98+
}
99+
53100
function unknownSessionIdentity(): SessionIdentitySnapshot {
54101
return {
55102
provider: null,
@@ -80,6 +127,18 @@ function resolveBaseSessionKey(sessionKey: string): string {
80127
return base || sessionKey;
81128
}
82129

130+
function resolveSessionAgentId(
131+
normalizedSessionKey: string,
132+
sourceAgentId: string | undefined,
133+
): string | undefined {
134+
const parts = normalizedSessionKey.split(":").filter(Boolean);
135+
if (parts.length >= 2 && parts[0] === "agent" && parts[1]) {
136+
return parts[1];
137+
}
138+
const normalizedSourceAgentId = asString(sourceAgentId);
139+
return normalizedSourceAgentId === "default" ? undefined : normalizedSourceAgentId;
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,12 @@ 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: now + SESSION_META_CACHE_TTL_MS,
187+
});
124188
if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) {
125189
const oldest = sessionMetadataCache.keys().next().value;
126190
if (typeof oldest === "string") {
@@ -129,36 +193,115 @@ function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): vo
129193
}
130194
}
131195

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;
196+
function setSessionMetadataCachePromise(
197+
key: string,
198+
promise: Promise<SessionIdentitySnapshot>,
199+
): void {
200+
sessionMetadataCache.set(key, { at: Date.now(), promise });
201+
if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) {
202+
const oldest = sessionMetadataCache.keys().next().value;
203+
if (typeof oldest === "string" && oldest !== key) {
204+
sessionMetadataCache.delete(oldest);
205+
}
143206
}
207+
}
144208

209+
async function readSessionIdentity(params: {
210+
normalizedKey: string;
211+
internals: SessionStoreInternals;
212+
storePath: string;
213+
}): Promise<SessionIdentitySnapshot> {
145214
try {
146-
const internals = await loadSessionStoreInternals();
147-
const cfg = internals.loadConfig();
148-
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
149-
const storePath = internals.resolveStorePath(asString(sessionCfg?.store));
150-
const store = internals.loadSessionStore(storePath);
151-
const directEntry = store[normalizedKey];
152-
const baseEntry = store[resolveBaseSessionKey(normalizedKey)];
215+
const store = params.internals.loadSessionStore(params.storePath);
216+
const directEntry = store[params.normalizedKey];
217+
const baseEntry = store[resolveBaseSessionKey(params.normalizedKey)];
153218
const entry: Record<string, unknown> | undefined = isRecord(directEntry)
154219
? directEntry
155220
: isRecord(baseEntry)
156221
? baseEntry
157222
: undefined;
158-
const data = entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity();
159-
setSessionMetadataCache(normalizedKey, data);
160-
return data;
223+
return entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity();
224+
} catch {
225+
return unknownSessionIdentity();
226+
}
227+
}
228+
229+
function buildSessionMetadataCacheKey(params: {
230+
normalizedKey: string;
231+
storePath: string;
232+
}): string {
233+
return JSON.stringify({
234+
normalizedKey: params.normalizedKey,
235+
storePath: params.storePath,
236+
});
237+
}
238+
239+
async function resolveSessionStoreLookupContext(params: {
240+
api?: OpenClawPluginApi;
241+
normalizedKey: string;
242+
sourceAgentId?: string;
243+
}): Promise<{ internals: SessionStoreInternals; storePath: string } | null> {
244+
try {
245+
const internals = await loadSessionStoreInternals(params.api);
246+
const cfg = internals.loadConfig();
247+
const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined;
248+
const storeAgentId = resolveSessionAgentId(params.normalizedKey, params.sourceAgentId);
249+
const storePath = internals.resolveStorePath(asString(sessionCfg?.store), {
250+
agentId: storeAgentId,
251+
});
252+
return { internals, storePath };
161253
} catch {
254+
return null;
255+
}
256+
}
257+
258+
export async function resolveSessionIdentity(
259+
input:
260+
| string
261+
| undefined
262+
| {
263+
api?: OpenClawPluginApi;
264+
sessionKey?: string;
265+
sourceAgentId?: string;
266+
},
267+
): Promise<SessionIdentitySnapshot> {
268+
const sessionKey = typeof input === "object" ? input.sessionKey : input;
269+
const api = typeof input === "object" ? input.api : undefined;
270+
const sourceAgentId = typeof input === "object" ? input.sourceAgentId : undefined;
271+
const normalizedKey = normalizeSessionStoreKey(sessionKey);
272+
if (!normalizedKey) {
273+
return unknownSessionIdentity();
274+
}
275+
276+
const lookupContext = await resolveSessionStoreLookupContext({
277+
api,
278+
normalizedKey,
279+
sourceAgentId,
280+
});
281+
if (!lookupContext) {
162282
return unknownSessionIdentity();
163283
}
284+
285+
const cacheKey = buildSessionMetadataCacheKey({
286+
normalizedKey,
287+
storePath: lookupContext.storePath,
288+
});
289+
const cached = sessionMetadataCache.get(cacheKey);
290+
if (cached?.data && cached.expiresAt && Date.now() < cached.expiresAt) {
291+
return cached.data;
292+
}
293+
if (cached?.promise) {
294+
return cached.promise;
295+
}
296+
297+
const promise = readSessionIdentity({
298+
normalizedKey,
299+
internals: lookupContext.internals,
300+
storePath: lookupContext.storePath,
301+
}).then((data) => {
302+
setSessionMetadataCache(cacheKey, data);
303+
return data;
304+
});
305+
setSessionMetadataCachePromise(cacheKey, promise);
306+
return promise;
164307
}

0 commit comments

Comments
 (0)