From d1f4cb4711966c0de485a1685dcbd49194a128e0 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 5 May 2026 22:24:24 -0700 Subject: [PATCH 1/4] Cache pre-tool metadata resolution --- README.md | 2 +- src/agent-control-plugin.ts | 27 +++++ src/session-context.ts | 6 +- src/session-store.ts | 150 ++++++++++++++++++++++++---- src/tool-catalog.ts | 92 ++++++++++++++++- src/types.ts | 8 +- test/session-store.test.ts | 137 ++++++++++++++++++++++++- test/tool-catalog.test.ts | 66 ++++++++++++ types/openclaw-plugin-sdk-core.d.ts | 14 +++ 9 files changed, 472 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ebaf800..7690217 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcon 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. -The plugin handles multiple agents, tracks tool catalog changes between calls, and re-syncs automatically when the catalog drifts. +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. ## Quick start diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index 14e1114..df19eca 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -10,6 +10,7 @@ import { } from "./observability.ts"; import { resolveStepsForContext } from "./tool-catalog.ts"; import { buildEvaluationContext } from "./session-context.ts"; +import { warmSessionIdentityResolver } from "./session-store.ts"; import { asPositiveInt, asString, @@ -106,6 +107,7 @@ export default function register(api: OpenClawPluginApi) { const states = new Map(); let gatewayWarmupPromise: Promise | null = null; let gatewayWarmupStatus: "idle" | "running" | "done" | "failed" = "idle"; + let sessionIdentityWarmupPromise: Promise | null = null; const getOrCreateState = (sourceAgentId: string): AgentState => { const existing = states.get(sourceAgentId); @@ -160,6 +162,30 @@ export default function register(api: OpenClawPluginApi) { return gatewayWarmupPromise; }; + const ensureSessionIdentityWarmup = (): Promise => { + if (sessionIdentityWarmupPromise) { + return sessionIdentityWarmupPromise; + } + + const warmupStartedAt = process.hrtime.bigint(); + sessionIdentityWarmupPromise = warmSessionIdentityResolver({ + api, + sourceAgentId: BOOT_WARMUP_AGENT_ID, + }) + .then(() => { + logger.debug( + `agent-control: session_identity_warmup done duration_sec=${secondsSince(warmupStartedAt)}`, + ); + }) + .catch((err) => { + logger.debug( + `agent-control: session_identity_warmup failed duration_sec=${secondsSince(warmupStartedAt)} error=${formatAgentControlError(err)}`, + ); + }); + + return sessionIdentityWarmupPromise; + }; + const syncAgent = async (state: AgentState): Promise => { if (state.syncPromise) { await state.syncPromise; @@ -205,6 +231,7 @@ export default function register(api: OpenClawPluginApi) { }; api.on("gateway_start", async () => { + void ensureSessionIdentityWarmup(); await ensureGatewayWarmup(); }); diff --git a/src/session-context.ts b/src/session-context.ts index ceb7333..fe298b0 100644 --- a/src/session-context.ts +++ b/src/session-context.ts @@ -83,7 +83,11 @@ export async function buildEvaluationContext(params: { configuredAgentVersion?: string; }): Promise> { const channelFromSessionKey = deriveChannelContext(params.ctx.sessionKey); - const sessionIdentity = await resolveSessionIdentity(params.ctx.sessionKey); + const sessionIdentity = await resolveSessionIdentity({ + api: params.api, + sessionKey: params.ctx.sessionKey, + sourceAgentId: params.sourceAgentId, + }); const mergedChannelType = sessionIdentity.type !== "unknown" ? sessionIdentity.type : channelFromSessionKey.type; const mergedChannelProvider = sessionIdentity.provider ?? channelFromSessionKey.provider; diff --git a/src/session-store.ts b/src/session-store.ts index 0ba3023..08c966f 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -1,14 +1,49 @@ import type { SessionIdentitySnapshot, SessionMetadataCacheEntry, SessionStoreInternals } from "./types.ts"; import { asString, isRecord } from "./shared.ts"; import { getResolvedOpenClawRootDir, importOpenClawInternalModule } from "./openclaw-runtime.ts"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -const SESSION_META_CACHE_TTL_MS = 2_000; +const SESSION_META_KNOWN_CACHE_TTL_MS = 60_000; +const SESSION_META_UNKNOWN_CACHE_TTL_MS = 2_000; const SESSION_META_CACHE_MAX = 512; let sessionStoreInternalsPromise: Promise | null = null; const sessionMetadataCache = new Map(); -async function loadSessionStoreInternals(): Promise { +function resolveRuntimeSessionStoreInternals( + api: OpenClawPluginApi | undefined, +): SessionStoreInternals | null { + const runtime = isRecord(api?.runtime) ? api.runtime : undefined; + const runtimeConfig = isRecord(runtime?.config) ? runtime.config : undefined; + const runtimeAgent = isRecord(runtime?.agent) ? runtime.agent : undefined; + const runtimeAgentSession = isRecord(runtimeAgent?.session) ? runtimeAgent.session : undefined; + + const resolveStorePath = runtimeAgentSession?.resolveStorePath; + const loadSessionStore = runtimeAgentSession?.loadSessionStore; + if (typeof resolveStorePath !== "function" || typeof loadSessionStore !== "function") { + return null; + } + + const loadConfig = runtimeConfig?.loadConfig; + const fallbackConfig = isRecord(api?.config) ? api.config : {}; + return { + loadConfig: + typeof loadConfig === "function" + ? (loadConfig as SessionStoreInternals["loadConfig"]) + : () => fallbackConfig, + resolveStorePath: resolveStorePath as SessionStoreInternals["resolveStorePath"], + loadSessionStore: loadSessionStore as SessionStoreInternals["loadSessionStore"], + }; +} + +async function loadSessionStoreInternals( + api: OpenClawPluginApi | undefined, +): Promise { + const runtimeInternals = resolveRuntimeSessionStoreInternals(api); + if (runtimeInternals) { + return runtimeInternals; + } + if (sessionStoreInternalsPromise) { return sessionStoreInternalsPromise; } @@ -50,6 +85,19 @@ async function loadSessionStoreInternals(): Promise { return sessionStoreInternalsPromise; } +export async function warmSessionIdentityResolver(params: { + api: OpenClawPluginApi; + sourceAgentId?: string; +}): Promise { + const internals = await loadSessionStoreInternals(params.api); + const cfg = internals.loadConfig(); + const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined; + const storePath = internals.resolveStorePath(asString(sessionCfg?.store), { + agentId: asString(params.sourceAgentId), + }); + internals.loadSessionStore(storePath); +} + function unknownSessionIdentity(): SessionIdentitySnapshot { return { provider: null, @@ -80,6 +128,17 @@ function resolveBaseSessionKey(sessionKey: string): string { return base || sessionKey; } +function resolveSessionAgentId( + normalizedSessionKey: string, + sourceAgentId: string | undefined, +): string | undefined { + const parts = normalizedSessionKey.split(":").filter(Boolean); + if (parts.length >= 2 && parts[0] === "agent" && parts[1]) { + return parts[1]; + } + return asString(sourceAgentId); +} + function readSessionIdentityFromEntry(entry: Record): SessionIdentitySnapshot { const origin = isRecord(entry.origin) ? entry.origin : undefined; const deliveryContext = isRecord(entry.deliveryContext) ? entry.deliveryContext : undefined; @@ -120,7 +179,16 @@ function readSessionIdentityFromEntry(entry: Record): SessionId } function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): void { - sessionMetadataCache.set(key, { at: Date.now(), data }); + const now = Date.now(); + sessionMetadataCache.set(key, { + at: now, + data, + expiresAt: + now + + (data.source === "sessionStore" + ? SESSION_META_KNOWN_CACHE_TTL_MS + : SESSION_META_UNKNOWN_CACHE_TTL_MS), + }); if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) { const oldest = sessionMetadataCache.keys().next().value; if (typeof oldest === "string") { @@ -129,36 +197,76 @@ function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): vo } } -export async function resolveSessionIdentity( - sessionKey: string | undefined, -): Promise { - const normalizedKey = normalizeSessionStoreKey(sessionKey); - if (!normalizedKey) { - return unknownSessionIdentity(); - } - - const cached = sessionMetadataCache.get(normalizedKey); - if (cached && Date.now() - cached.at < SESSION_META_CACHE_TTL_MS) { - return cached.data; +function setSessionMetadataCachePromise( + key: string, + promise: Promise, +): void { + sessionMetadataCache.set(key, { at: Date.now(), promise }); + if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) { + const oldest = sessionMetadataCache.keys().next().value; + if (typeof oldest === "string" && oldest !== key) { + sessionMetadataCache.delete(oldest); + } } +} +async function readSessionIdentity(params: { + api?: OpenClawPluginApi; + normalizedKey: string; + sourceAgentId?: string; +}): Promise { try { - const internals = await loadSessionStoreInternals(); + const internals = await loadSessionStoreInternals(params.api); const cfg = internals.loadConfig(); const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined; - const storePath = internals.resolveStorePath(asString(sessionCfg?.store)); + const storeAgentId = resolveSessionAgentId(params.normalizedKey, params.sourceAgentId); + const storePath = internals.resolveStorePath(asString(sessionCfg?.store), { + agentId: storeAgentId, + }); const store = internals.loadSessionStore(storePath); - const directEntry = store[normalizedKey]; - const baseEntry = store[resolveBaseSessionKey(normalizedKey)]; + const directEntry = store[params.normalizedKey]; + const baseEntry = store[resolveBaseSessionKey(params.normalizedKey)]; const entry: Record | undefined = isRecord(directEntry) ? directEntry : isRecord(baseEntry) ? baseEntry : undefined; - const data = entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity(); - setSessionMetadataCache(normalizedKey, data); - return data; + return entry ? readSessionIdentityFromEntry(entry) : unknownSessionIdentity(); } catch { return unknownSessionIdentity(); } } + +export async function resolveSessionIdentity( + input: + | string + | undefined + | { + api?: OpenClawPluginApi; + sessionKey?: string; + sourceAgentId?: string; + }, +): Promise { + const sessionKey = typeof input === "object" ? input.sessionKey : input; + const api = typeof input === "object" ? input.api : undefined; + const sourceAgentId = typeof input === "object" ? input.sourceAgentId : undefined; + const normalizedKey = normalizeSessionStoreKey(sessionKey); + if (!normalizedKey) { + return unknownSessionIdentity(); + } + + const cached = sessionMetadataCache.get(normalizedKey); + if (cached?.data && cached.expiresAt && Date.now() < cached.expiresAt) { + return cached.data; + } + if (cached?.promise) { + return cached.promise; + } + + const promise = readSessionIdentity({ api, normalizedKey, sourceAgentId }).then((data) => { + setSessionMetadataCache(normalizedKey, data); + return data; + }); + setSessionMetadataCachePromise(normalizedKey, promise); + return promise; +} diff --git a/src/tool-catalog.ts b/src/tool-catalog.ts index c961774..71cd560 100644 --- a/src/tool-catalog.ts +++ b/src/tool-catalog.ts @@ -23,9 +23,20 @@ import { const TOOL_CATALOG_BUNDLE_DIRNAME = path.join("dist", "agent-control-generated", "tool-catalog"); const TOOL_CATALOG_BUNDLE_FILE = "index.mjs"; const TOOL_CATALOG_WRAPPER_FILE = "entry.ts"; +const TOOL_CATALOG_STEPS_CACHE_TTL_MS = 30_000; +const TOOL_CATALOG_STEPS_CACHE_MAX = 128; let toolCatalogInternalsPromise: Promise | null = null; +type ToolCatalogStepsCacheEntry = { + at: number; + expiresAt?: number; + promise?: Promise; + steps?: AgentControlStep[]; +}; + +const toolCatalogStepsCache = new Map(); + function resolveToolCatalogBundleBuildInfo(openClawRoot: string): ToolCatalogBundleBuildInfo { const piToolsSource = path.join(openClawRoot, "src/agents/pi-tools.ts"); const adapterSource = path.join(openClawRoot, "src/agents/pi-tool-definition-adapter.ts"); @@ -293,8 +304,49 @@ function buildSteps( return [...deduped.values()]; } -export async function resolveStepsForContext( +function hashJsonRecord(value: Record): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function resolveStepsCacheKey(params: ResolveStepsForContextParams, config: Record): string { + return JSON.stringify({ + sourceAgentId: params.sourceAgentId, + sessionKey: params.sessionKey ?? null, + configHash: hashJsonRecord(config), + }); +} + +function setToolCatalogStepsCache(key: string, steps: AgentControlStep[]): void { + const now = Date.now(); + toolCatalogStepsCache.set(key, { + at: now, + expiresAt: now + TOOL_CATALOG_STEPS_CACHE_TTL_MS, + steps, + }); + if (toolCatalogStepsCache.size > TOOL_CATALOG_STEPS_CACHE_MAX) { + const oldest = toolCatalogStepsCache.keys().next().value; + if (typeof oldest === "string") { + toolCatalogStepsCache.delete(oldest); + } + } +} + +function setToolCatalogStepsCachePromise( + key: string, + promise: Promise, +): void { + toolCatalogStepsCache.set(key, { at: Date.now(), promise }); + if (toolCatalogStepsCache.size > TOOL_CATALOG_STEPS_CACHE_MAX) { + const oldest = toolCatalogStepsCache.keys().next().value; + if (typeof oldest === "string" && oldest !== key) { + toolCatalogStepsCache.delete(oldest); + } + } +} + +async function resolveFreshStepsForContext( params: ResolveStepsForContextParams, + config: Record, ): Promise { const resolveStartedAt = process.hrtime.bigint(); const internalsStartedAt = process.hrtime.bigint(); @@ -307,7 +359,7 @@ export async function resolveStepsForContext( sessionKey: params.sessionKey, sessionId: params.sessionId, runId: params.runId, - config: sanitizeToolCatalogConfig(toJsonRecord(params.api.config) ?? {}), + config, // Keep the synced step catalog permissive so guardrail policy sees the full // internal tool surface when sender ownership is unknown in this hook context. senderIsOwner: true, @@ -332,3 +384,39 @@ export async function resolveStepsForContext( return steps; } + +export async function resolveStepsForContext( + params: ResolveStepsForContextParams, +): Promise { + const resolveStartedAt = process.hrtime.bigint(); + const config = sanitizeToolCatalogConfig(toJsonRecord(params.api.config) ?? {}); + const cacheKey = resolveStepsCacheKey(params, config); + const cached = toolCatalogStepsCache.get(cacheKey); + if (cached?.steps && cached.expiresAt && Date.now() < cached.expiresAt) { + params.logger.debug( + `agent-control: resolve_steps cache_hit duration_sec=${secondsSince(resolveStartedAt)} agent=${params.sourceAgentId} steps=${cached.steps.length}`, + ); + return cached.steps; + } + if (cached?.promise) { + const steps = await cached.promise; + params.logger.debug( + `agent-control: resolve_steps cache_join duration_sec=${secondsSince(resolveStartedAt)} agent=${params.sourceAgentId} steps=${steps.length}`, + ); + return steps; + } + + const promise = resolveFreshStepsForContext(params, config); + setToolCatalogStepsCachePromise(cacheKey, promise); + try { + const steps = await promise; + setToolCatalogStepsCache(cacheKey, steps); + return steps; + } catch (err) { + const current = toolCatalogStepsCache.get(cacheKey); + if (current?.promise === promise) { + toolCatalogStepsCache.delete(cacheKey); + } + throw err; + } +} diff --git a/src/types.ts b/src/types.ts index cc73256..745c555 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,8 +69,8 @@ export type ToolCatalogInternals = { export type SessionStoreInternals = { loadConfig: () => Record; - resolveStorePath: (storePath?: string) => string; - loadSessionStore: (storePath: string) => Record; + resolveStorePath: (storePath?: string, opts?: { agentId?: string }) => string; + loadSessionStore: (storePath: string, opts?: Record) => Record; }; export type SessionIdentitySnapshot = { @@ -87,7 +87,9 @@ export type SessionIdentitySnapshot = { export type SessionMetadataCacheEntry = { at: number; - data: SessionIdentitySnapshot; + data?: SessionIdentitySnapshot; + expiresAt?: number; + promise?: Promise; }; export type LoggerLike = Pick; diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 6814241..de9eb5c 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; type SessionStoreFixture = { config?: Record; @@ -31,6 +32,7 @@ async function loadSessionStoreModule(fixture: SessionStoreFixture = {}) { const module = await import("../src/session-store.ts"); return { resolveSessionIdentity: module.resolveSessionIdentity, + warmSessionIdentityResolver: module.warmSessionIdentityResolver, mocks: { importOpenClawInternalModule, loadConfig, @@ -43,6 +45,35 @@ async function loadSessionStoreModule(fixture: SessionStoreFixture = {}) { }; } +function createApi(params: { + config?: Record; + loadConfig?: () => Record; + resolveStorePath?: (storePath?: string, opts?: { agentId?: string }) => string; + loadSessionStore?: (storePath: string) => Record; +}): OpenClawPluginApi { + return { + id: "agent-control-openclaw-plugin", + version: "test-version", + config: params.config ?? {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + on: vi.fn(), + runtime: { + config: { + loadConfig: params.loadConfig, + }, + agent: { + session: { + resolveStorePath: params.resolveStorePath, + loadSessionStore: params.loadSessionStore, + }, + }, + }, + }; +} + afterEach(() => { vi.useRealTimers(); vi.doUnmock("../src/openclaw-runtime.ts"); @@ -168,7 +199,7 @@ describe("resolveSessionIdentity", () => { }); it("refreshes the identity after the TTL expires", async () => { - // Given cached session metadata and a store update after the TTL window + // Given cached session-store metadata and a store update after the known-metadata TTL window vi.useFakeTimers(); const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ initialStore: { @@ -196,7 +227,7 @@ describe("resolveSessionIdentity", () => { }, }, }); - vi.advanceTimersByTime(2_001); + vi.advanceTimersByTime(60_001); // Then the refreshed identity is returned and the store is reloaded await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ @@ -205,6 +236,108 @@ describe("resolveSessionIdentity", () => { expect(mocks.loadSessionStore).toHaveBeenCalledTimes(2); }); + it("uses injected runtime helpers before falling back to internal imports", async () => { + // Given OpenClaw provides session-store helpers through the plugin runtime + const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ + throws: true, + }); + const loadConfig = vi.fn(() => ({ + session: { + store: "/tmp/{agentId}/sessions.json", + }, + })); + const resolveStorePath = vi.fn( + (storePath?: string, opts?: { agentId?: string }) => + (storePath ?? "").replace("{agentId}", opts?.agentId ?? "main"), + ); + const loadSessionStore = vi.fn(() => ({ + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + }, + }, + })); + const api = createApi({ loadConfig, resolveStorePath, loadSessionStore }); + + // When identity is resolved with a runtime-aware API object + const identity = await resolveSessionIdentity({ + api, + sessionKey: "agent:worker-1:slack:direct:alice", + }); + + // Then runtime helpers are used and the store path is scoped to the session agent + expect(identity).toMatchObject({ + provider: "slack", + type: "direct", + label: "Alice", + source: "sessionStore", + }); + expect(resolveStorePath).toHaveBeenCalledWith("/tmp/{agentId}/sessions.json", { + agentId: "worker-1", + }); + expect(loadSessionStore).toHaveBeenCalledWith("/tmp/worker-1/sessions.json"); + expect(mocks.importOpenClawInternalModule).not.toHaveBeenCalled(); + }); + + it("warms the session store through injected runtime helpers", async () => { + // Given runtime helpers are available for the default source agent + const { warmSessionIdentityResolver, mocks } = await loadSessionStoreModule({ + throws: true, + }); + const loadConfig = vi.fn(() => ({ + session: { + store: "/tmp/{agentId}/sessions.json", + }, + })); + const resolveStorePath = vi.fn( + (storePath?: string, opts?: { agentId?: string }) => + (storePath ?? "").replace("{agentId}", opts?.agentId ?? "main"), + ); + const loadSessionStore = vi.fn(() => ({})); + const api = createApi({ loadConfig, resolveStorePath, loadSessionStore }); + + // When the resolver is warmed for the default source agent + await warmSessionIdentityResolver({ + api, + sourceAgentId: "main", + }); + + // Then the backing store is loaded once through the runtime path + expect(resolveStorePath).toHaveBeenCalledWith("/tmp/{agentId}/sessions.json", { + agentId: "main", + }); + expect(loadSessionStore).toHaveBeenCalledWith("/tmp/main/sessions.json"); + expect(mocks.importOpenClawInternalModule).not.toHaveBeenCalled(); + }); + + it("deduplicates concurrent lookups for the same session key", async () => { + // Given session-store internals that resolve asynchronously + const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ + initialStore: { + "agent:worker-1:slack:direct:alice": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + }, + }, + }, + }); + + // When two callers resolve the same session before the first lookup finishes + const [first, second] = await Promise.all([ + resolveSessionIdentity("agent:worker-1:slack:direct:alice"), + resolveSessionIdentity("agent:worker-1:slack:direct:alice"), + ]); + + // Then the backing store is loaded once and both callers receive the identity + expect(first.label).toBe("Alice"); + expect(second.label).toBe("Alice"); + expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1); + }); + it("returns an unknown identity when session-store internals cannot be loaded", async () => { // Given a runtime fixture where OpenClaw session-store internals fail to load const { resolveSessionIdentity } = await loadSessionStoreModule({ diff --git a/test/tool-catalog.test.ts b/test/tool-catalog.test.ts index 791aa6b..622452c 100644 --- a/test/tool-catalog.test.ts +++ b/test/tool-catalog.test.ts @@ -78,6 +78,7 @@ function createLogger() { } afterEach(() => { + vi.useRealTimers(); vi.doUnmock("../src/openclaw-runtime.ts"); }); @@ -220,4 +221,69 @@ describe("resolveStepsForContext", () => { ["src/agents/pi-tool-definition-adapter.ts"], ); }); + + it("caches resolved steps briefly for the same agent, session, and config", async () => { + // Given an expensive OpenClaw tool catalog resolver for one session + vi.useFakeTimers(); + const createOpenClawCodingTools = vi + .fn() + .mockReturnValueOnce(["first-tool-marker"]) + .mockReturnValueOnce(["second-tool-marker"]); + const toToolDefinitions = vi + .fn() + .mockReturnValueOnce([ + { + name: "shell", + label: "Shell", + description: "Run a shell command", + parameters: { type: "object" }, + }, + ]) + .mockReturnValueOnce([ + { + name: "grep", + label: "Grep", + description: "Search files", + parameters: { type: "object" }, + }, + ]); + + const { resolveStepsForContext } = await loadToolCatalogModule({ + openClawRoot: fs.mkdtempSync(path.join(os.tmpdir(), "tool-catalog-cache-")), + distPiToolsModule: { createOpenClawCodingTools }, + distAdapterModule: { toToolDefinitions }, + }); + const logger = createLogger(); + const request = { + api: createApi({ mode: "test" }), + logger, + sourceAgentId: "worker-1", + sessionKey: "agent:worker-1:slack:direct:alice", + }; + + // When the same context is resolved twice before the cache TTL expires + const first = await resolveStepsForContext(request); + const second = await resolveStepsForContext(request); + + // Then the second call reuses the cached step catalog + expect(second).toEqual(first); + expect(createOpenClawCodingTools).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("resolve_steps cache_hit")); + + // When the cache TTL expires + vi.advanceTimersByTime(30_001); + const refreshed = await resolveStepsForContext(request); + + // Then the catalog is refreshed from OpenClaw internals + expect(refreshed).toEqual([ + { + type: "tool", + name: "grep", + description: "Search files", + inputSchema: { type: "object" }, + metadata: { label: "Grep" }, + }, + ]); + expect(createOpenClawCodingTools).toHaveBeenCalledTimes(2); + }); }); diff --git a/types/openclaw-plugin-sdk-core.d.ts b/types/openclaw-plugin-sdk-core.d.ts index 7fd01fc..e2f5864 100644 --- a/types/openclaw-plugin-sdk-core.d.ts +++ b/types/openclaw-plugin-sdk-core.d.ts @@ -19,6 +19,20 @@ declare module "openclaw/plugin-sdk/core" { version?: string; config: Record; pluginConfig?: unknown; + runtime?: { + config?: { + loadConfig?: () => Record; + }; + agent?: { + session?: { + resolveStorePath?: (storePath?: string, opts?: { agentId?: string }) => string; + loadSessionStore?: ( + storePath: string, + opts?: Record, + ) => Record; + }; + }; + }; logger: { info(message: string): void; warn(message: string): void; From 52011796b4dc3e2460c201817f13943df799a34d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 5 May 2026 22:47:26 -0700 Subject: [PATCH 2/4] Tighten pre-tool cache keys --- src/session-store.ts | 76 +++++++++++++----- src/tool-catalog.ts | 2 + test/agent-control-plugin.test.ts | 49 ++++++++++++ test/session-store.test.ts | 67 +++++++++++++++- test/tool-catalog.test.ts | 129 ++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 23 deletions(-) diff --git a/src/session-store.ts b/src/session-store.ts index 08c966f..138c157 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -3,8 +3,7 @@ import { asString, isRecord } from "./shared.ts"; import { getResolvedOpenClawRootDir, importOpenClawInternalModule } from "./openclaw-runtime.ts"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -const SESSION_META_KNOWN_CACHE_TTL_MS = 60_000; -const SESSION_META_UNKNOWN_CACHE_TTL_MS = 2_000; +const SESSION_META_CACHE_TTL_MS = 2_000; const SESSION_META_CACHE_MAX = 512; let sessionStoreInternalsPromise: Promise | null = null; @@ -183,11 +182,7 @@ function setSessionMetadataCache(key: string, data: SessionIdentitySnapshot): vo sessionMetadataCache.set(key, { at: now, data, - expiresAt: - now + - (data.source === "sessionStore" - ? SESSION_META_KNOWN_CACHE_TTL_MS - : SESSION_META_UNKNOWN_CACHE_TTL_MS), + expiresAt: now + SESSION_META_CACHE_TTL_MS, }); if (sessionMetadataCache.size > SESSION_META_CACHE_MAX) { const oldest = sessionMetadataCache.keys().next().value; @@ -211,19 +206,12 @@ function setSessionMetadataCachePromise( } async function readSessionIdentity(params: { - api?: OpenClawPluginApi; normalizedKey: string; - sourceAgentId?: string; + internals: SessionStoreInternals; + storePath: string; }): Promise { try { - const internals = await loadSessionStoreInternals(params.api); - const cfg = internals.loadConfig(); - const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined; - const storeAgentId = resolveSessionAgentId(params.normalizedKey, params.sourceAgentId); - const storePath = internals.resolveStorePath(asString(sessionCfg?.store), { - agentId: storeAgentId, - }); - const store = internals.loadSessionStore(storePath); + const store = params.internals.loadSessionStore(params.storePath); const directEntry = store[params.normalizedKey]; const baseEntry = store[resolveBaseSessionKey(params.normalizedKey)]; const entry: Record | undefined = isRecord(directEntry) @@ -237,6 +225,35 @@ async function readSessionIdentity(params: { } } +function buildSessionMetadataCacheKey(params: { + normalizedKey: string; + storePath: string; +}): string { + return JSON.stringify({ + normalizedKey: params.normalizedKey, + storePath: params.storePath, + }); +} + +async function resolveSessionStoreLookupContext(params: { + api?: OpenClawPluginApi; + normalizedKey: string; + sourceAgentId?: string; +}): Promise<{ internals: SessionStoreInternals; storePath: string } | null> { + try { + const internals = await loadSessionStoreInternals(params.api); + const cfg = internals.loadConfig(); + const sessionCfg = isRecord(cfg.session) ? cfg.session : undefined; + const storeAgentId = resolveSessionAgentId(params.normalizedKey, params.sourceAgentId); + const storePath = internals.resolveStorePath(asString(sessionCfg?.store), { + agentId: storeAgentId, + }); + return { internals, storePath }; + } catch { + return null; + } +} + export async function resolveSessionIdentity( input: | string @@ -255,7 +272,20 @@ export async function resolveSessionIdentity( return unknownSessionIdentity(); } - const cached = sessionMetadataCache.get(normalizedKey); + const lookupContext = await resolveSessionStoreLookupContext({ + api, + normalizedKey, + sourceAgentId, + }); + if (!lookupContext) { + return unknownSessionIdentity(); + } + + const cacheKey = buildSessionMetadataCacheKey({ + normalizedKey, + storePath: lookupContext.storePath, + }); + const cached = sessionMetadataCache.get(cacheKey); if (cached?.data && cached.expiresAt && Date.now() < cached.expiresAt) { return cached.data; } @@ -263,10 +293,14 @@ export async function resolveSessionIdentity( return cached.promise; } - const promise = readSessionIdentity({ api, normalizedKey, sourceAgentId }).then((data) => { - setSessionMetadataCache(normalizedKey, data); + const promise = readSessionIdentity({ + normalizedKey, + internals: lookupContext.internals, + storePath: lookupContext.storePath, + }).then((data) => { + setSessionMetadataCache(cacheKey, data); return data; }); - setSessionMetadataCachePromise(normalizedKey, promise); + setSessionMetadataCachePromise(cacheKey, promise); return promise; } diff --git a/src/tool-catalog.ts b/src/tool-catalog.ts index 71cd560..41e3107 100644 --- a/src/tool-catalog.ts +++ b/src/tool-catalog.ts @@ -312,6 +312,8 @@ function resolveStepsCacheKey(params: ResolveStepsForContextParams, config: Reco return JSON.stringify({ sourceAgentId: params.sourceAgentId, sessionKey: params.sessionKey ?? null, + sessionId: params.sessionId ?? null, + runId: params.runId ?? null, configHash: hashJsonRecord(config), }); } diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index 04ad940..b90d9c4 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -10,6 +10,7 @@ const { clientMocks, resolveStepsForContextMock, buildEvaluationContextMock, + warmSessionIdentityResolverMock, } = vi.hoisted(() => ({ clientMocks: { init: vi.fn(), @@ -19,6 +20,7 @@ const { }, resolveStepsForContextMock: vi.fn(), buildEvaluationContextMock: vi.fn(), + warmSessionIdentityResolverMock: vi.fn(), })); vi.mock("agent-control", () => ({ @@ -44,6 +46,10 @@ vi.mock("../src/session-context.ts", () => ({ buildEvaluationContext: buildEvaluationContextMock, })); +vi.mock("../src/session-store.ts", () => ({ + warmSessionIdentityResolver: warmSessionIdentityResolverMock, +})); + import register from "../src/agent-control-plugin.ts"; type MockApi = { @@ -144,6 +150,7 @@ beforeEach(() => { }); resolveStepsForContextMock.mockReset().mockResolvedValue([{ type: "tool", name: "shell" }]); buildEvaluationContextMock.mockReset().mockResolvedValue({ channelType: "unknown" }); + warmSessionIdentityResolverMock.mockReset().mockResolvedValue(undefined); }); describe("agent-control plugin logging and blocking", () => { @@ -312,6 +319,48 @@ describe("agent-control plugin logging and blocking", () => { ); }); + it("warms session identity resolution once across repeated gateway_start events", async () => { + // Given a plugin instance that can warm session metadata on gateway startup + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + // When gateway_start is fired twice + register(api.api); + await runGatewayStart(api); + await runGatewayStart(api); + + // Then session identity warmup is started once for the default source agent + expect(warmSessionIdentityResolverMock).toHaveBeenCalledTimes(1); + expect(warmSessionIdentityResolverMock).toHaveBeenCalledWith({ + api: api.api, + sourceAgentId: "main", + }); + }); + + it("does not fail gateway_start when session identity warmup fails", async () => { + // Given debug logging and a session identity warmup failure + const api = createMockApi({ + serverUrl: "http://localhost:8000", + logLevel: "debug", + }); + warmSessionIdentityResolverMock.mockRejectedValueOnce(new Error("session warmup exploded")); + + // When gateway_start runs + register(api.api); + await expect(runGatewayStart(api)).resolves.toBeUndefined(); + await Promise.resolve(); + + // Then the failure is logged as debug and gateway tool warmup still completes + const messages = api.info.mock.calls.map(([message]) => String(message)); + expect(messages.some((message) => message.includes("session_identity_warmup failed"))).toBe(true); + expect(resolveStepsForContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAgentId: "main", + }), + ); + }); + it("warns when gateway warmup fails and still evaluates later tool calls", async () => { // Given gateway warmup fails once before regular tool evaluation starts const api = createMockApi({ diff --git a/test/session-store.test.ts b/test/session-store.test.ts index de9eb5c..10edf95 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -199,7 +199,7 @@ describe("resolveSessionIdentity", () => { }); it("refreshes the identity after the TTL expires", async () => { - // Given cached session-store metadata and a store update after the known-metadata TTL window + // Given cached session-store metadata and a store update after the TTL window vi.useFakeTimers(); const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ initialStore: { @@ -227,7 +227,7 @@ describe("resolveSessionIdentity", () => { }, }, }); - vi.advanceTimersByTime(60_001); + vi.advanceTimersByTime(2_001); // Then the refreshed identity is returned and the store is reloaded await expect(resolveSessionIdentity("agent:worker-1:slack:direct:alice")).resolves.toMatchObject({ @@ -236,6 +236,69 @@ describe("resolveSessionIdentity", () => { expect(mocks.loadSessionStore).toHaveBeenCalledTimes(2); }); + it("keys cached identity by resolved store path", async () => { + // Given two source agents can resolve the same legacy key in separate stores + const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ + throws: true, + }); + const loadConfig = vi.fn(() => ({ + session: { + store: "/tmp/{agentId}/sessions.json", + }, + })); + const resolveStorePath = vi.fn( + (storePath?: string, opts?: { agentId?: string }) => + (storePath ?? "").replace("{agentId}", opts?.agentId ?? "main"), + ); + const loadSessionStore = vi.fn((storePath: string) => ({ + "/tmp/worker-1/sessions.json": { + "legacy-session": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + }, + }, + }, + "/tmp/worker-2/sessions.json": { + "legacy-session": { + origin: { + provider: "discord", + chatType: "channel", + label: "House Buying", + }, + }, + }, + })[storePath] ?? {}); + const api = createApi({ loadConfig, resolveStorePath, loadSessionStore }); + + // When the same session key is resolved for two different source agents + const first = await resolveSessionIdentity({ + api, + sourceAgentId: "worker-1", + sessionKey: "legacy-session", + }); + const second = await resolveSessionIdentity({ + api, + sourceAgentId: "worker-2", + sessionKey: "legacy-session", + }); + + // Then each lookup returns metadata from its own resolved store + expect(first).toMatchObject({ + provider: "slack", + label: "Alice", + type: "direct", + }); + expect(second).toMatchObject({ + provider: "discord", + label: "House Buying", + type: "channel", + }); + expect(loadSessionStore).toHaveBeenCalledTimes(2); + expect(mocks.importOpenClawInternalModule).not.toHaveBeenCalled(); + }); + it("uses injected runtime helpers before falling back to internal imports", async () => { // Given OpenClaw provides session-store helpers through the plugin runtime const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ diff --git a/test/tool-catalog.test.ts b/test/tool-catalog.test.ts index 622452c..13f9799 100644 --- a/test/tool-catalog.test.ts +++ b/test/tool-catalog.test.ts @@ -77,6 +77,16 @@ function createLogger() { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + afterEach(() => { vi.useRealTimers(); vi.doUnmock("../src/openclaw-runtime.ts"); @@ -286,4 +296,123 @@ describe("resolveStepsForContext", () => { ]); expect(createOpenClawCodingTools).toHaveBeenCalledTimes(2); }); + + it("does not reuse cached steps across different run context identifiers", async () => { + // Given two calls with the same session key but different run context identifiers + const createOpenClawCodingTools = vi + .fn() + .mockReturnValueOnce(["first-tool-marker"]) + .mockReturnValueOnce(["second-tool-marker"]); + const toToolDefinitions = vi + .fn() + .mockReturnValueOnce([ + { + name: "shell", + label: "Shell", + description: "Run a shell command", + parameters: { type: "object" }, + }, + ]) + .mockReturnValueOnce([ + { + name: "grep", + label: "Grep", + description: "Search files", + parameters: { type: "object" }, + }, + ]); + + const { resolveStepsForContext } = await loadToolCatalogModule({ + openClawRoot: fs.mkdtempSync(path.join(os.tmpdir(), "tool-catalog-run-context-")), + distPiToolsModule: { createOpenClawCodingTools }, + distAdapterModule: { toToolDefinitions }, + }); + const baseRequest = { + api: createApi({ mode: "test" }), + logger: createLogger(), + sourceAgentId: "worker-1", + sessionKey: "agent:worker-1:slack:direct:alice", + }; + + // When steps are resolved for different session and run identifiers + const first = await resolveStepsForContext({ + ...baseRequest, + sessionId: "session-1", + runId: "run-1", + }); + const second = await resolveStepsForContext({ + ...baseRequest, + sessionId: "session-2", + runId: "run-2", + }); + + // Then each context resolves its own catalog rather than sharing a stale cache entry + expect(first).toEqual([ + { + type: "tool", + name: "shell", + description: "Run a shell command", + inputSchema: { type: "object" }, + metadata: { label: "Shell" }, + }, + ]); + expect(second).toEqual([ + { + type: "tool", + name: "grep", + description: "Search files", + inputSchema: { type: "object" }, + metadata: { label: "Grep" }, + }, + ]); + expect(createOpenClawCodingTools).toHaveBeenCalledTimes(2); + }); + + it("deduplicates concurrent step resolution for the same cache key", async () => { + // Given OpenClaw internals are still loading when two identical step resolutions start + const internalsDeferred = createDeferred | null>(); + const createOpenClawCodingTools = vi.fn(() => ["tool-marker"]); + const toToolDefinitions = vi.fn(() => [ + { + name: "shell", + label: "Shell", + description: "Run a shell command", + parameters: { type: "object" }, + }, + ]); + vi.resetModules(); + + const tryImportOpenClawInternalModule = vi.fn(() => internalsDeferred.promise); + vi.doMock("../src/openclaw-runtime.ts", () => ({ + getResolvedOpenClawRootDir: () => "/openclaw", + tryImportOpenClawInternalModule, + importOpenClawInternalModule: vi.fn(), + normalizeRelativeImportPath: vi.fn(), + PLUGIN_ROOT_DIR: "/plugin", + readPackageVersion: vi.fn(() => "1.0.0"), + safeStatMtimeMs: vi.fn(() => null), + })); + + const { resolveStepsForContext } = await import("../src/tool-catalog.ts"); + const logger = createLogger(); + const request = { + api: createApi({ mode: "test" }), + logger, + sourceAgentId: "worker-1", + sessionKey: "agent:worker-1:slack:direct:alice", + }; + + // When both callers request the same catalog before internals finish loading + const firstPromise = resolveStepsForContext(request); + const secondPromise = resolveStepsForContext(request); + await Promise.resolve(); + internalsDeferred.resolve({ createOpenClawCodingTools, toToolDefinitions }); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + + // Then both callers share one resolver invocation and receive the same steps + expect(first).toEqual(second); + expect(createOpenClawCodingTools).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("resolve_steps cache_join")); + }); }); From 81abbc7573574298fe634521f31be64108c62841 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 5 May 2026 22:54:09 -0700 Subject: [PATCH 3/4] Fix cache edge cases --- src/agent-control-plugin.ts | 3 +++ src/session-store.ts | 3 ++- test/agent-control-plugin.test.ts | 39 +++++++++++++++++++++++++++ test/session-store.test.ts | 45 +++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index df19eca..1f652fb 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -189,6 +189,9 @@ export default function register(api: OpenClawPluginApi) { const syncAgent = async (state: AgentState): Promise => { if (state.syncPromise) { await state.syncPromise; + if (state.lastSyncedStepsHash !== state.stepsHash) { + await syncAgent(state); + } return; } if (state.lastSyncedStepsHash === state.stepsHash) { diff --git a/src/session-store.ts b/src/session-store.ts index 138c157..c2e96ca 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -135,7 +135,8 @@ function resolveSessionAgentId( if (parts.length >= 2 && parts[0] === "agent" && parts[1]) { return parts[1]; } - return asString(sourceAgentId); + const normalizedSourceAgentId = asString(sourceAgentId); + return normalizedSourceAgentId === "default" ? undefined : normalizedSourceAgentId; } function readSessionIdentityFromEntry(entry: Record): SessionIdentitySnapshot { diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index b90d9c4..232345e 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -482,6 +482,45 @@ describe("agent-control plugin logging and blocking", () => { expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); }); + it("waits for catch-up sync before evaluating joined callers when steps change", async () => { + // Given one tool call changes the step catalog while a joined caller waits on an in-flight sync + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + const syncDeferred = createDeferred(); + clientMocks.agentsInit + .mockImplementationOnce(() => syncDeferred.promise) + .mockResolvedValueOnce(undefined); + resolveStepsForContextMock + .mockResolvedValueOnce([{ type: "tool", name: "shell" }]) + .mockResolvedValueOnce([ + { type: "tool", name: "shell" }, + { type: "tool", name: "grep" }, + ]); + + // When the joined caller updates steps before the original sync resolves + register(api.api); + const first = runBeforeToolCall(api); + await Promise.resolve(); + await Promise.resolve(); + const second = runBeforeToolCall(api, { toolName: "grep" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(clientMocks.agentsInit).toHaveBeenCalledTimes(1); + expect(clientMocks.evaluationEvaluate).not.toHaveBeenCalled(); + + syncDeferred.resolve(undefined); + await Promise.all([first, second]); + + // Then both callers wait until the catch-up sync completes before evaluating + expect(clientMocks.agentsInit).toHaveBeenCalledTimes(2); + expect(clientMocks.evaluationEvaluate).toHaveBeenCalledTimes(2); + expect(clientMocks.evaluationEvaluate.mock.invocationCallOrder[0]).toBeGreaterThan( + clientMocks.agentsInit.mock.invocationCallOrder[1] ?? 0, + ); + }); + it("skips resyncing when the step catalog has not changed", async () => { // Given a source agent whose step catalog is unchanged across two tool calls const api = createMockApi({ diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 10edf95..d50e77a 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -299,6 +299,51 @@ describe("resolveSessionIdentity", () => { expect(mocks.importOpenClawInternalModule).not.toHaveBeenCalled(); }); + it("uses the OpenClaw default store for legacy keys when source agent is synthetic default", async () => { + // Given the plugin fallback source agent ID is the synthetic default value + const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ + throws: true, + }); + const loadConfig = vi.fn(() => ({ + session: { + store: "/tmp/{agentId}/sessions.json", + }, + })); + const resolveStorePath = vi.fn( + (storePath?: string, opts?: { agentId?: string }) => + (storePath ?? "").replace("{agentId}", opts?.agentId ?? "main"), + ); + const loadSessionStore = vi.fn(() => ({ + "legacy-session": { + origin: { + provider: "slack", + chatType: "direct", + label: "Alice", + }, + }, + })); + const api = createApi({ loadConfig, resolveStorePath, loadSessionStore }); + + // When identity is resolved for a non-agent-prefixed session key + const identity = await resolveSessionIdentity({ + api, + sourceAgentId: "default", + sessionKey: "legacy-session", + }); + + // Then the OpenClaw runtime resolves its own default agent store + expect(identity).toMatchObject({ + provider: "slack", + type: "direct", + label: "Alice", + }); + expect(resolveStorePath).toHaveBeenCalledWith("/tmp/{agentId}/sessions.json", { + agentId: undefined, + }); + expect(loadSessionStore).toHaveBeenCalledWith("/tmp/main/sessions.json"); + expect(mocks.importOpenClawInternalModule).not.toHaveBeenCalled(); + }); + it("uses injected runtime helpers before falling back to internal imports", async () => { // Given OpenClaw provides session-store helpers through the plugin runtime const { resolveSessionIdentity, mocks } = await loadSessionStoreModule({ From d5643685abea1209511c4f562bbf540cecd95cae Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Wed, 6 May 2026 01:44:06 -0700 Subject: [PATCH 4/4] ci: validate semantic PR title only --- .github/workflows/pr-title.yml | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 597b8ef..6eec001 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -20,20 +20,13 @@ jobs: semantic-pr-title: runs-on: ubuntu-latest steps: - - name: Check out PR head - uses: actions/checkout@v5 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 1 - - - name: Validate semantic PR title and head commit subject + - name: Validate semantic PR title env: PR_TITLE: ${{ github.event.pull_request.title }} run: | python3 - <<'PY' import os import re - import subprocess import sys pattern = re.compile( @@ -43,7 +36,7 @@ jobs: def validate(label: str, value: str) -> None: if pattern.match(value): - print(f"{label} is a valid conventional commit title: {value}") + print(f"{label} is a valid conventional title: {value}") return print(f"Invalid {label}: {value}", file=sys.stderr) @@ -52,10 +45,4 @@ jobs: raise SystemExit(1) validate("PR title", os.environ["PR_TITLE"]) - - head_subject = subprocess.check_output( - ["git", "log", "-1", "--pretty=%s"], - text=True, - ).strip() - validate("head commit subject", head_subject) PY