diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index fd90f3f505..f9f0d92f18 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -85,6 +85,23 @@ async function isMemoryToolUsable(req, writePermissions = []) { } } +/** + * Whether the agent actually declared the requested inline memory tool. The + * event-driven executor loads tools by requested name, so a hallucinated or + * undeclared `set_memory`/`delete_memory` call (e.g. on an agent that never + * opted into the memory capability) must not be constructed. + * @param {{ toolDefinitions?: Array<{ name?: string }>, tools?: Array }} [agent] + * @param {string} toolName + * @returns {boolean} + */ +function agentDeclaredMemoryTool(agent, toolName) { + if (!agent) { + return false; + } + const declared = (entry) => (typeof entry === 'string' ? entry : entry?.name) === toolName; + return (agent.toolDefinitions ?? []).some(declared) || (agent.tools ?? []).some(declared); +} + /** * Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values. * Tools without required authentication or with valid authentication are considered valid. @@ -397,7 +414,10 @@ const loadTools = async ({ const validKeys = memoryConfig?.validKeys; const tokenLimit = memoryConfig?.tokenLimit; requestedTools[tool] = async () => { - if (!(await isMemoryToolUsable(options.req, [Permissions.CREATE, Permissions.UPDATE]))) { + if ( + !agentDeclaredMemoryTool(agent, SET_MEMORY_TOOL_NAME) || + !(await isMemoryToolUsable(options.req, [Permissions.CREATE, Permissions.UPDATE])) + ) { return null; } let totalTokens = 0; @@ -407,6 +427,9 @@ const loadTools = async ({ totalTokens = formatted?.totalTokens ?? 0; } catch (error) { logger.error('[handleTools] Failed to load memory token count for set_memory', error); + /** Fail closed: without the current usage total a configured + * tokenLimit could be silently bypassed. */ + return null; } } return createMemoryTool({ userId: user, setMemory, validKeys, tokenLimit, totalTokens }); @@ -415,7 +438,10 @@ const loadTools = async ({ } else if (tool === DELETE_MEMORY_TOOL_NAME) { const memoryConfig = options.req?.config?.memory; requestedTools[tool] = async () => { - if (!(await isMemoryToolUsable(options.req, [Permissions.UPDATE]))) { + if ( + !agentDeclaredMemoryTool(agent, DELETE_MEMORY_TOOL_NAME) || + !(await isMemoryToolUsable(options.req, [Permissions.UPDATE])) + ) { return null; } return createDeleteMemoryTool({ diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 7bda67c545..530901e691 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -153,9 +153,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const skillDbMethods = getSkillDbMethods(); /** Run-level gate for inline memory tools: the `memory` capability must be - * enabled, memory must be configured, and the user must not have opted out - * or lost the `MEMORIES.USE` permission. Agents (or the ephemeral memory - * badge) opt in per-agent via the `memory` marker on `tools`. */ + * enabled, memory must be configured, and the user must not have opted out. + * Requires the memory WRITE permissions (CREATE + UPDATE) — both inline tools + * mutate memory — so the tools aren't registered (and shown to the model) for + * read-only-memory roles that the runtime loader would then refuse to build. + * Agents (or the ephemeral memory badge) opt in per-agent via the `memory` + * marker on `tools`. */ const memoryAvailable = enabledCapabilities.has(AgentCapabilities.memory) && isMemoryEnabled(appConfig?.memory) && @@ -163,7 +166,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { (await checkAccess({ user: req.user, permissionType: PermissionTypes.MEMORIES, - permissions: [Permissions.USE], + permissions: [Permissions.USE, Permissions.CREATE, Permissions.UPDATE], getRoleByName: db.getRoleByName, }));