From 29cfd195d19d8d6b0dab41f80f540bfc4a562872 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:21:51 -0500 Subject: [PATCH] fix(memory): honor turn tool policy during automatic recall (#126482) * fix(memory): bind prompt recall to turn tool policy * test(plugins): update hook contract inventory * fix(memory): bind recall to active run lifecycle * docs(plugins): define prompt authority contract * test(plugins): track prompt authority type guard * fix(plugins): revalidate prompt authority per handler --- config/assertion-safety-baseline.txt | 2 +- docs/plugins/hooks.md | 90 ++++++-- extensions/active-memory/index.test.ts | 194 ++++++++++-------- extensions/active-memory/index.ts | 134 ++++++------ extensions/active-memory/recall-state.ts | 29 ++- extensions/active-memory/recall.ts | 36 +++- .../active-memory/trigger-recall.test.ts | 90 ++++---- extensions/active-memory/trigger-recall.ts | 33 +-- .../src/app-server/run-attempt-prompt.ts | 24 ++- .../codex/src/app-server/run-attempt.test.ts | 11 +- .../copilot/src/attempt-session-setup.ts | 8 + extensions/memory-core/index.ts | 2 + extensions/memory-lancedb/auto-recall.ts | 148 +++++++++++++ extensions/memory-lancedb/index.test.ts | 139 +++++++++++-- extensions/memory-lancedb/index.ts | 131 ++---------- src/agents/admitted-run-context.test.ts | 20 ++ src/agents/admitted-run-context.ts | 21 ++ src/agents/cli-runner/prepare.test.ts | 42 ++++ src/agents/cli-runner/prepare.ts | 84 ++++++-- .../run/attempt-prompt-build.ts | 43 +++- .../prompt-compaction-hook-helpers.test.ts | 49 +++++ .../harness/prompt-compaction-hook-helpers.ts | 38 +++- .../plugin-entry.reply-trigger.test.ts | 25 ++- .../contracts/boundary-invariants.test.ts | 2 +- src/plugins/hook-types.ts | 20 +- src/plugins/hooks.phase-hooks.test.ts | 133 +++++++++++- src/plugins/hooks.test-fixtures.ts | 1 + src/plugins/hooks.test-helpers.ts | 4 + src/plugins/hooks.ts | 90 +++++++- src/plugins/registry-contribution-types.ts | 4 + .../registry-registrars-tools-hooks.ts | 3 + .../type-suppression-inventory.test.ts | 1 + 32 files changed, 1231 insertions(+), 420 deletions(-) create mode 100644 extensions/memory-lancedb/auto-recall.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 6c242f03c566..ca32eada005c 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -11,7 +11,7 @@ extensions/acpx/src/service.ts 2 extensions/acpx/src/state.ts 1 extensions/active-memory/config.ts 4 extensions/active-memory/doctor-contract-api.ts 3 -extensions/active-memory/index.ts 7 +extensions/active-memory/index.ts 6 extensions/active-memory/query.ts 2 extensions/active-memory/session.ts 4 extensions/active-memory/transcript-result.ts 2 diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index f01bbe835cef..1b4985485390 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -57,13 +57,14 @@ observation side effects. `api.on(name, handler, opts?)` accepts: -| Option | Effect | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `matcher` | Non-empty list of canonical OpenClaw tool ids handled by `before_tool_call` or `after_tool_call`, such as `exec`, `apply_patch`, or `spawn_agent`. Omit to match all tools. Empty lists, wildcards, blanks, and provider-specific aliases are invalid. | -| `priority` | Ordering; higher runs first. | -| `registrationId` | Stable identity for one registration inside a plugin. Skill evaluators use it as `evaluatorId`; otherwise the plugin id is used. | -| `timeoutMs` | Per-hook await budget. When it expires, OpenClaw stops awaiting that handler and moves on. It does not cancel the handler or its side effects. Omit to use the runner's default per-hook timeout. | -| `eligibleTriggers` | For `before_agent_reply` only, limits host dispatch to one or more of `cron`, `heartbeat`, or `user`. | +| Option | Effect | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `matcher` | Non-empty list of canonical OpenClaw tool ids handled by `before_tool_call` or `after_tool_call`, such as `exec`, `apply_patch`, or `spawn_agent`. Omit to match all tools. Empty lists, wildcards, blanks, and provider-specific aliases are invalid. | +| `priority` | Ordering; higher runs first. | +| `registrationId` | Stable identity for one registration inside a plugin. Skill evaluators use it as `evaluatorId`; otherwise the plugin id is used. | +| `timeoutMs` | Per-hook await budget. When it expires, OpenClaw stops awaiting that handler and moves on. It does not cancel the handler or its side effects. Omit to use the runner's default per-hook timeout. | +| `eligibleTriggers` | For `before_agent_reply` only, limits host dispatch to one or more of `cron`, `heartbeat`, or `user`. | +| `requiresToolAuthority` | For `before_prompt_build` only, runs the handler after the host finalizes the current turn's tool surface and supplies ephemeral `ctx.toolAuthority`. Use this for context retrieval that must follow tool policy. | Trigger eligibility is enforced by the host before it invokes the handler. A hook registered with `eligibleTriggers: ["heartbeat", "cron"]` is therefore @@ -135,16 +136,16 @@ observation-only. **Agent turn** -| Hook | Purpose | -| ------------------------------- | ---------------------------------------------------------------------------------------- | -| `before_model_resolve` | Override provider or model before session messages load | -| `agent_turn_prepare` | Consume queued plugin turn injections and add same-turn context before prompt hooks | -| `before_prompt_build` | Add prompt context or narrow the current turn's submitted tool surface | -| **`before_agent_run`** | Inspect the final prompt and session messages before model submission; can block the run | -| **`before_agent_reply`** | Short-circuit the model turn with a synthetic reply or silence | -| **`before_agent_finalize`** | Inspect the natural final answer and request one more model pass | -| `agent_end` | Observe final messages, success state, and run duration | -| `heartbeat_prompt_contribution` | Add heartbeat-only context for background monitor and lifecycle plugins | +| Hook | Purpose | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `before_model_resolve` | Override provider or model before session messages load | +| `agent_turn_prepare` | Consume queued plugin turn injections and add same-turn context before prompt hooks | +| `before_prompt_build` | Add prompt context, narrow the current turn's submitted tools, or perform authorized post-policy enrichment | +| **`before_agent_run`** | Inspect the final prompt and session messages before model submission; can block the run | +| **`before_agent_reply`** | Short-circuit the model turn with a synthetic reply or silence | +| **`before_agent_finalize`** | Inspect the natural final answer and request one more model pass | +| `agent_end` | Observe final messages, success state, and run duration | +| `heartbeat_prompt_contribution` | Add heartbeat-only context for background monitor and lifecycle plugins | **Conversation observation** @@ -577,10 +578,65 @@ Use the phase-specific hooks for new plugins: dynamic tools are thread-scoped and Codex `turn/start` has no tool-surface override; use the embedded or Copilot runtime when a plugin requires this policy. +- `before_prompt_build` with `{ requiresToolAuthority: true }`: runs in a + second, post-policy phase. Use it when prompt enrichment reads data through + a tool-backed capability and the same turn must be allowed to call that + tool. See [Authorized prompt enrichment](#authorized-prompt-enrichment). - `heartbeat_prompt_contribution`: runs only for heartbeat turns and returns `prependContext` or `appendContext`. Intended for background monitors that need to summarize current state without changing user-initiated turns. +### Authorized prompt enrichment + +Register `before_prompt_build` with `requiresToolAuthority: true` when a plugin +must verify the finalized per-turn tool policy before retrieving context: + +```typescript +api.on( + "before_prompt_build", + async (event, ctx) => { + const authority = ctx.toolAuthority; + if (!authority?.allows("memory_search")) { + return; + } + + const recalledContext = await recallForPrompt(event.prompt); + authority.assertActive(); + return { prependContext: recalledContext }; + }, + { requiresToolAuthority: true }, +); +``` + +The host excludes this handler from the ordinary prompt-build phase. After all +ordinary hooks and tool restrictions settle, a supported runtime invokes it +with `ctx.toolAuthority` bound to that exact active turn and finalized tool +surface. Embedded, CLI, Copilot, and Codex runtimes support this phase. If a +runtime cannot prove the authority, it skips the handler. + +Treat `toolAuthority` as an ephemeral capability: + +- `allows(toolName)` checks a canonical tool id against the finalized surface + and also verifies that the capability is still active. +- `assertActive()` rejects after abort, cancellation, run replacement, + lifecycle rotation, or hook dispatch completion. Call it after awaited work + and before committing plugin-owned side effects. +- `fingerprint` is opaque cache-partitioning input. It is not a bearer token or + authorization proof; never persist, transmit, or compare it as authority. +- Return only `prependContext` or `appendContext` from this phase. It cannot + replace the system prompt or change `toolsAllow` after policy has settled. + +The host revalidates authority after each awaited handler and discards stale +enrichment. A retained `toolAuthority` object fails closed after dispatch. + +This option requires a host that implements the post-policy phase. Published +plugins must set `package.json` `openclaw.compat.pluginApi` to a range beginning +with the first OpenClaw version they build against for this contract. Older +hosts skip incompatible packages during discovery and reject incompatible +installs or updates. Do not publish a package that uses this option while +claiming compatibility with an older plugin API; an older host may otherwise +treat an unknown option as an ordinary pre-policy hook. + `before_agent_run` runs after prompt construction and before any model input, including prompt-local image loading and `llm_input` observation. It receives the current user input as `prompt`, plus loaded session history in `messages` diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 4a731cee06b7..615b3ebeb4b9 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -73,6 +73,22 @@ vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({ getActiveMemorySearchManager: hoisted.getActiveMemorySearchManager, })); +vi.mock("openclaw/plugin-sdk/memory-host-core", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/memory-host-core", + ); + return { + ...actual, + getMemoryCapabilityRegistration: () => ({ + pluginId: "memory-core", + capability: { + deterministicRecallToolName: "memory_search", + supportsPrivateTranscriptRecall: true, + }, + }), + }; +}); + vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/session-store-runtime", @@ -557,6 +573,11 @@ describe("active-memory plugin", () => { agentId: "main", trigger: "user", messageProvider: "webchat", + toolAuthority: { + fingerprint: "allowed-memory-authority", + allows: () => true, + assertActive: () => undefined, + }, ...defaultSession, ...context, }, @@ -720,104 +741,67 @@ describe("active-memory plugin", () => { const [hookName, handler, options] = firstHookRegistration(); expect(hookName).toBe("before_prompt_build"); expect(typeof handler).toBe("function"); - expect(options).toEqual({ timeoutMs: 153_000 }); + expect(options).toEqual({ timeoutMs: 153_000, requiresToolAuthority: true }); expect(hookOptions.before_prompt_build?.timeoutMs).toBe(153_000); - expect(typeof hooks.before_model_resolve).toBe("function"); + expect(hooks.before_model_resolve).toBeUndefined(); expect(typeof hooks.agent_end).toBe("function"); }); - it("prewarms a cold lane-1 lookup before the first QA-channel turn budget starts", async () => { - registerPluginConfig({ mode: "off" }); - let cold = true; - const simulatedBudgetMs = 500; - const coldDelayMs = 650; - const runtimePreparationMs = 500; - const runId = "run-cold-qa-channel"; - const triggerEntry = { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "Prefer aisle seats.", - source: "memory" as const, - originClass: "agent" as const, - triggers: "booking a flight", - }; - const warmLookup = async () => { - if (cold) { - await new Promise((resolve) => { - setTimeout(resolve, coldDelayMs); - }); - cold = false; - } - }; - const search = vi.fn(async () => { - await warmLookup(); - return []; - }); - const listTriggerCandidates = vi.fn(async () => { - await warmLookup(); - return [triggerEntry]; - }); - hoisted.getActiveMemorySearchManager.mockResolvedValue({ - manager: { search, listTriggerCandidates }, - } as never); + it("does not read or inject memory when the turn authority denies recall tools", async () => { + const assertActive = vi.fn(); - const prewarmResult = await requireHook("before_model_resolve")( - { prompt: "Help when booking a flight" }, - { - agentId: "main", - runId, - trigger: "user", - sessionKey: "agent:main:qa-channel:direct:owner", - messageProvider: "qa-channel", - channelId: "owner", - }, - ); - expect(prewarmResult).toBeUndefined(); - expect(coldDelayMs).toBeGreaterThan(simulatedBudgetMs); - await new Promise((resolve) => { - setTimeout(resolve, runtimePreparationMs); - }); - - const startedAt = performance.now(); const result = await runPromptBuild( - { prompt: "Help when booking a flight" }, + { prompt: "what wings should i order?" }, { - sessionKey: "agent:main:qa-channel:direct:owner", - messageProvider: "qa-channel", - channelId: "owner", - runId, + toolAuthority: { + fingerprint: "denied-memory-authority", + allows: () => false, + assertActive, + }, }, ); - const firstTurnMs = performance.now() - startedAt; - expectPrependContextContains(result, "Prefer aisle seats."); - expect(cold).toBe(false); - expect(firstTurnMs).toBeLessThan(simulatedBudgetMs); - expect(search).toHaveBeenCalledTimes(1); - expect(listTriggerCandidates).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + expect(assertActive).toHaveBeenCalled(); + expect(hoisted.getActiveMemorySearchManager).not.toHaveBeenCalled(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); - it("does not prewarm a session disabled with /active-memory off", async () => { - const sessionKey = "agent:main:qa-channel:direct:paused"; - seedSession(sessionKey, "s-paused"); - await runActiveMemoryCommand({ sessionKey, args: "off" }); - - await requireHook("before_model_resolve")( - { prompt: "Help when booking a flight" }, + it("does not inject recall that completes after the turn authority closes", async () => { + let releaseRecall: () => void = () => { + throw new Error("recall gate was not initialized"); + }; + const recallGate = new Promise((resolve) => { + releaseRecall = resolve; + }); + runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { + await recallGate; + await writeUsableMemoryTranscript(params.sessionFile, "stale private memory"); + return { payloads: [{ text: "- stale private memory" }] }; + }); + let authorityActive = true; + const result = runPromptBuild( + { prompt: "what should i remember?" }, { - agentId: "main", - runId: "run-paused-qa-channel", - trigger: "user", - sessionKey, - messageProvider: "qa-channel", - channelId: "paused", + toolAuthority: { + fingerprint: "closing-memory-authority", + allows: () => true, + assertActive: () => { + if (!authorityActive) { + throw new Error("turn authority is no longer active"); + } + }, + }, }, ); + await vi.waitFor(() => { + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); + }); - expect(hoisted.getActiveMemorySearchManager).not.toHaveBeenCalled(); + authorityActive = false; + releaseRecall(); + + await expect(result).resolves.toBeUndefined(); }); it("keeps the outer hook timeout at the live-config ceiling", () => { @@ -931,7 +915,7 @@ describe("active-memory plugin", () => { expect(secondSessionKey).not.toBe(firstSessionKey); }); - it("shares recall results across changed-prompt retries in one run", async () => { + it("does not share recall results across changed prompts in one run", async () => { const context = { runId: "run-changed-prompt-retry", sessionKey: "agent:main:changed-prompt-retry", @@ -945,10 +929,10 @@ describe("active-memory plugin", () => { expectPrependContextContains(first, "lemon pepper wings"); expectPrependContextContains(second, "lemon pepper wings"); - expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); }); - it("joins concurrent recall attempts in one run", async () => { + it("joins concurrent identical recall attempts in one run", async () => { let releaseRecall: () => void = () => { throw new Error("recall gate was not initialized"); }; @@ -969,7 +953,7 @@ describe("active-memory plugin", () => { await vi.waitFor(() => { expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); - const second = runPromptBuild({ prompt: "what wings did I order last time?" }, context); + const second = runPromptBuild({ prompt: "what wings should i order?" }, context); await new Promise((resolve) => { setImmediate(resolve); }); @@ -5602,6 +5586,8 @@ describe("active-memory plugin", () => { agentId: "main", sessionKey, query: `cache pressure prompt ${index}`, + authorityFingerprint: "cache-authority", + recallToolNames: ["memory_search"], }), { status: "ok", @@ -5619,6 +5605,8 @@ describe("active-memory plugin", () => { agentId: "main", sessionKey, query: "cache pressure prompt 0", + authorityFingerprint: "cache-authority", + recallToolNames: ["memory_search"], }), ), ).toBeUndefined(); @@ -5627,18 +5615,52 @@ describe("active-memory plugin", () => { agentId: "main", sessionKey, query: "cache pressure prompt 1", + authorityFingerprint: "cache-authority", + recallToolNames: ["memory_search"], }), ); expect(cached?.status).toBe("ok"); expect(cached?.summary).toBe("memory 1"); }); + it("partitions cached recall by turn authority and memory resource scope", () => { + const base = { + agentId: "main", + sessionKey: "agent:main:cache-scope", + query: "same prompt", + authorityFingerprint: "authority-a", + recallToolNames: ["memory_search"], + memorySlot: "memory-core", + activeProjectKeys: ["project-a"], + modelProviderId: "openai", + modelId: "gpt-5", + }; + + expect(testing.buildCacheKey(base)).not.toBe( + testing.buildCacheKey({ ...base, authorityFingerprint: "authority-b" }), + ); + expect(testing.buildCacheKey(base)).not.toBe( + testing.buildCacheKey({ ...base, activeProjectKeys: ["project-b"] }), + ); + expect(testing.buildCacheKey(base)).not.toBe( + testing.buildCacheKey({ ...base, modelId: "gpt-5-mini" }), + ); + expect(testing.buildCacheKey(base)).not.toBe( + testing.buildCacheKey({ ...base, recallToolNames: ["memory_get"] }), + ); + expect(testing.buildCacheKey(base)).not.toBe( + testing.buildCacheKey({ ...base, resourceScope: "same-agent-private:sessions" }), + ); + }); + it("drops cached active-memory results when the current clock is not a valid date timestamp", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); const cacheKey = testing.buildCacheKey({ agentId: "main", sessionKey: "agent:main:invalid-clock-cache", query: "cache invalid clock prompt", + authorityFingerprint: "cache-authority", + recallToolNames: ["memory_search"], }); testing.setCachedResult( cacheKey, @@ -5662,6 +5684,8 @@ describe("active-memory plugin", () => { agentId: "main", sessionKey: "agent:main:overflow-cache", query: "cache overflow prompt", + authorityFingerprint: "cache-authority", + recallToolNames: ["memory_search"], }); testing.setCachedResult( cacheKey, diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 8a4badd28db1..4307cfe1ed8c 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -3,6 +3,7 @@ */ import { resolveAgentDir, resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { getMemoryCapabilityRegistration } from "openclaw/plugin-sdk/memory-host-core"; import { normalizePluginsConfig, resolveLivePluginConfigObject, @@ -68,20 +69,18 @@ import { hasUsableMemoryResultInSessionRecord, } from "./transcript.js"; import { - forgetTriggerRecallPrewarm, - prewarmTriggerRecall, - resetTriggerRecallPrewarmsForTests, + forgetTriggerRecallRun, + resetTriggerRecallRunsForTests, resolveTriggerRecall, } from "./trigger-recall.js"; import { + ACTIVE_MEMORY_STATUS_PREFIX, HOOK_TIMEOUT_RECOVERY_GRACE_MS, MAX_SETUP_GRACE_TIMEOUT_MS, MAX_TIMEOUT_MS, type ConversationRecallContext, } from "./types.js"; -const MEMORY_CORE_PLUGIN_ID = "memory-core"; - /** Plugin entry registering Active Memory hooks, tools, config schema, and doctor cleanup. */ export default definePluginEntry({ id: "active-memory", @@ -241,6 +240,14 @@ export default definePluginEntry({ api.on( "before_prompt_build", async (event, ctx) => { + const toolAuthority = ctx.toolAuthority; + if (!toolAuthority) { + api.logger.debug?.( + "active-memory: recall skipped because this prompt has no turn tool authority", + ); + return undefined; + } + toolAuthority.assertActive(); refreshLiveConfigFromRuntime(); const liveConfig = readCurrentConfig() ?? api.config; // The hook deadline, watchdog, and embedded-run budget all flow from @@ -274,6 +281,9 @@ export default definePluginEntry({ config, cliDispatchEligibility !== undefined, ); + const authorityAllowedRecallTools = invocationConfig.toolsAllow.filter((toolName) => + toolAuthority.allows(toolName), + ); const liveRecallTimeoutMs = invocationConfig.timeoutMs + invocationConfig.setupGraceTimeoutMs + @@ -305,6 +315,16 @@ export default definePluginEntry({ : undefined); const effectiveAgentId = resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey }); + if (authorityAllowedRecallTools.length === 0) { + await persistPluginStatusLines({ + api, + agentId: effectiveAgentId, + sessionKey: resolvedSessionKey, + statusLine: `${ACTIVE_MEMORY_STATUS_PREFIX} status=policy-disabled`, + }); + toolAuthority.assertActive(); + return undefined; + } if ( shouldSkipActiveMemoryForHarnessSession({ api, @@ -319,6 +339,7 @@ export default definePluginEntry({ sessionKey: resolvedSessionKey, }); deadlineController.signal.throwIfAborted(); + toolAuthority.assertActive(); if (sessionDisabled) { await persistPluginStatusLines({ api, @@ -349,6 +370,23 @@ export default definePluginEntry({ recentTurns, }); const memorySlot = normalizePluginsConfig(liveConfig.plugins).slots.memory; + const memoryCapabilityRegistration = getMemoryCapabilityRegistration(); + const memoryCapability = + memoryCapabilityRegistration && memoryCapabilityRegistration.pluginId === memorySlot + ? memoryCapabilityRegistration.capability + : undefined; + const allowedRecallTools = authorityAllowedRecallTools; + const deterministicRecallToolName = memoryCapability?.deterministicRecallToolName; + if (allowedRecallTools.length === 0) { + await persistPluginStatusLines({ + api, + agentId: effectiveAgentId, + sessionKey: resolvedSessionKey, + statusLine: `${ACTIVE_MEMORY_STATUS_PREFIX} status=policy-disabled`, + }); + toolAuthority.assertActive(); + return undefined; + } const chatIdAllowed = isAllowedChatId(invocationConfig, { sessionKey: destinationContext.sessionKey, messageProvider: destinationContext.messageProvider, @@ -362,10 +400,12 @@ export default definePluginEntry({ if ( activeMemoryConfigured && effectiveAgentId && - memorySlot === MEMORY_CORE_PLUGIN_ID && + deterministicRecallToolName && + allowedRecallTools.includes(deterministicRecallToolName) && isPrivateRecallDestination(destinationContext) && chatIdAllowed ) { + toolAuthority.assertActive(); laneOne = await resolveTriggerRecall({ cfg: liveConfig, agentId: effectiveAgentId, @@ -374,12 +414,14 @@ export default definePluginEntry({ activeProjectKeys: ctx.activeProjectKeys, signal: AbortSignal.timeout(HOOK_TIMEOUT_RECOVERY_GRACE_MS), runId: ctx.runId, + authorityFingerprint: toolAuthority.fingerprint, }).catch((error: unknown) => { api.logger.debug?.( `active-memory: lane-1 trigger recall failed: ${toSingleLineErrorMessage(error)}`, ); return { hasStrongHit: false, injectedCount: 0 }; }); + toolAuthority.assertActive(); if (laneOne.context && laneOne.injectedCount > 0 && invocationConfig.logging) { api.logger.info?.( `active-memory: lane-1 injected ${laneOne.injectedCount} trigger-matched entries`, @@ -399,17 +441,22 @@ export default definePluginEntry({ chatIdAllowed, ); const productRecallEligible = - productRecallRequested && memorySlot === MEMORY_CORE_PLUGIN_ID; + productRecallRequested && memoryCapability?.supportsPrivateTranscriptRecall === true; if (productRecallRequested && !productRecallEligible) { api.logger.warn?.( "active-memory: the current memory provider does not support protected private transcript recall; skipping Remember across conversations", ); } - const productRecallAllowed = - productRecallEligible && invocationConfig.toolsAllow.includes("memory_search"); + const productRecallToolName = + productRecallEligible && + deterministicRecallToolName && + allowedRecallTools.includes(deterministicRecallToolName) + ? deterministicRecallToolName + : undefined; + const productRecallAllowed = Boolean(productRecallToolName); if (productRecallEligible && !productRecallAllowed) { api.logger.warn?.( - "active-memory: memory_search is unavailable; skipping Remember across conversations private transcript recall", + `active-memory: ${deterministicRecallToolName ?? "the provider's deterministic recall tool"} is unavailable; skipping Remember across conversations private transcript recall`, ); } if (!activeMemoryAllowed && !productRecallAllowed) { @@ -438,9 +485,9 @@ export default definePluginEntry({ } : undefined; const recallConfig = - productRecallAllowed && !activeMemoryAllowed - ? { ...invocationConfig, toolsAllow: ["memory_search"] } - : invocationConfig; + productRecallToolName && !activeMemoryAllowed + ? { ...invocationConfig, toolsAllow: [productRecallToolName] } + : { ...invocationConfig, toolsAllow: allowedRecallTools }; const query = buildQuery({ latestUserMessage: event.prompt, recentTurns, @@ -449,6 +496,7 @@ export default definePluginEntry({ // Start recall with its full configured budget. The preceding // session/config checks must not consume abort-settlement time. armHookDeadline(liveRecallTimeoutMs, "recall"); + toolAuthority.assertActive(); const result = await maybeResolveActiveRecall({ api, runtimeConfig: liveConfig, @@ -465,8 +513,12 @@ export default definePluginEntry({ conversationRecall, abortSignal: deadlineController.signal, runId: ctx.runId, + authorityFingerprint: toolAuthority.fingerprint, + memorySlot: memorySlot ?? undefined, + activeProjectKeys: ctx.activeProjectKeys, }); deadlineController.signal.throwIfAborted(); + toolAuthority.assertActive(); if (!result.summary) { return laneOneContext ? { prependContext: laneOneContext } : undefined; } @@ -495,62 +547,12 @@ export default definePluginEntry({ hookDeadline.stop(); } }, - { timeoutMs: beforePromptBuildTimeoutMs }, + { timeoutMs: beforePromptBuildTimeoutMs, requiresToolAuthority: true }, ); - api.on("before_model_resolve", async (event, ctx) => { - refreshLiveConfigFromRuntime(); - const liveConfig = readCurrentConfig() ?? (api.config as OpenClawConfig); - const effectiveAgentId = resolveStatusUpdateAgentId(ctx); - const sessionContext = { - ...ctx, - sessionKey: - ctx.sessionKey?.trim() || - (effectiveAgentId - ? resolveCanonicalSessionKeyFromSessionId({ - api, - agentId: effectiveAgentId, - sessionId: ctx.sessionId, - }) - : undefined), - mainKey: liveConfig.session?.mainKey ?? api.config.session?.mainKey, - }; - if ( - !isEligibleInteractiveSession(sessionContext) || - !isEnabledForAgent(config, effectiveAgentId) || - !effectiveAgentId || - normalizePluginsConfig(liveConfig.plugins).slots.memory !== MEMORY_CORE_PLUGIN_ID || - !isPrivateRecallDestination(sessionContext) || - !isAllowedChatId(config, sessionContext) - ) { - return; - } - if ( - await isSessionActiveMemoryDisabled({ - api, - sessionKey: sessionContext.sessionKey, - }) - ) { - return; - } - - // Start now, but do not await: runtime/model preparation overlaps the - // cold SQLite open and FTS statement/page warmup before lane 1 is budgeted. - void prewarmTriggerRecall({ - cfg: liveConfig, - agentId: effectiveAgentId, - query: buildSearchQuery({ latestUserMessage: event.prompt }), - activeProjectKeys: ctx.activeProjectKeys, - runId: ctx.runId, - }).catch((error: unknown) => { - api.logger.debug?.( - `active-memory: lane-1 prewarm failed: ${toSingleLineErrorMessage(error)}`, - ); - }); - }); api.on("agent_end", (event, ctx) => { const runId = event.runId ?? ctx.runId; forgetActiveRecallRun(runId); - forgetTriggerRecallPrewarm(runId); + forgetTriggerRecallRun(runId); }); }, }); @@ -574,7 +576,7 @@ const testing = { resetActiveRecallStateForTests(); resetActiveMemoryConfigForTests(); resetActiveMemoryTranscriptForTests(); - resetTriggerRecallPrewarmsForTests(); + resetTriggerRecallRunsForTests(); }, setMinimumTimeoutMsForTests, setSetupGraceTimeoutMsForTests, diff --git a/extensions/active-memory/recall-state.ts b/extensions/active-memory/recall-state.ts index 4d7e71eb2d8c..57b3d0be7b7e 100644 --- a/extensions/active-memory/recall-state.ts +++ b/extensions/active-memory/recall-state.ts @@ -125,7 +125,11 @@ async function resolveActiveRecallForRun( function forgetActiveRecallRun(runId: string | undefined): void { if (runId) { - activeRecallRuns.delete(runId); + for (const key of activeRecallRuns.keys()) { + if (key === runId || key.startsWith(`${runId}:`)) { + activeRecallRuns.delete(key); + } + } } } @@ -134,8 +138,29 @@ function buildCacheKey(params: { sessionKey?: string; sessionId?: string; query: string; + authorityFingerprint: string; + memorySlot?: string; + activeProjectKeys?: string[]; + modelProviderId?: string; + modelId?: string; + recallToolNames: string[]; + resourceScope?: string; }): string { - const hash = crypto.createHash("sha1").update(params.query).digest("hex"); + const hash = crypto + .createHash("sha256") + .update( + JSON.stringify({ + query: params.query, + authorityFingerprint: params.authorityFingerprint, + memorySlot: params.memorySlot, + activeProjectKeys: [...(params.activeProjectKeys ?? [])].toSorted(), + modelProviderId: params.modelProviderId, + modelId: params.modelId, + recallToolNames: [...params.recallToolNames].toSorted(), + resourceScope: params.resourceScope, + }), + ) + .digest("hex"); return `${params.agentId}:${params.sessionKey ?? params.sessionId ?? "none"}:${hash}`; } diff --git a/extensions/active-memory/recall.ts b/extensions/active-memory/recall.ts index 5914cb0d2f5a..caf9f98894d1 100644 --- a/extensions/active-memory/recall.ts +++ b/extensions/active-memory/recall.ts @@ -108,6 +108,9 @@ type ActiveRecallParams = { conversationRecall?: ConversationRecallContext; abortSignal?: AbortSignal; runId?: string; + authorityFingerprint: string; + memorySlot?: string; + activeProjectKeys?: string[]; }; async function resolveActiveRecall( @@ -117,6 +120,10 @@ async function resolveActiveRecall( ): Promise { params.abortSignal?.throwIfAborted(); const startedAt = Date.now(); + const resolvedModelRef = getModelRef(params.runtimeConfig, params.agentId, params.config, { + modelProviderId: params.currentModelProviderId, + modelId: params.currentModelId, + }); // Memory Core re-authorizes every conversation-recall request against live // session state. Never replay a cached private summary after eligibility changes. const cacheKey = params.conversationRecall @@ -126,12 +133,14 @@ async function resolveActiveRecall( sessionKey: params.sessionKey, sessionId: params.sessionId, query: params.query, + authorityFingerprint: params.authorityFingerprint, + memorySlot: params.memorySlot, + activeProjectKeys: params.activeProjectKeys, + modelProviderId: resolvedModelRef?.provider, + modelId: resolvedModelRef?.model, + recallToolNames: params.config.toolsAllow, }); const cached = cacheKey ? getCachedResult(cacheKey) : undefined; - const resolvedModelRef = getModelRef(params.runtimeConfig, params.agentId, params.config, { - modelProviderId: params.currentModelProviderId, - modelId: params.currentModelId, - }); const buildLogPrefix = (fastMode: ActiveMemoryFastMode | undefined) => [ `active-memory: agent=${toSingleLineLogValue(params.agentId)}`, @@ -472,7 +481,24 @@ async function maybeResolveActiveRecall(params: ActiveRecallParams): Promise + const model = getModelRef(params.runtimeConfig, params.agentId, params.config, { + modelProviderId: params.currentModelProviderId, + modelId: params.currentModelId, + }); + const scopeFingerprint = buildCacheKey({ + agentId: params.agentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + query: params.query, + authorityFingerprint: params.authorityFingerprint, + memorySlot: params.memorySlot, + activeProjectKeys: params.activeProjectKeys, + modelProviderId: model?.provider, + modelId: model?.model, + recallToolNames: params.config.toolsAllow, + resourceScope: JSON.stringify(params.conversationRecall ?? null), + }); + return await resolveActiveRecallForRun(`${runId}:${scopeFingerprint}`, (onTimeoutCleanup) => resolveActiveRecall({ ...recallParams, onTimeoutCleanup }), ); } diff --git a/extensions/active-memory/trigger-recall.test.ts b/extensions/active-memory/trigger-recall.test.ts index b9bc723ebc22..67c788dec7aa 100644 --- a/extensions/active-memory/trigger-recall.test.ts +++ b/extensions/active-memory/trigger-recall.test.ts @@ -4,7 +4,6 @@ import { buildTriggerRecallContext, isPromotedTrustedMemoryEntry, MAX_TRIGGER_CONTEXT_CHARS, - prewarmTriggerRecall, scoreTriggerMatch, resolveTriggerRecall, selectStrongTriggerMatches, @@ -240,25 +239,7 @@ describe("active-memory trigger recall", () => { }); }); - it("prewarms the exact lexical and trigger-candidate lookup path", async () => { - hoisted.search.mockResolvedValue([]); - hoisted.listTriggerCandidates.mockResolvedValue([]); - - await prewarmTriggerRecall({ - cfg: {} as never, - agentId: "main", - query: "flight booking", - }); - - expect(hoisted.getManager).toHaveBeenCalledWith({ cfg: {}, agentId: "main" }); - expect(hoisted.search).toHaveBeenCalledWith( - "flight booking", - expect.objectContaining({ lexicalOnly: true }), - ); - expect(hoisted.listTriggerCandidates).toHaveBeenCalledWith({ activeProjectKeys: [] }); - }); - - it("shares one in-flight prewarm with the lane-1 lookup for a run", async () => { + it("shares one in-flight lane-1 lookup for the same run authority", async () => { let releaseLookup: () => void = () => { throw new Error("lookup gate was not initialized"); }; @@ -275,31 +256,54 @@ describe("active-memory trigger recall", () => { }); const cfg = {} as never; - const prewarm = prewarmTriggerRecall({ - cfg, - agentId: "main", - query: "flight booking", - runId: "run-shared-prewarm", - }); - const recall = resolveTriggerRecall({ + const first = resolveTriggerRecall({ cfg, agentId: "main", query: "flight booking", message: "Help when booking a flight", - runId: "run-shared-prewarm", + runId: "run-shared-lookup", + authorityFingerprint: "authority-a", + }); + const second = resolveTriggerRecall({ + cfg, + agentId: "main", + query: "flight booking", + message: "Help when booking a flight", + runId: "run-shared-lookup", + authorityFingerprint: "authority-a", }); await vi.waitFor(() => expect(hoisted.search).toHaveBeenCalledTimes(1)); releaseLookup(); - await expect(prewarm).resolves.toBeUndefined(); - await expect(recall).resolves.toEqual( + await expect(first).resolves.toEqual( + expect.objectContaining({ hasStrongHit: true, injectedCount: 1 }), + ); + await expect(second).resolves.toEqual( expect.objectContaining({ hasStrongHit: true, injectedCount: 1 }), ); expect(hoisted.search).toHaveBeenCalledTimes(1); expect(hoisted.listTriggerCandidates).toHaveBeenCalledTimes(1); }); - it("keeps the lane-1 abort deadline while a shared prewarm continues", async () => { + it("does not share lane-1 results across turn authorities", async () => { + hoisted.search.mockResolvedValue([]); + hoisted.listTriggerCandidates.mockResolvedValue([result()]); + const params = { + cfg: {} as never, + agentId: "main", + query: "flight booking", + message: "Help when booking a flight", + runId: "run-authority-scope", + }; + + await resolveTriggerRecall({ ...params, authorityFingerprint: "authority-a" }); + await resolveTriggerRecall({ ...params, authorityFingerprint: "authority-b" }); + + expect(hoisted.search).toHaveBeenCalledTimes(2); + expect(hoisted.listTriggerCandidates).toHaveBeenCalledTimes(2); + }); + + it("keeps each lane-1 abort deadline while shared lookup work continues", async () => { let releaseLookup: () => void = () => { throw new Error("lookup gate was not initialized"); }; @@ -314,11 +318,13 @@ describe("active-memory trigger recall", () => { await lookupGate; return []; }); - const prewarm = prewarmTriggerRecall({ + const first = resolveTriggerRecall({ cfg: {} as never, agentId: "main", query: "flight booking", - runId: "run-aborted-shared-prewarm", + message: "Help when booking a flight", + runId: "run-aborted-shared-lookup", + authorityFingerprint: "authority-a", }); const controller = new AbortController(); const recall = resolveTriggerRecall({ @@ -326,17 +332,20 @@ describe("active-memory trigger recall", () => { agentId: "main", query: "flight booking", message: "Help when booking a flight", - runId: "run-aborted-shared-prewarm", + runId: "run-aborted-shared-lookup", + authorityFingerprint: "authority-a", signal: controller.signal, }); controller.abort(new Error("lane-1 budget expired")); await expect(recall).rejects.toThrow("lane-1 budget expired"); releaseLookup(); - await expect(prewarm).resolves.toBeUndefined(); + await expect(first).resolves.toEqual( + expect.objectContaining({ hasStrongHit: false, injectedCount: 0 }), + ); }); - it("does not reuse an unscoped prewarm for a project-scoped lookup", async () => { + it("does not reuse an unscoped run lookup for a project-scoped lookup", async () => { const global = result({ startLine: 1 }); const project = result({ startLine: 2, projectKey: "alpha-key" }); hoisted.search.mockResolvedValue([]); @@ -345,11 +354,13 @@ describe("active-memory trigger recall", () => { .mockResolvedValueOnce([global, project]); const cfg = {} as never; - await prewarmTriggerRecall({ + await resolveTriggerRecall({ cfg, agentId: "main", query: "flight booking", - runId: "run-project-prewarm", + message: "Help when booking a flight", + runId: "run-project-scope", + authorityFingerprint: "authority-a", }); const recall = await resolveTriggerRecall({ cfg, @@ -357,7 +368,8 @@ describe("active-memory trigger recall", () => { query: "flight booking", message: "Help when booking a flight", activeProjectKeys: ["alpha-key"], - runId: "run-project-prewarm", + runId: "run-project-scope", + authorityFingerprint: "authority-a", }); expect(hoisted.listTriggerCandidates).toHaveBeenCalledTimes(2); diff --git a/extensions/active-memory/trigger-recall.ts b/extensions/active-memory/trigger-recall.ts index abb64985176b..d7682bd7f6a2 100644 --- a/extensions/active-memory/trigger-recall.ts +++ b/extensions/active-memory/trigger-recall.ts @@ -137,9 +137,10 @@ type TriggerLookupParams = { activeProjectKeys?: string[]; signal?: AbortSignal; runId?: string; + authorityFingerprint?: string; }; -type TriggerRecallPrewarmEntry = { +type TriggerRecallRunEntry = { activeProjectKeys: string[]; agentId: string; cfg: OpenClawConfig; @@ -147,7 +148,7 @@ type TriggerRecallPrewarmEntry = { query: string; }; -const triggerRecallPrewarms = new Map(); +const triggerRecallRuns = new Map(); async function loadTriggerRecallCandidates(params: TriggerLookupParams) { params.signal?.throwIfAborted(); @@ -195,7 +196,8 @@ function resolveTriggerRecallCandidates(params: TriggerLookupParams) { if (!runId) { return loadTriggerRecallCandidates(params); } - const existing = triggerRecallPrewarms.get(runId); + const runKey = `${runId}:${params.authorityFingerprint ?? "none"}`; + const existing = triggerRecallRuns.get(runKey); const activeProjectKeys = params.activeProjectKeys ?? []; if ( existing && @@ -207,27 +209,22 @@ function resolveTriggerRecallCandidates(params: TriggerLookupParams) { ) { return existing.promise; } - const entry: TriggerRecallPrewarmEntry = { + const entry: TriggerRecallRunEntry = { activeProjectKeys: [...activeProjectKeys], agentId: params.agentId, cfg: params.cfg, promise: loadTriggerRecallCandidates(params), query: params.query, }; - triggerRecallPrewarms.set(runId, entry); + triggerRecallRuns.set(runKey, entry); void entry.promise.catch(() => { - if (triggerRecallPrewarms.get(runId) === entry) { - triggerRecallPrewarms.delete(runId); + if (triggerRecallRuns.get(runKey) === entry) { + triggerRecallRuns.delete(runKey); } }); return entry.promise; } -/** Open and exercise the exact local lookup path used by lane 1 before its deadline starts. */ -export async function prewarmTriggerRecall(params: TriggerLookupParams): Promise { - await resolveTriggerRecallCandidates(params); -} - export async function resolveTriggerRecall( params: TriggerLookupParams & { message: string }, ): Promise<{ context?: string; hasStrongHit: boolean; injectedCount: number }> { @@ -246,14 +243,18 @@ export async function resolveTriggerRecall( }; } -export function forgetTriggerRecallPrewarm(runId: string | undefined): void { +export function forgetTriggerRecallRun(runId: string | undefined): void { if (runId) { - triggerRecallPrewarms.delete(runId); + for (const key of triggerRecallRuns.keys()) { + if (key.startsWith(`${runId}:`)) { + triggerRecallRuns.delete(key); + } + } } } -export function resetTriggerRecallPrewarmsForTests(): void { - triggerRecallPrewarms.clear(); +export function resetTriggerRecallRunsForTests(): void { + triggerRecallRuns.clear(); } function waitForTriggerLookup(work: Promise, signal?: AbortSignal): Promise { diff --git a/extensions/codex/src/app-server/run-attempt-prompt.ts b/extensions/codex/src/app-server/run-attempt-prompt.ts index b9e26758e15b..7eb13c6aec39 100644 --- a/extensions/codex/src/app-server/run-attempt-prompt.ts +++ b/extensions/codex/src/app-server/run-attempt-prompt.ts @@ -188,16 +188,28 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) { const buildPromptFromCurrentInputs = async () => { const result = await resolveAgentHarnessBeforePromptBuildResult({ prompt: prependCurrentInboundContext(promptState.promptText, params.currentInboundContext), - developerInstructions: promptState.developerInstructions, + developerInstructions: { + build: ({ toolsAllow }) => { + if (isRestrictivePromptToolsAllow(toolsAllow)) { + throw new Error( + "Codex app-server cannot enforce before_prompt_build toolsAllow; use the embedded or Copilot runtime for turn-scoped tool policy.", + ); + } + return promptState.developerInstructions; + }, + }, messages: structuredClone(historyState.messages), ctx: hookContext, bootstrapContextRunKind: params.bootstrapContextRunKind, + toolAuthority: { + fingerprint: params.toolAuthorityFingerprint, + activeToolNames: () => + flattenCodexDynamicToolFunctions(toolBridge.availableSpecs) + .map((tool) => tool.name) + .filter(isNonEmptyString), + assertActive: params.hostCapabilities.assertActive, + }, }); - if (isRestrictivePromptToolsAllow(result.toolsAllow)) { - throw new Error( - "Codex app-server cannot enforce before_prompt_build toolsAllow; use the embedded or Copilot runtime for turn-scoped tool policy.", - ); - } return result; }; const resolveShiftedPromptInputRange = ( diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 179326cbe2cf..1dde07c9ff37 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -2936,19 +2936,28 @@ describe("runCodexAppServerAttempt", () => { }); it("fails closed when before_prompt_build restricts Codex tools", async () => { + const authorizedEnrichment = vi.fn(() => ({ prependContext: "private recalled context" })); initializeGlobalHookRunner( createMockPluginRegistry([ { hookName: "before_prompt_build", handler: () => ({ toolsAllow: ["message"] }), }, + { + hookName: "before_prompt_build", + handler: authorizedEnrichment, + requiresToolAuthority: true, + }, ]), ); const { sessionFile, workspaceDir } = createRunPaths(); + const params = createParams(sessionFile, workspaceDir); + params.toolAuthorityFingerprint = "restrictive-turn-authority"; - await expect(runCodexAppServerAttempt(createParams(sessionFile, workspaceDir))).rejects.toThrow( + await expect(runCodexAppServerAttempt(params)).rejects.toThrow( "Codex app-server cannot enforce before_prompt_build toolsAllow", ); + expect(authorizedEnrichment).not.toHaveBeenCalled(); }); it("releases adopted startup resources when continuity prompt rebuilding fails", async () => { diff --git a/extensions/copilot/src/attempt-session-setup.ts b/extensions/copilot/src/attempt-session-setup.ts index 11013d9ce7b0..332232bb45f4 100644 --- a/extensions/copilot/src/attempt-session-setup.ts +++ b/extensions/copilot/src/attempt-session-setup.ts @@ -74,6 +74,9 @@ export async function createCopilotSessionSetup(params: { promptPolicyResult = promptToolPolicy?.apply(); promptBuild = { prompt: input.prompt, developerInstructions: "" }; } else { + if (!ordinaryAttemptInput) { + throw new Error("Copilot ordinary attempt authority is unavailable."); + } if (!promptToolPolicy) { throw new Error("Copilot ordinary attempts require a prompt tool policy."); } @@ -92,6 +95,11 @@ export async function createCopilotSessionSetup(params: { messages, ctx: hookContext, bootstrapContextRunKind: input.bootstrapContextRunKind, + toolAuthority: { + fingerprint: input.toolAuthorityFingerprint, + activeToolNames: () => promptPolicyResult?.callableToolNames ?? [], + assertActive: ordinaryAttemptInput.hostCapabilities.assertActive, + }, }); } const attemptInput = diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index c6903a2e106c..2fcfa93fc7bf 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -288,6 +288,8 @@ export default definePluginEntry({ registerShortTermPromotionDreaming(api); registerSessionBackfillGatewayMethods(api); api.registerMemoryCapability({ + deterministicRecallToolName: "memory_search", + supportsPrivateTranscriptRecall: true, promptBuilder: buildPromptSection, flushPlanResolver: buildMemoryFlushPlan, runtime: memoryRuntime, diff --git a/extensions/memory-lancedb/auto-recall.ts b/extensions/memory-lancedb/auto-recall.ts new file mode 100644 index 000000000000..163b5057f4b9 --- /dev/null +++ b/extensions/memory-lancedb/auto-recall.ts @@ -0,0 +1,148 @@ +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { OpenClawPluginApi } from "./api.js"; +import type { MemoryConfig } from "./config.js"; +import { + type Embeddings, + isMemoryRecallTimeoutError, + MemoryRecallEmbeddingError, + runWithTimeout, +} from "./embeddings.js"; +import type { MemoryDB } from "./lancedb-store.js"; +import { dropMediaNoteLines } from "./memory-capture-sanitization.js"; +import { + cleanMemorySearchResults, + extractLatestUserText, + formatRelevantMemoriesContext, + normalizeRecallQuery, +} from "./memory-policy.js"; + +const AUTO_RECALL_TIMEOUT_MS = 15_000; +const AUTO_RECALL_OVERFETCH_LIMIT = 10; +const AUTO_RECALL_RESULT_CAP = 3; + +type AutoRecallToolAuthority = { + allows(toolName: string): boolean; + assertActive(): void; +}; + +type AutoRecallHookContext = { + agentId?: string; + toolAuthority?: AutoRecallToolAuthority; +}; + +type AutoRecallHookEvent = { + prompt: string; + messages: unknown[]; +}; + +export function createAutoRecallHook(params: { + logger: OpenClawPluginApi["logger"]; + db: MemoryDB; + embeddings: Embeddings; + resolveCurrentConfig: () => MemoryConfig; + resolveEnabledAgentId: (rawAgentId: string | undefined) => string | undefined; + readCooldown: (agentId: string) => { error: string } | undefined; + recordCooldown: (agentId: string, error: string) => void; +}) { + return async (event: AutoRecallHookEvent, ctx: AutoRecallHookContext) => { + const currentCfg = params.resolveCurrentConfig(); + const recallMaxChars = currentCfg.recallMaxChars; + if (!currentCfg.autoRecall) { + return undefined; + } + const toolAuthority = ctx.toolAuthority; + if (!toolAuthority) { + params.logger.debug?.( + "memory-lancedb: auto-recall skipped because this prompt has no turn tool authority", + ); + return undefined; + } + toolAuthority.assertActive(); + if (!toolAuthority.allows("memory_recall")) { + params.logger.debug?.("memory-lancedb: auto-recall skipped by turn tool policy"); + return undefined; + } + const agentId = params.resolveEnabledAgentId(ctx.agentId); + if (!agentId || !event.prompt || event.prompt.length < 5) { + return undefined; + } + // One hung embedding request must not stall both automatic and explicit recall. + // Keep the breaker per agent so unrelated memory namespaces still probe. + const cooldown = params.readCooldown(agentId); + if (cooldown) { + params.logger.debug?.( + `memory-lancedb: auto-recall skipped during recall cooldown: ${cooldown.error}`, + ); + return undefined; + } + + try { + const recallQuery = normalizeRecallQuery( + dropMediaNoteLines(extractLatestUserText(event.messages) ?? event.prompt), + recallMaxChars, + ); + if (!recallQuery) { + return undefined; + } + let recallPhase: "embedding" | "search" = "embedding"; + toolAuthority.assertActive(); + const recall = await runWithTimeout({ + timeoutMs: AUTO_RECALL_TIMEOUT_MS, + task: async (deadlineAtMs) => { + let vector: number[]; + try { + vector = await params.embeddings.embed( + agentId, + recallQuery, + currentCfg.embedding, + Math.max(1, deadlineAtMs - Date.now()), + ); + } catch (error) { + throw new MemoryRecallEmbeddingError(error); + } + toolAuthority.assertActive(); + // Keep one end-to-end deadline, but only let embedding timeouts trip + // the shared breaker. LanceDB stalls remain retryable next turn. + recallPhase = "search"; + return await params.db.search(agentId, vector, AUTO_RECALL_OVERFETCH_LIMIT, 0.3, { + timeoutMs: Math.max(0, deadlineAtMs - Date.now()), + }); + }, + }); + toolAuthority.assertActive(); + if (recall.status === "timeout") { + if (recallPhase === "embedding") { + params.recordCooldown( + agentId, + `auto-recall timed out after ${Math.round(AUTO_RECALL_TIMEOUT_MS / 1000)}s`, + ); + } + params.logger.warn?.( + `memory-lancedb: auto-recall timed out after ${AUTO_RECALL_TIMEOUT_MS}ms; skipping memory injection to avoid stalling agent startup`, + ); + return undefined; + } + + const cleanResults = cleanMemorySearchResults(recall.value) + .map(({ result, text }) => ({ category: result.entry.category, text })) + .slice(0, AUTO_RECALL_RESULT_CAP); + if (cleanResults.length === 0) { + return undefined; + } + params.logger.info?.( + `memory-lancedb: injecting ${cleanResults.length} memories into context`, + ); + const context = formatRelevantMemoriesContext(cleanResults, recallMaxChars); + return context ? { prependContext: context } : undefined; + } catch (err) { + if ( + err instanceof MemoryRecallEmbeddingError && + isMemoryRecallTimeoutError(err.originalError) + ) { + params.recordCooldown(agentId, formatErrorMessage(err.originalError)); + } + params.logger.warn(`memory-lancedb: recall failed: ${String(err)}`); + return undefined; + } + }; +} diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index b3185a33f8f5..1bae911aa2d7 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -112,6 +112,14 @@ const CTX = "⟦openclaw:ctx⟧"; const ctxHeader = (label: string): string => `${label} ${CTX}`; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key"; +const withAllowedMemoryRecallAuthority = (ctx: Record = {}) => ({ + toolAuthority: { + fingerprint: "allowed-memory-authority", + allows: (toolName: string) => toolName === "memory_recall", + assertActive: () => undefined, + }, + ...ctx, +}); type MemoryPluginTestConfig = { embedding?: { provider?: string; @@ -819,7 +827,7 @@ describe("memory plugin e2e", () => { }); await hookHandler(on, "before_prompt_build")?.( { prompt: "private automatic recall secret", messages: [] }, - { agentId: "private" }, + withAllowedMemoryRecallAuthority({ agentId: "private" }), ); await hookHandler(on, "agent_end")?.( { @@ -1315,7 +1323,10 @@ describe("memory plugin e2e", () => { )?.[1]; expect(beforePromptBuild).toBeTypeOf("function"); await expect( - beforePromptBuild?.({ prompt: "what editor should i use?", messages: [] }, {}), + beforePromptBuild?.( + { prompt: "what editor should i use?", messages: [] }, + withAllowedMemoryRecallAuthority(), + ), ).resolves.toBeUndefined(); expectHookRegistered(on, "agent_end"); }); @@ -1351,6 +1362,60 @@ describe("memory plugin e2e", () => { ).resolves.toBeUndefined(); }); + test("does not start auto-recall when the turn authority denies memory_recall", async () => { + const embeddingsCreate = vi.fn(async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + })); + const loadLanceDbModule = vi.fn(async () => ({ + connect: vi.fn(), + })); + + await withMockedOpenAiMemoryPlugin({ + embeddingsCreate, + ensureGlobalUndiciEnvProxyDispatcher: vi.fn(), + loadLanceDbModule, + run: async () => { + const on = vi.fn(); + const mockApi = createMemoryPluginApi(getDbPath(), { + pluginConfig: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: true, + }, + on, + }); + + registerTestPlugin(memoryPlugin, mockApi); + const beforePromptBuild = on.mock.calls.find( + ([hookName]) => hookName === "before_prompt_build", + )?.[1]; + const assertActive = vi.fn(); + + await expect( + beforePromptBuild?.( + { prompt: "what editor should i use?", messages: [] }, + { + agentId: "main", + toolAuthority: { + fingerprint: "denied-memory-authority", + allows: () => false, + assertActive, + }, + }, + ), + ).resolves.toBeUndefined(); + + expect(assertActive).toHaveBeenCalled(); + expect(embeddingsCreate).not.toHaveBeenCalled(); + expect(loadLanceDbModule).not.toHaveBeenCalled(); + }, + }); + }); + test("runs auto-recall through the registered before_prompt_build hook", async () => { const embeddingsCreate = vi.fn(async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }], @@ -1431,7 +1496,7 @@ describe("memory plugin e2e", () => { }, ], }, - { agentId: "main" }, + withAllowedMemoryRecallAuthority({ agentId: "main" }), ); expect(loadLanceDbModule).toHaveBeenCalledTimes(1); @@ -1528,7 +1593,10 @@ describe("memory plugin e2e", () => { expect(beforePromptBuild).toBeTypeOf("function"); const hookEvent = { prompt: "what editor should i use?", messages: [] }; - const resultPromise = beforePromptBuild?.(hookEvent, { agentId: "main" }); + const resultPromise = beforePromptBuild?.( + hookEvent, + withAllowedMemoryRecallAuthority({ agentId: "main" }), + ); await vi.advanceTimersByTimeAsync(15_000); await expect(resultPromise).resolves.toBeUndefined(); @@ -1542,7 +1610,12 @@ describe("memory plugin e2e", () => { "memory-lancedb: auto-recall timed out after 15000ms; skipping memory injection to avoid stalling agent startup", ); - expect(await beforePromptBuild?.(hookEvent, { agentId: "main" })).toBeUndefined(); + expect( + await beforePromptBuild?.( + hookEvent, + withAllowedMemoryRecallAuthority({ agentId: "main" }), + ), + ).toBeUndefined(); expect(post).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith( "memory-lancedb: auto-recall skipped during recall cooldown: auto-recall timed out after 15s", @@ -1569,7 +1642,7 @@ describe("memory plugin e2e", () => { }); post.mockRejectedValueOnce(sdkTimeoutError); await expect( - beforePromptBuild?.(hookEvent, { agentId: "main" }), + beforePromptBuild?.(hookEvent, withAllowedMemoryRecallAuthority({ agentId: "main" })), ).resolves.toBeUndefined(); expect(post).toHaveBeenCalledTimes(2); @@ -1586,7 +1659,10 @@ describe("memory plugin e2e", () => { await vi.advanceTimersByTimeAsync(60_000); post.mockResolvedValueOnce({ data: [{ embedding: [0.1, 0.2, 0.3] }] }); - const probeResult = beforePromptBuild?.(hookEvent, { agentId: "main" }); + const probeResult = beforePromptBuild?.( + hookEvent, + withAllowedMemoryRecallAuthority({ agentId: "main" }), + ); await vi.advanceTimersByTimeAsync(0); expect(loadLanceDbModule).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(15_000); @@ -1594,7 +1670,10 @@ describe("memory plugin e2e", () => { expect(post).toHaveBeenCalledTimes(3); post.mockRejectedValueOnce(Object.assign(new Error("bad auto query"), { status: 400 })); - const retryResult = beforePromptBuild?.(hookEvent, { agentId: "main" }); + const retryResult = beforePromptBuild?.( + hookEvent, + withAllowedMemoryRecallAuthority({ agentId: "main" }), + ); await vi.advanceTimersByTimeAsync(0); expect(post).toHaveBeenCalledTimes(4); await expect(retryResult).resolves.toBeUndefined(); @@ -1621,7 +1700,12 @@ describe("memory plugin e2e", () => { }); expect(post).toHaveBeenCalledTimes(6); - expect(await beforePromptBuild?.(hookEvent, { agentId: "main" })).toBeUndefined(); + expect( + await beforePromptBuild?.( + hookEvent, + withAllowedMemoryRecallAuthority({ agentId: "main" }), + ), + ).toBeUndefined(); expect(post).toHaveBeenCalledTimes(6); await vi.advanceTimersByTimeAsync(60_000); @@ -1640,7 +1724,10 @@ describe("memory plugin e2e", () => { }, }); - const finalResult = beforePromptBuild?.(hookEvent, { agentId: "main" }); + const finalResult = beforePromptBuild?.( + hookEvent, + withAllowedMemoryRecallAuthority({ agentId: "main" }), + ); await vi.advanceTimersByTimeAsync(0); expect(post).toHaveBeenCalledTimes(8); await vi.advanceTimersByTimeAsync(15_000); @@ -1815,7 +1902,7 @@ describe("memory plugin e2e", () => { const result = await beforePromptBuild?.( { prompt: "what editor should i use?", messages: [] }, - { agentId: "main" }, + withAllowedMemoryRecallAuthority({ agentId: "main" }), ); expect(loadLanceDbModule).toHaveBeenCalledTimes(1); @@ -1921,7 +2008,7 @@ describe("memory plugin e2e", () => { const result = await beforePromptBuild?.( { prompt: "what editor should i use?", messages: [] }, - { agentId: "main" }, + withAllowedMemoryRecallAuthority({ agentId: "main" }), ); expect(result).toBeUndefined(); @@ -2038,25 +2125,32 @@ describe("memory plugin e2e", () => { messages: [{ role: "user", content: "I prefer Helix for editing code every day." }], }; - const recallUnscoped = await beforePromptBuild?.(recallEvent, {}); + const recallUnscoped = await beforePromptBuild?.( + recallEvent, + withAllowedMemoryRecallAuthority(), + ); await agentEnd?.(captureEvent, {}); expect(recallUnscoped).toBeUndefined(); expect(embeddingsCreate).not.toHaveBeenCalled(); expect(add).not.toHaveBeenCalled(); - const recallDisabled = await beforePromptBuild?.(recallEvent, { agentId: "xiaohuo" }); + const recallDisabled = await beforePromptBuild?.( + recallEvent, + withAllowedMemoryRecallAuthority({ agentId: "xiaohuo" }), + ); await agentEnd?.(captureEvent, { agentId: "xiaohuo", sessionKey: "agent:xiaohuo:main" }); expect(recallDisabled).toBeUndefined(); expect(embeddingsCreate).not.toHaveBeenCalled(); expect(add).not.toHaveBeenCalled(); - const recallDisabledCased = await beforePromptBuild?.(recallEvent, { - agentId: " XiaoHuo ", - }); + const recallDisabledCased = await beforePromptBuild?.( + recallEvent, + withAllowedMemoryRecallAuthority({ agentId: " XiaoHuo " }), + ); expect(recallDisabledCased).toBeUndefined(); expect(embeddingsCreate).not.toHaveBeenCalled(); - await beforePromptBuild?.(recallEvent, { agentId: "main" }); + await beforePromptBuild?.(recallEvent, withAllowedMemoryRecallAuthority({ agentId: "main" })); expect(embeddingsCreate).toHaveBeenCalled(); embeddingsCreate.mockClear(); await agentEnd?.(captureEvent, { agentId: "main", sessionKey: "agent:main:main" }); @@ -2092,9 +2186,10 @@ describe("memory plugin e2e", () => { expect(add).not.toHaveBeenCalled(); expect(deleteRows).not.toHaveBeenCalled(); - const recallDefaultDisabled = await beforePromptBuild?.(recallEvent, { - agentId: "unlisted", - }); + const recallDefaultDisabled = await beforePromptBuild?.( + recallEvent, + withAllowedMemoryRecallAuthority({ agentId: "unlisted" }), + ); expect(recallDefaultDisabled).toBeUndefined(); expect(embeddingsCreate).not.toHaveBeenCalled(); } finally { @@ -2180,7 +2275,7 @@ describe("memory plugin e2e", () => { const result = await beforePromptBuild?.( { prompt: "what editor should i use after memory is removed?", messages: [] }, - { agentId: "main" }, + withAllowedMemoryRecallAuthority({ agentId: "main" }), ); expect(result).toBeUndefined(); diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index db135f321111..4430ff399e71 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -16,6 +16,7 @@ import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { Type } from "typebox"; import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; +import { createAutoRecallHook } from "./auto-recall.js"; import { MEMORY_CATEGORIES, type MemoryConfig, @@ -30,17 +31,15 @@ import { runWithTimeout, } from "./embeddings.js"; import { MemoryDB, type MemoryEntry, type MemorySearchResult } from "./lancedb-store.js"; -import { dropMediaNoteLines, sanitizeForMemoryCapture } from "./memory-capture-sanitization.js"; +import { sanitizeForMemoryCapture } from "./memory-capture-sanitization.js"; import { registerMemoryCli } from "./memory-cli.js"; import { type AutoCaptureCursor, cleanMemorySearchResults, detectCategory, - extractLatestUserText, extractUserTextContent, findCleanDuplicateMemory, formatRecalledMemoryForModel, - formatRelevantMemoriesContext, looksLikePromptInjection, messageFingerprint, normalizeRecallQuery, @@ -52,21 +51,10 @@ const loadMemoryHostCoreModule = createLazyRuntimeModule( () => import("openclaw/plugin-sdk/memory-host-core"), ); -const DEFAULT_AUTO_RECALL_TIMEOUT_MS = 15_000; const DEFAULT_TOOL_RECALL_TIMEOUT_MS = 15_000; const DEFAULT_RECALL_COOLDOWN_MS = 60_000; const DEFAULT_TOOL_RECALL_OVERFETCH_EXTRA = 10; -// Auto-recall over-fetches from the vector store, then filters envelope sludge -// (contaminated memories that slipped past capture gating), then caps the -// surviving results before prompt injection. The over-fetch limit must stay a -// few multiples above the cap so a small number of contaminated top-K hits -// still leave enough clean memories to surface; the cap mirrors prior -// behavior of "at most 3 injected memories" so prompt budget impact stays -// bounded. -const DEFAULT_AUTO_RECALL_OVERFETCH_LIMIT = 10; -const DEFAULT_AUTO_RECALL_RESULT_CAP = 3; - export { normalizeEmbeddingVector, testing } from "./embeddings.js"; export { parseMemoryCliFilter } from "./memory-cli.js"; export { @@ -554,108 +542,19 @@ export default definePluginEntry({ registerMemoryCli(api, db, embeddings, resolveCliAgentId, resolveCurrentHookConfig); - api.on("before_prompt_build", async (event, ctx) => { - const currentCfg = resolveCurrentHookConfig(); - const recallMaxChars = currentCfg.recallMaxChars; - if (!currentCfg.autoRecall) { - return undefined; - } - const agentId = resolveEnabledAgentId(ctx.agentId); - if (!agentId) { - return undefined; - } - if (!event.prompt || event.prompt.length < 5) { - return undefined; - } - // One hung embedding request must not stall both automatic and explicit recall. - // Keep the breaker per agent so unrelated memory namespaces still probe. - const cooldown = readMemoryRecallCooldown(agentId); - if (cooldown) { - api.logger.debug?.( - `memory-lancedb: auto-recall skipped during recall cooldown: ${cooldown.error}`, - ); - return undefined; - } - - try { - const recallQuery = normalizeRecallQuery( - dropMediaNoteLines( - extractLatestUserText(Array.isArray(event.messages) ? event.messages : []) ?? - event.prompt, - ), - recallMaxChars, - ); - if (!recallQuery) { - return undefined; - } - let recallPhase: "embedding" | "search" = "embedding"; - const recall = await runWithTimeout({ - timeoutMs: DEFAULT_AUTO_RECALL_TIMEOUT_MS, - task: async (deadlineAtMs) => { - let vector: number[]; - try { - vector = await embeddings.embed( - agentId, - recallQuery, - currentCfg.embedding, - Math.max(1, deadlineAtMs - Date.now()), - ); - } catch (error) { - throw new MemoryRecallEmbeddingError(error); - } - // Keep one end-to-end deadline, but only let embedding timeouts trip - // the shared breaker. LanceDB stalls remain retryable next turn. - recallPhase = "search"; - // Overfetch to compensate for sludge filtering: if contaminated - // entries occupy the top slots we still surface enough clean ones. - return await db.search(agentId, vector, DEFAULT_AUTO_RECALL_OVERFETCH_LIMIT, 0.3, { - timeoutMs: Math.max(0, deadlineAtMs - Date.now()), - }); - }, - }); - if (recall.status === "timeout") { - if (recallPhase === "embedding") { - recordMemoryRecallCooldown( - agentId, - `auto-recall timed out after ${Math.round(DEFAULT_AUTO_RECALL_TIMEOUT_MS / 1000)}s`, - ); - } - api.logger.warn?.( - `memory-lancedb: auto-recall timed out after ${DEFAULT_AUTO_RECALL_TIMEOUT_MS}ms; skipping memory injection to avoid stalling agent startup`, - ); - return undefined; - } - - // Filter contaminated memories, then cap at the prompt-budget bound. - const cleanResults = cleanMemorySearchResults(recall.value) - .map(({ result, text }) => ({ category: result.entry.category, text })) - .slice(0, DEFAULT_AUTO_RECALL_RESULT_CAP); - - if (cleanResults.length === 0) { - return undefined; - } - - api.logger.info?.(`memory-lancedb: injecting ${cleanResults.length} memories into context`); - - const context = formatRelevantMemoriesContext(cleanResults, recallMaxChars); - if (!context) { - return undefined; - } - - return { - prependContext: context, - }; - } catch (err) { - if ( - err instanceof MemoryRecallEmbeddingError && - isMemoryRecallTimeoutError(err.originalError) - ) { - recordMemoryRecallCooldown(agentId, formatErrorMessage(err.originalError)); - } - api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`); - } - return undefined; - }); + api.on( + "before_prompt_build", + createAutoRecallHook({ + logger: api.logger, + db, + embeddings, + resolveCurrentConfig: resolveCurrentHookConfig, + resolveEnabledAgentId, + readCooldown: readMemoryRecallCooldown, + recordCooldown: recordMemoryRecallCooldown, + }), + { requiresToolAuthority: true }, + ); api.on("agent_end", async (event, ctx) => { const currentCfg = resolveCurrentHookConfig(); diff --git a/src/agents/admitted-run-context.test.ts b/src/agents/admitted-run-context.test.ts index 25265934b9d3..ca1def826224 100644 --- a/src/agents/admitted-run-context.test.ts +++ b/src/agents/admitted-run-context.test.ts @@ -12,6 +12,7 @@ import { getAdmittedRunDelegatedAuthority, prepareAgentRunAdmission, retainAdmittedRunBeforeToolCallRecovery, + resolveAdmittedRunActiveAssertion, resolvePreparedRunAdmission, } from "./admitted-run-context.js"; @@ -241,6 +242,25 @@ describe("prepared run admission", () => { await expect(prepared.admit(runtime.kind)).rejects.toThrow("already closed"); }); + it("invalidates an admitted-run assertion on abort and outer close", async () => { + const { runtime, ...admissionFacts } = facts; + const prepared = prepareAgentRunAdmission({ + cfg: {}, + facts: { ...admissionFacts, runId: "run-assertion" }, + operationalRunInstance: createOperationalRunInstanceRef("run-assertion"), + }); + const admitted = await prepared.admit(runtime.kind); + const abort = new AbortController(); + const assertActive = resolveAdmittedRunActiveAssertion(admitted, abort.signal); + + expect(assertActive).toBeDefined(); + expect(() => assertActive?.()).not.toThrow(); + abort.abort(); + expect(() => assertActive?.()).toThrow("no longer active"); + prepared.close(); + expect(() => assertActive?.()).toThrow("no longer active"); + }); + it("closes generic authority while keeping a recovery-only lease active", async () => { const { runtime, ...admissionFacts } = facts; const prepared = prepareAgentRunAdmission({ diff --git a/src/agents/admitted-run-context.ts b/src/agents/admitted-run-context.ts index 1e636bbca809..99f02c8d2f90 100644 --- a/src/agents/admitted-run-context.ts +++ b/src/agents/admitted-run-context.ts @@ -65,6 +65,27 @@ export function getAdmittedRunDelegatedAuthority( : undefined; } +/** Captures an exact admitted-run assertion for work that may cross an await boundary. */ +export function resolveAdmittedRunActiveAssertion( + context: AdmittedRunContext, + signal?: AbortSignal, +): (() => void) | undefined { + const operationalRunInstance = context.operationalRunInstance; + const authority = getAdmittedRunDelegatedAuthority(context); + if (!authority) { + return undefined; + } + return () => { + if ( + signal?.aborted || + context.operationalRunInstance !== operationalRunInstance || + getAdmittedRunDelegatedAuthority(context) !== authority + ) { + throw new Error("admitted run authority is no longer active"); + } + }; +} + /** Idempotently compare-releases the authority captured by this admission. */ export function closeAdmittedRunDelegatedAuthority(context: AdmittedRunContext): boolean { const lease = delegatedAuthorityLeases.get(context); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 0b3eca9adc5a..954bc1198792 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -29,6 +29,10 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { captureEnv, setTestEnvValue } from "../../test-utils/env.js"; +import { + createOperationalRunInstanceRef, + prepareAgentRunAdmission, +} from "../admitted-run-context.js"; import { createTestAdmittedRunContext, createTestPreparedRunAdmission, @@ -2024,6 +2028,44 @@ describe("prepareCliRunContext", () => { expect(promptContext?.senderId).toBe("user-789"); }); + it("applies turn-authorized prompt enrichment after CLI tool preparation", async () => { + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), + runBeforePromptBuild: vi.fn(async () => undefined), + runAuthorizedPromptBuild: vi.fn(async () => ({ + prependContext: "authorized memory context", + })), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + const preparedRunAdmission = prepareAgentRunAdmission({ + cfg: {}, + operationalRunInstance: createOperationalRunInstanceRef("run-test"), + facts: { + runId: "run-test", + agentId: "main", + ingress: { kind: "system", boundary: "test", state: "present" }, + }, + }); + + const context = await fixture + .prepare({ + toolAuthorityFingerprint: "turn-authority", + preparedRunAdmission, + }) + .finally(preparedRunAdmission.close); + + expect(context.params.prompt).toBe("authorized memory context\n\nlatest ask"); + expect(hookRunner.runAuthorizedPromptBuild).toHaveBeenCalledWith( + expect.objectContaining({ prompt: "latest ask" }), + expect.any(Object), + { + toolAuthorityFingerprint: "turn-authority", + activeToolNames: [], + assertHostActive: expect.any(Function), + }, + ); + }); + it("preserves the base prompt when prompt-build hooks fail", async () => { const hookRunner = { hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index b20e99f0dfcd..63dbeaf61c2a 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -49,7 +49,10 @@ import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; -import { resolvePreparedRunAdmission } from "../admitted-run-context.js"; +import { + resolveAdmittedRunActiveAssertion, + resolvePreparedRunAdmission, +} from "../admitted-run-context.js"; import { hasAgentRosterProperty, resolveAgentWorkspaceDir } from "../agent-scope-config.js"; import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; import { hasUsableOAuthCredential } from "../auth-profiles/credential-state.js"; @@ -780,28 +783,29 @@ export async function prepareCliRunContext( }); return openClawHistoryMessages; }; + const promptBuildHookContext = { + runId: params.runId, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir, + modelProviderId: params.provider, + modelId, + trigger: params.trigger, + ...buildAgentHookContextChannelFields(params), + }; + const promptBuildHookRunner = skipsTurnPreparation ? undefined : getGlobalHookRunner(); const promptBuildHookResult = await (async () => { if (skipsTurnPreparation) { return undefined; } - const hookRunner = getGlobalHookRunner(); try { return await resolvePromptBuildHookResult({ config: params.config ?? getRuntimeConfig(), prompt: params.prompt, messages: await loadOpenClawHistoryMessages(), - hookCtx: { - runId: params.runId, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - workspaceDir, - modelProviderId: params.provider, - modelId, - trigger: params.trigger, - ...buildAgentHookContextChannelFields(params), - }, - hookRunner, + hookCtx: promptBuildHookContext, + hookRunner: promptBuildHookRunner, bootstrapContextRunKind: params.bootstrapContextRunKind, }); } catch (error) { @@ -1077,6 +1081,38 @@ export async function prepareCliRunContext( ) : hookFilteredProjectedTools; const promptTools = bundleMcpEnabled ? projectedTools : []; + const authorizedPromptBuildResult = await (async () => { + const toolAuthorityFingerprint = params.toolAuthorityFingerprint; + if (!promptBuildHookRunner || !toolAuthorityFingerprint) { + return undefined; + } + const admittedParams = await admitPreparedParams(params); + params = admittedParams; + const assertHostActive = resolveAdmittedRunActiveAssertion( + admittedParams.admittedRunContext, + admittedParams.abortSignal, + ); + if (!assertHostActive) { + return undefined; + } + try { + return await promptBuildHookRunner.runAuthorizedPromptBuild( + { + prompt: params.prompt, + messages: await loadOpenClawHistoryMessages(), + }, + promptBuildHookContext, + { + toolAuthorityFingerprint, + activeToolNames: promptTools.map((tool) => tool.name), + assertHostActive, + }, + ); + } catch (error) { + cliBackendLog.warn(`authorized CLI prompt-build hook failed: ${String(error)}`); + return undefined; + } + })(); const messageToolAvailable = promptTools.some( (tool) => normalizeToolPolicyName(tool.name) === "message", ); @@ -1558,11 +1594,23 @@ export async function prepareCliRunContext( if (!skipsTurnPreparation) { try { const hookResult = promptBuildHookResult; - if (hookResult?.prependContext) { - preparedPrompt = `${hookResult.prependContext}\n\n${preparedPrompt}`; + const prependContext = [ + hookResult?.prependContext, + authorizedPromptBuildResult?.prependContext, + ] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n\n"); + const appendContext = [ + hookResult?.appendContext, + authorizedPromptBuildResult?.appendContext, + ] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n\n"); + if (prependContext) { + preparedPrompt = `${prependContext}\n\n${preparedPrompt}`; } - if (hookResult?.appendContext) { - preparedPrompt = `${preparedPrompt}\n\n${hookResult.appendContext}`; + if (appendContext) { + preparedPrompt = `${preparedPrompt}\n\n${appendContext}`; } const hookSystemPrompt = hookResult?.systemPrompt?.trim(); if (hookSystemPrompt) { diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts index d2b91b3878dd..64a0c141ffdd 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-build.ts @@ -21,6 +21,7 @@ import { } from "../../../plugins/hook-agent-context.js"; import type { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { annotateInterSessionPromptText } from "../../../sessions/input-provenance.js"; +import { resolveAdmittedRunActiveAssertion } from "../../admitted-run-context.js"; import type { createCacheTrace } from "../../cache-trace.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; @@ -159,6 +160,7 @@ export async function prepareEmbeddedAttemptPromptAssembly(input: { }; const promptBuildMessages = pruneProcessedHistoryImages(input.activeSession.messages) ?? input.activeSession.messages; + const promptEvent = { prompt: attempt.prompt, messages: promptBuildMessages }; const hookResult = input.isRawModelRun || isSettledTurnFinalization ? undefined @@ -171,6 +173,23 @@ export async function prepareEmbeddedAttemptPromptAssembly(input: { bootstrapContextRunKind: attempt.bootstrapContextRunKind, }); const promptCacheToolNames = input.applyPromptBuildToolsAllow(hookResult?.toolsAllow); + const hookRunner = input.hookRunner; + const assertHostActive = resolveAdmittedRunActiveAssertion( + attempt.admittedRunContext, + attempt.abortSignal, + ); + const authorizedHookResult = + input.isRawModelRun || + isSettledTurnFinalization || + !hookRunner || + !attempt.toolAuthorityFingerprint || + !assertHostActive + ? undefined + : await hookRunner.runAuthorizedPromptBuild(promptEvent, hookCtx, { + toolAuthorityFingerprint: attempt.toolAuthorityFingerprint, + activeToolNames: promptCacheToolNames, + assertHostActive, + }); const promptCacheToolNameSet = new Set(promptCacheToolNames.map(normalizeToolPolicyName)); const promptBeforeResolvedToolFinalization = effectivePrompt; effectivePrompt = applyResolvedToolPromptFinalizer({ @@ -186,18 +205,26 @@ export async function prepareEmbeddedAttemptPromptAssembly(input: { promptCacheToolNameSet.has(normalizeToolPolicyName(tool.name)), ); const promptBeforePromptBuildHooks = effectivePrompt; - const promptBuildPrependContext = hookResult?.prependContext; - const promptBuildAppendContext = hookResult?.appendContext; + const joinHookContext = (...values: Array) => + values.filter((value): value is string => Boolean(value?.trim())).join("\n\n") || undefined; + const promptBuildPrependContext = joinHookContext( + hookResult?.prependContext, + authorizedHookResult?.prependContext, + ); + const promptBuildAppendContext = joinHookContext( + hookResult?.appendContext, + authorizedHookResult?.appendContext, + ); const hasPromptBuildContext = Boolean(promptBuildPrependContext?.trim()) || Boolean(promptBuildAppendContext?.trim()); - if (hookResult?.prependContext) { - effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`; - log.debug(`hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`); + if (promptBuildPrependContext) { + effectivePrompt = `${promptBuildPrependContext}\n\n${effectivePrompt}`; + log.debug(`hooks: prepended context to prompt (${promptBuildPrependContext.length} chars)`); } - if (hookResult?.appendContext) { - effectivePrompt = `${effectivePrompt}\n\n${hookResult.appendContext}`; - log.debug(`hooks: appended context to prompt (${hookResult.appendContext.length} chars)`); + if (promptBuildAppendContext) { + effectivePrompt = `${effectivePrompt}\n\n${promptBuildAppendContext}`; + log.debug(`hooks: appended context to prompt (${promptBuildAppendContext.length} chars)`); } const legacySystemPrompt = normalizeOptionalString(hookResult?.systemPrompt) ?? ""; if (legacySystemPrompt) { diff --git a/src/agents/harness/prompt-compaction-hook-helpers.test.ts b/src/agents/harness/prompt-compaction-hook-helpers.test.ts index eac0118437be..6f2cf367f193 100644 --- a/src/agents/harness/prompt-compaction-hook-helpers.test.ts +++ b/src/agents/harness/prompt-compaction-hook-helpers.test.ts @@ -3,6 +3,7 @@ import { initializeGlobalHookRunner, resetGlobalHookRunner, } from "../../plugins/hook-runner-global.js"; +import type { PluginHookAgentContext } from "../../plugins/hook-types.js"; import { createMockPluginRegistry } from "../../plugins/hooks.test-fixtures.js"; import { resolveAgentHarnessBeforePromptBuildResult } from "./prompt-compaction-hook-helpers.js"; @@ -112,6 +113,54 @@ describe("resolveAgentHarnessBeforePromptBuildResult", () => { expect(result.prompt).toBe("heartbeat context\n\nprompt context\n\nhello"); }); + it("runs authorized enrichment after restrictive hooks finalize the tool surface", async () => { + const calls: string[] = []; + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_prompt_build", + handler: () => { + calls.push("restrict"); + return { prependContext: "regular context", toolsAllow: ["message"] }; + }, + }, + { + hookName: "before_prompt_build", + requiresToolAuthority: true, + handler: (_event, ctx) => { + calls.push("enrich"); + expect((ctx as PluginHookAgentContext).toolAuthority?.allows("memory_search")).toBe( + false, + ); + return { prependContext: "authorized context" }; + }, + }, + ]), + ); + let activeToolNames: string[] = []; + + const result = await resolveAgentHarnessBeforePromptBuildResult({ + prompt: "hello", + developerInstructions: { + build: ({ toolsAllow }) => { + calls.push("build"); + activeToolNames = toolsAllow ?? []; + return "base instructions"; + }, + }, + messages: [], + ctx: {}, + toolAuthority: { + fingerprint: "turn-authority", + activeToolNames: () => activeToolNames, + assertActive: () => undefined, + }, + }); + + expect(calls).toEqual(["restrict", "build", "enrich"]); + expect(result.prompt).toBe("regular context\n\nauthorized context\n\nhello"); + }); + it("skips heartbeat_prompt_contribution off a heartbeat turn", async () => { const handler = vi.fn(() => ({ prependContext: "should not appear" })); initializeGlobalHookRunner( diff --git a/src/agents/harness/prompt-compaction-hook-helpers.ts b/src/agents/harness/prompt-compaction-hook-helpers.ts index 887f0e4fc8f1..42bc406b96fd 100644 --- a/src/agents/harness/prompt-compaction-hook-helpers.ts +++ b/src/agents/harness/prompt-compaction-hook-helpers.ts @@ -36,6 +36,11 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { messages: unknown[]; ctx: AgentHarnessHookContext; bootstrapContextRunKind?: BootstrapContextRunKind; + toolAuthority?: { + fingerprint?: string; + activeToolNames: () => readonly string[]; + assertActive: () => void; + }; }): Promise { const hookRunner = getGlobalHookRunner(); // heartbeat_prompt_contribution fires only on heartbeat turns. Harness runtimes @@ -45,7 +50,8 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { const isHeartbeatTurn = params.ctx.trigger === "heartbeat"; const hasHeartbeatContribution = isHeartbeatTurn && Boolean(hookRunner?.hasHooks("heartbeat_prompt_contribution")); - if (!hasHeartbeatContribution && !hookRunner?.hasHooks("before_prompt_build")) { + const hasPromptBuildHooks = Boolean(hookRunner?.hasHooks("before_prompt_build")); + if (!hasHeartbeatContribution && !hasPromptBuildHooks) { const developerInstructions = resolveDeveloperInstructions(params.developerInstructions); return { prompt: params.prompt, @@ -78,16 +84,32 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { }) : undefined; - const promptBuildResult = hookRunner?.hasHooks("before_prompt_build") - ? await hookRunner.runBeforePromptBuild(promptEvent, hookCtx).catch((error: unknown) => { - log.warn(`before_prompt_build hook failed: ${String(error)}`); - return undefined; - }) - : undefined; + const promptBuildResult = + hookRunner && hasPromptBuildHooks + ? await hookRunner.runBeforePromptBuild(promptEvent, hookCtx).catch((error: unknown) => { + log.warn(`before_prompt_build hook failed: ${String(error)}`); + return undefined; + }) + : undefined; const developerInstructions = resolveDeveloperInstructions( params.developerInstructions, promptBuildResult?.toolsAllow, ); + const toolAuthority = params.toolAuthority; + const toolAuthorityFingerprint = toolAuthority?.fingerprint?.trim(); + const authorizedPromptBuildResult = + hookRunner && toolAuthorityFingerprint && toolAuthority + ? await hookRunner + .runAuthorizedPromptBuild(promptEvent, hookCtx, { + toolAuthorityFingerprint, + activeToolNames: toolAuthority.activeToolNames(), + assertHostActive: toolAuthority.assertActive, + }) + .catch((error: unknown) => { + log.warn(`authorized before_prompt_build hook failed: ${String(error)}`); + return undefined; + }) + : undefined; const systemPrompt = resolvePromptBuildSystemPrompt({ developerInstructions, promptBuildResult, @@ -95,10 +117,12 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { const promptPrefix = joinPresentTextSegments([ heartbeatResult?.prependContext, promptBuildResult?.prependContext, + authorizedPromptBuildResult?.prependContext, ]); const promptSuffix = joinPresentTextSegments([ heartbeatResult?.appendContext, promptBuildResult?.appendContext, + authorizedPromptBuildResult?.appendContext, ]); const prompt = joinPresentTextSegments([promptPrefix, params.prompt, promptSuffix]) ?? params.prompt; diff --git a/src/plugin-sdk/plugin-entry.reply-trigger.test.ts b/src/plugin-sdk/plugin-entry.reply-trigger.test.ts index 146dced85a37..939bf236001d 100644 --- a/src/plugin-sdk/plugin-entry.reply-trigger.test.ts +++ b/src/plugin-sdk/plugin-entry.reply-trigger.test.ts @@ -11,10 +11,29 @@ function registerScopedReplyHook(api: OpenClawPluginApi): void { api.on("before_agent_reply", async () => undefined, { eligibleTriggers: [] }); } -void registerScopedReplyHook; +function registerAuthorizedPromptHook(api: OpenClawPluginApi): void { + api.on( + "before_prompt_build", + async (_event, ctx) => { + const authority = ctx.toolAuthority; + if (!authority?.allows("memory_search")) { + return undefined; + } + authority.assertActive(); + return { prependContext: `authority:${authority.fingerprint}` }; + }, + { requiresToolAuthority: true }, + ); -describe("plugin-entry reply trigger contract", () => { - it("exposes the scoped option through the public plugin API", () => { + // @ts-expect-error Tool authority is only supported for before_prompt_build. + api.on("before_tool_call", async () => undefined, { requiresToolAuthority: true }); +} + +void registerScopedReplyHook; +void registerAuthorizedPromptHook; + +describe("plugin entry hook option contracts", () => { + it("exposes scoped reply and prompt authority options through the public plugin API", () => { expectTypeOf().toBeFunction(); expectTypeOf().toEqualTypeOf<"cron" | "heartbeat" | "user">(); expectTypeOf().toEqualTypeOf<{ diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 12828f9d0779..a991e1eb2f66 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -29,7 +29,7 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_FILES = [ ] as const; const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = { "extensions/acpx/index.ts": ["reply_dispatch"], - "extensions/active-memory/index.ts": ["agent_end", "before_model_resolve", "before_prompt_build"], + "extensions/active-memory/index.ts": ["agent_end", "before_prompt_build"], "extensions/clickclack/src/discussions/register.ts": ["before_tool_call"], "extensions/codex/index.ts": ["after_compaction", "inbound_claim", "session_end"], "extensions/diffs/src/plugin.ts": ["before_prompt_build"], diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 0580fc7d98b4..ef1cba4d0629 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -262,7 +262,22 @@ export type PluginHookRegistrationOptions = { : { eligibleTriggers?: never }) & (K extends "before_tool_call" | "after_tool_call" ? { matcher?: PluginToolMatcher } - : { matcher?: never }); + : { matcher?: never }) & + (K extends "before_prompt_build" + ? { + /** Run only after the host has finalized the turn's policy-filtered tool surface. */ + requiresToolAuthority?: true; + } + : { requiresToolAuthority?: never }); + +export type PluginHookToolAuthority = { + /** Opaque host fingerprint for the exact turn, route, policy, and active tool surface. */ + readonly fingerprint: string; + /** Checks whether the finalized turn surface contains this exact tool. */ + allows(toolName: string): boolean; + /** Rejects retained or timed-out capabilities after the host dispatch closes. */ + assertActive(): void; +}; export type PluginHookAgentContext = { runId?: string; @@ -300,6 +315,8 @@ export type PluginHookAgentContext = { senderExternalId?: string; /** Channel-owned sender/chat details. Plugins may augment the nested interfaces. */ channelContext?: PluginHookChannelContext; + /** Present only for post-policy prompt enrichment hooks that requested tool authority. */ + toolAuthority?: PluginHookToolAuthority; }; export type PluginHookContextWindowSource = @@ -1348,6 +1365,7 @@ export type PluginHookRegistration = priority?: number; timeoutMs?: number; eligibleTriggers?: readonly PluginHookAgentTrigger[]; + requiresToolAuthority?: true; source: string; }; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugins/hooks.phase-hooks.test.ts b/src/plugins/hooks.phase-hooks.test.ts index 082bec295c3a..8d133838f299 100644 --- a/src/plugins/hooks.phase-hooks.test.ts +++ b/src/plugins/hooks.phase-hooks.test.ts @@ -1,5 +1,5 @@ /** Tests phase-scoped plugin hooks and hook registration ordering. */ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { applyEmbeddedAttemptToolsAllow } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; import { readToolAllowlistIntersection } from "../agents/tool-policy.js"; import { createHookRunner } from "./hooks.js"; @@ -242,4 +242,135 @@ describe("phase hooks merger", () => { ), ).toEqual([{ name: "web_search" }]); }); + + it("dispatches authorized enrichment only after the host supplies the final tool surface", async () => { + const enrichment = vi.fn((_event, ctx) => { + expect(ctx.toolAuthority?.allows("memory_search")).toBe(false); + expect(ctx.toolAuthority?.allows("message")).toBe(true); + return { prependContext: "authorized context", systemPrompt: "ignored override" }; + }); + registry.typedHooks.push( + { + pluginId: "restrictor", + hookName: "before_prompt_build", + handler: () => ({ toolsAllow: ["message"] }), + source: "test", + }, + { + pluginId: "enricher", + hookName: "before_prompt_build", + handler: enrichment, + requiresToolAuthority: true, + source: "test", + }, + ); + const runner = createHookRunner(registry); + const event = { prompt: "test", messages: [] }; + + await expect(runner.runBeforePromptBuild(event, {})).resolves.toMatchObject({ + toolsAllow: ["message"], + }); + expect(enrichment).not.toHaveBeenCalled(); + + const result = await runner.runAuthorizedPromptBuild( + event, + {}, + { + toolAuthorityFingerprint: "turn-authority", + activeToolNames: ["message"], + assertHostActive: () => undefined, + }, + ); + const retainedAuthority = enrichment.mock.calls[0]?.[1].toolAuthority; + + expect(result).toEqual({ prependContext: "authorized context" }); + expect(() => retainedAuthority?.assertActive()).toThrow("no longer active"); + }); + + it("rejects enrichment that finishes after the host authority closes", async () => { + let releaseEnrichment: () => void = () => { + throw new Error("enrichment gate was not initialized"); + }; + const enrichmentGate = new Promise((resolve) => { + releaseEnrichment = resolve; + }); + const enrichment = vi.fn(async () => { + await enrichmentGate; + return { prependContext: "stale authorized context" }; + }); + registry.typedHooks.push({ + pluginId: "enricher", + hookName: "before_prompt_build", + handler: enrichment, + requiresToolAuthority: true, + source: "test", + }); + const runner = createHookRunner(registry); + let hostActive = true; + const run = runner.runAuthorizedPromptBuild( + { prompt: "test", messages: [] }, + {}, + { + toolAuthorityFingerprint: "turn-authority", + activeToolNames: ["memory_search"], + assertHostActive: () => { + if (!hostActive) { + throw new Error("host turn authority is no longer active"); + } + }, + }, + ); + await vi.waitFor(() => { + expect(enrichment).toHaveBeenCalledOnce(); + }); + + hostActive = false; + releaseEnrichment(); + + await expect(run).rejects.toThrow("host turn authority is no longer active"); + }); + + it("does not start a later authorized handler after host authority closes", async () => { + let hostActive = true; + const firstEnrichment = vi.fn(async () => { + hostActive = false; + return { prependContext: "first context" }; + }); + const laterEnrichment = vi.fn(() => ({ prependContext: "stale later context" })); + registry.typedHooks.push( + { + pluginId: "first-enricher", + hookName: "before_prompt_build", + handler: firstEnrichment, + requiresToolAuthority: true, + source: "test", + }, + { + pluginId: "later-enricher", + hookName: "before_prompt_build", + handler: laterEnrichment, + requiresToolAuthority: true, + source: "test", + }, + ); + const runner = createHookRunner(registry); + + await expect( + runner.runAuthorizedPromptBuild( + { prompt: "test", messages: [] }, + {}, + { + toolAuthorityFingerprint: "turn-authority", + activeToolNames: ["memory_search"], + assertHostActive: () => { + if (!hostActive) { + throw new Error("host turn authority is no longer active"); + } + }, + }, + ), + ).rejects.toThrow("host turn authority is no longer active"); + expect(firstEnrichment).toHaveBeenCalledOnce(); + expect(laterEnrichment).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/hooks.test-fixtures.ts b/src/plugins/hooks.test-fixtures.ts index 59fd7430f14e..0a8b6bf83b97 100644 --- a/src/plugins/hooks.test-fixtures.ts +++ b/src/plugins/hooks.test-fixtures.ts @@ -57,6 +57,7 @@ export function createHookRunnerWithRegistry( priority?: number; timeoutMs?: number; eligibleTriggers?: readonly PluginHookAgentTrigger[]; + requiresToolAuthority?: true; }>, options?: Parameters[1], ) { diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 8db44ed10327..b15fd5075403 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -15,6 +15,7 @@ export function createMockPluginRegistry( registrationId?: string; timeoutMs?: number; eligibleTriggers?: readonly PluginHookAgentTrigger[]; + requiresToolAuthority?: true; }>, ): PluginRegistry { const pluginIds = @@ -41,6 +42,7 @@ export function createMockPluginRegistry( ...(h.registrationId ? { registrationId: h.registrationId } : {}), ...(h.timeoutMs !== undefined ? { timeoutMs: h.timeoutMs } : {}), ...(h.eligibleTriggers !== undefined ? { eligibleTriggers: h.eligibleTriggers } : {}), + ...(h.requiresToolAuthority ? { requiresToolAuthority: true } : {}), source: "test", })) as PluginRegistry["typedHooks"], }; @@ -55,6 +57,7 @@ export function addTestHook(params: { registrationId?: string; timeoutMs?: number; eligibleTriggers?: readonly PluginHookAgentTrigger[]; + requiresToolAuthority?: true; }) { params.registry.typedHooks.push({ pluginId: params.pluginId, @@ -65,6 +68,7 @@ export function addTestHook(params: { ...(params.registrationId ? { registrationId: params.registrationId } : {}), ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}), ...(params.eligibleTriggers !== undefined ? { eligibleTriggers: params.eligibleTriggers } : {}), + ...(params.requiresToolAuthority ? { requiresToolAuthority: true } : {}), source: "test", } as PluginHookRegistration); } diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index ede16de0af57..69f31e1efb58 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -6,6 +6,7 @@ */ import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; import { clampPositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { isPromiseLike } from "@openclaw/normalization-core/promise-like"; import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js"; @@ -94,6 +95,7 @@ import type { PluginHookToolResultPersistContext, PluginHookToolResultPersistEvent, PluginHookToolResultPersistResult, + PluginHookToolAuthority, PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, PluginHookBeforeInstallContext, @@ -223,6 +225,8 @@ type ModifyingHookPolicy = { shouldStop?: (result: TResult) => boolean; terminalLabel?: string; onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void; + includeRegistration?: (registration: PluginHookRegistration) => boolean; + assertHandlerBoundaryActive?: () => void; }; type PluginTargetedInboundClaimOutcome = @@ -713,15 +717,20 @@ export function createHookRunner( matcherToolName?: string, ): Promise { const hooks = getHooksForName(registry, hookName, undefined, matcherToolName); - if (hooks.length === 0) { + const selectedHooks = policy.includeRegistration + ? hooks.filter(policy.includeRegistration) + : hooks; + if (selectedHooks.length === 0) { return undefined; } - logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, sequential)`); + logger?.debug?.(`[hooks] running ${hookName} (${selectedHooks.length} handlers, sequential)`); let result: TResult | undefined; - for (const hook of hooks) { + for (const hook of selectedHooks) { + policy.assertHandlerBoundaryActive?.(); + let shouldStop = false; try { const handler = hook.handler as (event: unknown, ctx: unknown) => Promise; const handlerEvent = policy.isolateEventPerHandler @@ -746,7 +755,7 @@ export function createHookRunner( `[hooks] ${hookName}${terminalLabel} decided by ${hook.pluginId} (priority=${priority}); skipping remaining handlers`, ); policy.onTerminal?.({ hookName, pluginId: hook.pluginId, result }); - break; + shouldStop = true; } } } catch (err) { @@ -755,6 +764,10 @@ export function createHookRunner( } handleHookError({ hookName, pluginId: hook.pluginId, error: err }); } + policy.assertHandlerBoundaryActive?.(); + if (shouldStop) { + break; + } } return result; @@ -934,7 +947,10 @@ export function createHookRunner( "before_prompt_build", event, ctx, - { mergeResults: mergeBeforePromptBuild }, + { + mergeResults: mergeBeforePromptBuild, + includeRegistration: (registration) => registration.requiresToolAuthority !== true, + }, ); } finally { token.active = false; @@ -942,6 +958,69 @@ export function createHookRunner( }); } + /** Runs context enrichment only after the host has finalized the turn's tool surface. */ + async function runAuthorizedPromptBuild( + event: PluginHookBeforePromptBuildEvent, + ctx: PluginHookAgentContext, + params: { + toolAuthorityFingerprint: string; + activeToolNames: readonly string[]; + assertHostActive: () => void; + }, + ): Promise { + const sourceFingerprint = params.toolAuthorityFingerprint.trim(); + if (!sourceFingerprint) { + return undefined; + } + const activeToolNames = [ + ...new Set(params.activeToolNames.map(normalizeToolPolicyName).filter(Boolean)), + ].toSorted(); + const activeToolNameSet = new Set(activeToolNames); + const token = { active: true }; + const assertActive = () => { + if (!token.active) { + throw new Error("prompt tool authority is no longer active"); + } + params.assertHostActive(); + }; + const authority: PluginHookToolAuthority = Object.freeze({ + fingerprint: createHash("sha256") + .update(sourceFingerprint) + .update("\0") + .update(activeToolNames.join("\0")) + .digest("hex"), + allows(toolName: string): boolean { + assertActive(); + return activeToolNameSet.has(normalizeToolPolicyName(toolName)); + }, + assertActive, + }); + try { + const result = await runModifyingHook< + "before_prompt_build", + PluginHookBeforePromptBuildResult + >( + "before_prompt_build", + event, + { ...ctx, toolAuthority: authority }, + { + mergeResults: mergeBeforePromptBuild, + includeRegistration: (registration) => registration.requiresToolAuthority === true, + assertHandlerBoundaryActive: assertActive, + }, + ); + if (!result) { + return undefined; + } + return { + ...(result.prependContext ? { prependContext: result.prependContext } : {}), + ...(result.appendContext ? { appendContext: result.appendContext } : {}), + }; + } finally { + token.active = false; + } + } + async function runAgentTurnPrepare( event: PluginAgentTurnPrepareEvent, ctx: PluginHookAgentContext, @@ -1585,6 +1664,7 @@ export function createHookRunner( runBeforeModelResolve, runAgentTurnPrepare, runBeforePromptBuild, + runAuthorizedPromptBuild, runBeforeAgentReply, runModelCallStarted: async ( event: PluginHookModelCallStartedEvent, diff --git a/src/plugins/registry-contribution-types.ts b/src/plugins/registry-contribution-types.ts index e177c65654f3..d9c4bc8b08a3 100644 --- a/src/plugins/registry-contribution-types.ts +++ b/src/plugins/registry-contribution-types.ts @@ -301,6 +301,10 @@ export type MemoryPluginCapability = { flushPlanResolver?: MemoryFlushPlanResolver; runtime?: MemoryPluginRuntime; publicArtifacts?: MemoryPluginPublicArtifactsProvider; + /** Local deterministic recall tool required by provider-owned direct lookup. */ + deterministicRecallToolName?: string; + /** Whether recall may read protected same-agent private session transcripts. */ + supportsPrivateTranscriptRecall?: boolean; }; export type MemoryPluginCapabilityRegistration = { diff --git a/src/plugins/registry-registrars-tools-hooks.ts b/src/plugins/registry-registrars-tools-hooks.ts index 88344d89b93d..226feb839998 100644 --- a/src/plugins/registry-registrars-tools-hooks.ts +++ b/src/plugins/registry-registrars-tools-hooks.ts @@ -453,6 +453,9 @@ export function createToolHookRegistrars(state: PluginRegistryState) { priority: opts?.priority, ...(timeoutMs !== undefined ? { timeoutMs } : {}), ...(eligibleTriggers ? { eligibleTriggers } : {}), + ...(hookName === "before_prompt_build" && opts?.requiresToolAuthority === true + ? { requiresToolAuthority: true } + : {}), source: record.source, } as TypedPluginHookRegistration); }; diff --git a/test/scripts/type-suppression-inventory.test.ts b/test/scripts/type-suppression-inventory.test.ts index f6417327b57f..064bde127a34 100644 --- a/test/scripts/type-suppression-inventory.test.ts +++ b/test/scripts/type-suppression-inventory.test.ts @@ -78,6 +78,7 @@ describe("type suppression inventory", () => { "src/infra/kysely-sync.types.test.ts:61:@ts-expect-error Kysely checks order references and selected aliases.", "src/plugin-sdk/plugin-entry.reply-trigger.test.ts:8:@ts-expect-error Trigger eligibility is only supported for before_agent_reply.", "src/plugin-sdk/plugin-entry.reply-trigger.test.ts:10:@ts-expect-error An empty trigger list cannot prove that a hook is inactive.", + "src/plugin-sdk/plugin-entry.reply-trigger.test.ts:28:@ts-expect-error Tool authority is only supported for before_prompt_build.", ]); }); });