mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
+73
-17
@@ -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`
|
||||
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/memory-host-core")>(
|
||||
"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<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
|
||||
"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<void>((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<void>((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<void>((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<void>((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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ActiveRecallResult> {
|
||||
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<Act
|
||||
if (!runId) {
|
||||
return await resolveActiveRecall(recallParams);
|
||||
}
|
||||
return await resolveActiveRecallForRun(runId, (onTimeoutCleanup) =>
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, TriggerRecallPrewarmEntry>();
|
||||
const triggerRecallRuns = new Map<string, TriggerRecallRunEntry>();
|
||||
|
||||
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<void> {
|
||||
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<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -288,6 +288,8 @@ export default definePluginEntry({
|
||||
registerShortTermPromotionDreaming(api);
|
||||
registerSessionBackfillGatewayMethods(api);
|
||||
api.registerMemoryCapability({
|
||||
deterministicRecallToolName: "memory_search",
|
||||
supportsPrivateTranscriptRecall: true,
|
||||
promptBuilder: buildPromptSection,
|
||||
flushPlanResolver: buildMemoryFlushPlan,
|
||||
runtime: memoryRuntime,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | undefined>) =>
|
||||
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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -36,6 +36,11 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: {
|
||||
messages: unknown[];
|
||||
ctx: AgentHarnessHookContext;
|
||||
bootstrapContextRunKind?: BootstrapContextRunKind;
|
||||
toolAuthority?: {
|
||||
fingerprint?: string;
|
||||
activeToolNames: () => readonly string[];
|
||||
assertActive: () => void;
|
||||
};
|
||||
}): Promise<AgentHarnessPromptBuildResult> {
|
||||
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;
|
||||
|
||||
@@ -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<OpenClawPluginApi["on"]>().toBeFunction();
|
||||
expectTypeOf<PluginHookAgentTrigger>().toEqualTypeOf<"cron" | "heartbeat" | "user">();
|
||||
expectTypeOf<WorkerMachineOption>().toEqualTypeOf<{
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -262,7 +262,22 @@ export type PluginHookRegistrationOptions<K extends PluginHookName> = {
|
||||
: { 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<K extends PluginHookName = PluginHookName> =
|
||||
priority?: number;
|
||||
timeoutMs?: number;
|
||||
eligibleTriggers?: readonly PluginHookAgentTrigger[];
|
||||
requiresToolAuthority?: true;
|
||||
source: string;
|
||||
};
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -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<void>((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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createHookRunnerWithRegistry(
|
||||
priority?: number;
|
||||
timeoutMs?: number;
|
||||
eligibleTriggers?: readonly PluginHookAgentTrigger[];
|
||||
requiresToolAuthority?: true;
|
||||
}>,
|
||||
options?: Parameters<typeof createHookRunner>[1],
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+85
-5
@@ -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<K extends PluginHookName, TResult> = {
|
||||
shouldStop?: (result: TResult) => boolean;
|
||||
terminalLabel?: string;
|
||||
onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void;
|
||||
includeRegistration?: (registration: PluginHookRegistration<K>) => boolean;
|
||||
assertHandlerBoundaryActive?: () => void;
|
||||
};
|
||||
|
||||
type PluginTargetedInboundClaimOutcome =
|
||||
@@ -713,15 +717,20 @@ export function createHookRunner(
|
||||
matcherToolName?: string,
|
||||
): Promise<TResult | undefined> {
|
||||
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<TResult>;
|
||||
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<PluginHookBeforePromptBuildResult | undefined> {
|
||||
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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user