diff --git a/changes/525.fixed.md b/changes/525.fixed.md new file mode 100644 index 000000000..8f66b1d4b --- /dev/null +++ b/changes/525.fixed.md @@ -0,0 +1,3 @@ +**Incognito chats without memory keep memories out of helpers too.** Before, a helper or the reader started from such +a chat could still be handed the memory and past-chat search tools. Now nothing a chat starts gets more than the chat +itself: no memory tools, no memories and not your name. diff --git a/docs/API.md b/docs/API.md index 0ff5dd36a..0fe40ef0e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -405,13 +405,14 @@ accept or loosen a rule. - nothing is learned from it: no fact extraction after a turn, no memory flush when it is compressed, and no `chat.turn_completed` event, so skill review and repair, the user model and dreaming never see it. Its skill views don't count as skill use. - - `memory_remember` and `skill_save` are not offered, and refuse with an error when called in it (also from a - helper it started). Nothing is stored, not even held for review. + - `memory_remember` and `skill_save` are not offered to it or to a helper it started, and refuse with an error + when called anyway. Nothing is stored, not even held for review. - keyword search (`GET /api/sessions/search`, `memory_search_history`), conversation summaries, `history_time_search`, the user model's evidence and skill review leave it out. - reading memory follows `chat.incognito_uses_memory` (default `true`): recalled facts, user-model insights and the memory tools work as usual, and the reply's `memory_sources` list them. Off, its prompt has no profile, - memory file, daily notes, facts or insights, and the memory look-up tools are not offered and return nothing. + memory file, daily notes, facts or insights, and the memory tools are not offered and refuse to run, also in the + helpers, reader and scripts it starts (section 10, "Turns without memory"). The prompt also tells the model the chat is incognito. - it is deleted: the window deletes it when the user leaves it; the engine deletes every incognito chat at start and shutdown (after replies and background work have finished), and one with no new message for @@ -2705,8 +2706,8 @@ Every new tool declares a `Risk`; approvals behave as in section 1. content and a `text`, `email` or `url` value came back (the run is then marked, ADR 0018); a result of only dates, numbers, yes or no, ids and choices does not mark it. Refused (`{error}`) for tools that aren't look-ups, that could send data out, whose rule is Ask, Only if I asked or Hand it to me, that are hidden, the subagent tools, and - an address that could carry data to a site a marked run hasn't visited. Tool selection offers it with any tool - that reads outside content. + an address that could carry data to a site a marked run hasn't visited, and, in a turn without memory (below), the + memory and history tools. Tool selection offers it with any tool that reads outside content. - **Subagent** `{subagent_id, session_id, parent_call_id, goal, kind: "worker"|"reader", status: "running"|"completed"|"error"|"cancelled", background, summary, error, tool_calls, started_at, finished_at, events: [ProgressUpdate]}`. A reader (`kind: "reader"`) has `tool_calls: 0`, `goal` `"Read for: "`, `summary` the JSON of the fields returned, and two events: `info` (the fields asked for, with their kinds) and `final_answer` (the fields @@ -2716,6 +2717,14 @@ Every new tool declares a `Risk`; approvals behave as in section 1. - While a foreground subagent runs, the parent emits `tool_progress` with `kind: "subagent"` and `data: {subagent_id, message}`. - Subagents cannot spawn subagents, use the voice role, or run tools of effective risk `send` or `exec`; those calls are denied with an explanation the parent can relay. +- **Turns without memory** (#525). Each chat turn decides once whether it may use what Sentient knows about the user + (`Agent.memory_allowed(session, channel)`: false in an incognito chat while `chat.incognito_uses_memory` is off) + and carries the answer in `ToolContext.memory_allowed`. Everything the turn starts copies it: helpers + (`delegate_task`, `delegate_tasks`, background ones too), the reader and `execute_code` scripts. Without memory, + the memory tools (`memory_recall`, `memory_search_by_source`, `memory_search_history`, `history_semantic_search`, + `history_time_search`, `user_model_ask`, `memory_forget`) are not offered by `run_loop` whoever built the list, + are not listed to scripts, and refuse to run (`{error}`) however they are called. A helper's prompt then leaves out + the user's name too. - Limits: `subagents.max_rounds` steps (default 24), `subagents.timeout_minutes` (20), and on cloud models `subagents.max_tokens` (1000000) and `subagents.max_cost_usd` (2.0, when the price is known; `0` = no limit). The repeated-call breaker applies too. Reaching one ends the subagent with `status: "error"` and a plain `error` diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index 992d4e9b3..21954ae9e 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -109,6 +109,7 @@ describe_call, effective_risk, is_unprompted, + without_memory_tools, ) from sentient.tools.builtin.time_tool import resolve_tz from sentient.tools.registry import ToolRegistry @@ -191,12 +192,9 @@ "Do not call it again with these arguments. Try something different, or answer the user with what you have." ) # Incognito chats (#154): tools that would keep something from the chat are never offered or run, and the model is -# told so it does not promise to remember. Without ``chat.incognito_uses_memory`` the memory look-ups go too. +# told so it does not promise to remember. Without ``chat.incognito_uses_memory`` the memory tools go too: that is the +# turn's ``memory_allowed`` decision (#525). ``run_loop`` leaves both out of every run of the chat, helpers included. INCOGNITO_LEARNING_TOOLS = frozenset({"memory_remember", "skill_save"}) -INCOGNITO_MEMORY_TOOLS = frozenset({ - "memory_recall", "memory_search_by_source", "memory_search_history", "history_semantic_search", - "history_time_search", "user_model_ask", -}) INCOGNITO_NOTE = ( "## Incognito chat\nThis is an incognito chat. Nothing from it is saved to memory, and it is deleted when the " "user closes it. Never say you will remember something from it." @@ -723,15 +721,24 @@ async def _prompt_notes(self, user_text: str, scope: str | None = None) -> list[ async def system_prompt(self, user_text: str, channel: str, session: dict | None = None) -> str: return (await self._system_prompt_with_sources(user_text, channel, session))[0] + def memory_allowed(self, session: dict | None, channel: str) -> bool: + """Whether a chat turn may use what Sentient knows about the user (#525): memories, the profile, the user + model and other chats. Decided once per turn, here only; the turn puts it in ``ToolContext.memory_allowed`` + so its prompt, its tools and everything it starts (helpers, the reader, scripts) follow the same answer. + False in an incognito chat while ``chat.incognito_uses_memory`` is off. Another kind of turn without memory + (an app's turn the user hasn't allowed to read memories, #479) adds its condition here.""" + incognito = bool((session or {}).get("incognito")) + return not incognito or self.config.chat.incognito_uses_memory + async def _system_prompt_with_sources( - self, user_text: str, channel: str, session: dict | None = None + self, user_text: str, channel: str, session: dict | None = None, *, memory_allowed: bool | None = None ) -> tuple[str, MemorySources, list[dict]]: """The system prompt, the memories put into it (recalled facts, user-model insights) and the pieces of the user's notes put into it (#354). An incognito chat (#154) reads memory and notes only while ``chat.incognito_uses_memory`` is on, and is told nothing will be kept. The chat's memory scope (#155) decides which facts are recalled; a work or project chat also leaves out the personal profile files.""" incognito = bool((session or {}).get("incognito")) - reads_memory = not incognito or self.config.chat.incognito_uses_memory + reads_memory = self.memory_allowed(session, channel) if memory_allowed is None else memory_allowed scope = memory_scopes.of_session(self.config, session) personal = memory_scopes.is_personal(scope) if reads_memory: @@ -774,12 +781,6 @@ async def _system_prompt_with_sources( prompt += "\n\n" + (INCOGNITO_NOTE if reads_memory else INCOGNITO_NO_MEMORY_NOTE) return prompt, sources, notes - def _incognito_tools(self, tool_names: list[str] | None) -> list[str]: - """``tool_names`` (None: every offered tool) without the tools an incognito chat must not use (#154).""" - names = tool_names if tool_names is not None else [t.name for t in self.registry.tools()] - left_out = INCOGNITO_LEARNING_TOOLS | (frozenset() if self.config.chat.incognito_uses_memory else INCOGNITO_MEMORY_TOOLS) - return [n for n in names if n not in left_out] - def _read_only_apps(self) -> list[str]: """Names of the connected apps set to Read only that have tools Sentient now can't use (#141), so the model can say why instead of looking for another way.""" @@ -846,6 +847,14 @@ async def run_loop( unprompted = is_unprompted(getattr(ctx, "origin", None)) or is_unprompted(source) if unprompted and not is_unprompted(getattr(ctx, "origin", None)): ctx.origin = str(source).strip().lower() # tools this run starts (a subagent) must see it as unprompted too + if ctx.incognito or not ctx.memory_allowed: + # whoever built the list (a chat turn, a helper, a task), a run is never offered more than its turn + # (#525): nothing that would keep something from an incognito chat (#154), and no memory tools without + # memory. A call made anyway is refused as not offered, and the tools themselves refuse too. + names = tool_names if tool_names is not None else [t.name for t in self.registry.tools()] + if ctx.incognito: + names = [n for n in names if n not in INCOGNITO_LEARNING_TOOLS] + tool_names = names if ctx.memory_allowed else without_memory_tools(names) tools = self.registry.openai_schemas(tool_names) or None result.tool_result_limit = await self.tool_result_limit(role, model) rounds = max_rounds or self.config.models.max_tool_rounds @@ -1899,7 +1908,10 @@ def release() -> list[str]: session = await self.store.get_session(session_id) incognito = bool((session or {}).get("incognito")) - system, sources, notes = await self._system_prompt_with_sources(user_text, channel, session) + memory_allowed = self.memory_allowed(session, channel) # once per turn (#525) + system, sources, notes = await self._system_prompt_with_sources( + user_text, channel, session, memory_allowed=memory_allowed + ) history = await self.store.recent_messages(session_id, self.config.chat.history_window) convo = self.mark_outside_messages(history_to_openai(history)) result.browser_click_history.extend(browser_click_history(history)) @@ -1935,6 +1947,7 @@ def release() -> list[str]: messages: list[dict] = [{"role": "system", "content": system}, *convo] ctx = self.tool_context(session_id, channel) ctx.incognito = incognito + ctx.memory_allowed = memory_allowed # helpers, the reader and scripts it starts copy it (#525) ctx.memory_scope = memory_scopes.of_session(self.config, session) # recall and saving use it (#155) # a turn an MCP app steers is outside content from the start (issue #443): its text is not # the user's words, so it never fills user_words, and the chat is marked before anything @@ -1974,8 +1987,6 @@ def release() -> list[str]: except Exception as exc: # selection must never break a turn log.warning("tool selection failed, offering all tools: %s", exc) tool_names = None - if incognito: - tool_names = self._incognito_tools(tool_names) async def persist(role_: str, content: str | None, **fields: Any) -> None: mid = await self.store.add_message(session_id, role_, content, **fields) @@ -2132,6 +2143,7 @@ async def _quick_turn( session = await self.store.get_session(session_id) ctx = self.tool_context(session_id, channel) ctx.incognito = bool((session or {}).get("incognito")) + ctx.memory_allowed = self.memory_allowed(session, channel) ctx.extra = {**ctx.extra, "quick": True, "quick_language": plan.language.code} results: list[tuple[Any, bool]] = [] checks: list[dict] = [] diff --git a/sentient/agent/subagents.py b/sentient/agent/subagents.py index 0eda8120d..b4b7d1321 100644 --- a/sentient/agent/subagents.py +++ b/sentient/agent/subagents.py @@ -36,7 +36,7 @@ from sentient.llm.jobs import detached from sentient.services import Service, cancel_tasks from sentient.store.db import new_id, now_iso -from sentient.tools.base import Risk, Tool +from sentient.tools.base import MEMORY_TOOLS, Risk, Tool log = logging.getLogger(__name__) @@ -190,11 +190,14 @@ async def delegate( on_update: UpdateFn | None = None, origin: str = "user", untrusted: str = "", + memory_allowed: bool = True, ) -> dict: """Start a subagent. Foreground: waits and returns ``{subagent_id, status, summary, files_created}`` (plus ``error``). Background: returns ``{subagent_id, status: "running"}`` right away. ``origin`` is the parent run's ``ToolContext.origin``: a subagent of work nobody asked for may only read too (ADR 0017). - ``untrusted`` is the parent's ``ToolContext.untrusted``: outside content it read still counts (ADR 0018).""" + ``untrusted`` is the parent's ``ToolContext.untrusted``: outside content it read still counts (ADR 0018). + ``memory_allowed`` is the parent's ``ToolContext.memory_allowed`` (#525): False, the helper gets no memory + tools, no user name and nothing Sentient knows, like the turn that started it.""" cfg = self.app.config.subagents if not cfg.enabled: return {"status": "error", "error": "Subagents are turned off in Settings."} @@ -214,7 +217,8 @@ async def delegate( ) self._publish(await self.get(sub_id, events=False)) task = asyncio.create_task( - self._run(sub_id, goal, context or "", tools, session_id, bool(background), on_update, origin, untrusted), + self._run(sub_id, goal, context or "", tools, session_id, bool(background), on_update, origin, untrusted, + memory_allowed), name=f"subagent:{sub_id}", # a foreground helper is part of whatever waits on it (a chat reply); a background one is task work (#149) context=detached("task") if background else None, @@ -301,8 +305,10 @@ async def delegate_many( on_update: UpdateFn | None = None, origin: str = "user", untrusted: str = "", + memory_allowed: bool = True, ) -> list[dict]: - """Run several foreground subagents; at most ``subagents.max_concurrent`` at a time.""" + """Run several foreground subagents; at most ``subagents.max_concurrent`` at a time. ``origin``, + ``untrusted`` and ``memory_allowed`` are the parent's, as for ``delegate``.""" jobs = [ self.delegate( str(t.get("goal") or ""), @@ -313,6 +319,7 @@ async def delegate_many( on_update=on_update, origin=origin, untrusted=untrusted, + memory_allowed=memory_allowed, ) for t in tasks ] @@ -365,12 +372,16 @@ def check(tool: Tool, risk: Risk, arguments: dict) -> str | None: return check async def _tool_names( - self, goal: str, context: str, tools: list[str] | None, role: str, model: str | None = None + self, goal: str, context: str, tools: list[str] | None, role: str, model: str | None = None, + *, memory_allowed: bool = True, ) -> list[str]: + """The helper's tools: never wider than its parent turn's, so without memory (#525) no memory tools, even + when the goal names them.""" reg = self.app.registry usable = [ t for t in reg.tools() if t.plugin != PLUGIN_ID and not (t.risk >= Risk.send and t.risk_fn is None) + and (memory_allowed or t.name not in MEMORY_TOOLS) ] usable_names = {t.name for t in usable} if tools: @@ -394,7 +405,8 @@ async def _tool_names( return [n for n in selected if n in usable_names] def _system_prompt(self, *, private: bool = False) -> str: - """``private``: started from an incognito chat that doesn't use memory (#154), so the user's name stays out.""" + """``private``: started from a turn without memory (an incognito chat that doesn't use it, #154, #525), so the + user's name stays out.""" from sentient.tools.builtin.time_tool import resolve_tz cfg = self.app.config.assistant @@ -416,6 +428,7 @@ async def _run( on_update: UpdateFn | None, origin: str = "user", untrusted: str = "", + memory_allowed: bool = True, ) -> None: cfg = self.app.config.subagents agent = self.app.agent @@ -437,18 +450,22 @@ def note(message: dict, text: str | None = None) -> None: async with self._slot(): note({"type": "info", "content": f"Started: {goal[:200]}"}, "Started") incognito = await self.app.store.is_incognito(session_id) # kept if the chat is deleted (#154) - private = incognito and not self.app.config.chat.incognito_uses_memory + # never wider than the parent turn (#525); the chat's own setting still holds for a caller that + # didn't pass the parent's decision + allowed = memory_allowed and not (incognito and not self.app.config.chat.incognito_uses_memory) + private = not allowed messages = [ {"role": "system", "content": self._system_prompt(private=private)}, {"role": "user", "content": f"Goal: {goal}" + (f"\n\nContext:\n{context}" if context else "")}, ] ctx = agent.tool_context(session_id, "subagent", origin=origin) ctx.incognito = incognito + ctx.memory_allowed = allowed ctx.untrusted = untrusted ctx.extra["subagent_id"] = sub_id # where it would use the primary model, a helper uses the chat's own model (#312) model = await agent.helper_model(session_id, cfg.role) - names = await self._tool_names(goal, context, tools, cfg.role, model) + names = await self._tool_names(goal, context, tools, cfg.role, model, memory_allowed=allowed) narration = "" async with asyncio.timeout(cfg.timeout_minutes * 60): async for event in agent.run_loop( diff --git a/sentient/sandbox/policy.py b/sentient/sandbox/policy.py index 6920c4686..03be49485 100644 --- a/sentient/sandbox/policy.py +++ b/sentient/sandbox/policy.py @@ -13,6 +13,8 @@ hand anything to the user (its look-ups run as usual). An "allow" rule changes nothing: scripts still only read (ADR 0012, docs/PRIVACY.md). A connection set to Read only (#141) takes away the same way, even with approvals mode ``off``: its tools that can change things are not listed and calls above ``read`` are refused. +A script never gets more than the chat turn that ran it (#525): in a turn without memory the memory and history +tools (``MEMORY_TOOLS``) are not listed and are refused. """ from __future__ import annotations @@ -20,7 +22,14 @@ from collections.abc import Callable, Collection, Iterable, Mapping from dataclasses import dataclass, field -from sentient.tools.base import Risk, Tool, ToolContext, effective_risk +from sentient.tools.base import ( + MEMORY_OFF_ERROR, + MEMORY_TOOLS, + Risk, + Tool, + ToolContext, + effective_risk, +) from sentient.tools.rules import ( HAND_OFF, IF_ASKED, @@ -54,6 +63,8 @@ class BridgePolicy: rules_source: Callable[[], Mapping[str, str] | None] | None = None # app ids of Read only connections (``integrations.read_only``), read on every call like the rules read_only_apps_source: Callable[[], Collection[str] | None] | None = None + # tools the run that started the script may not use (its turn has no memory, #525): never listed, always refused + withheld: frozenset[str] = frozenset() @classmethod def build( @@ -66,7 +77,9 @@ def build( rules: Mapping[str, str] | None = None, rules_source: Callable[[], Mapping[str, str] | None] | None = None, read_only_apps_source: Callable[[], Collection[str] | None] | None = None, + memory_allowed: bool = True, ) -> BridgePolicy: + """``memory_allowed``: the starting run's ``ToolContext.memory_allowed`` (#525).""" return cls( approvals_mode=approvals_mode, allowed_tools=None if allowed_tools is None else frozenset(allowed_tools), @@ -75,6 +88,7 @@ def build( rules=dict(rules or {}), rules_source=rules_source, read_only_apps_source=read_only_apps_source, + withheld=frozenset() if memory_allowed else MEMORY_TOOLS, ) def current_rules(self) -> Mapping[str, str]: @@ -87,7 +101,7 @@ def read_only_apps(self) -> Collection[str]: def is_available(self, tool: Tool) -> bool: """Tools a script can see at all (listed in the generated sentient_tools module).""" - if tool.plugin in BLOCKED_PLUGINS or tool.name in BLOCKED_TOOLS: + if tool.plugin in BLOCKED_PLUGINS or tool.name in BLOCKED_TOOLS or tool.name in self.withheld: return False if rule_for(self.current_rules(), tool) == "never" or read_only_hides(self.read_only_apps(), tool): return False @@ -99,6 +113,8 @@ async def check(self, tool: Tool | None, name: str, arguments: dict, ctx: ToolCo raise Refused(f"There is no tool named '{name}'. Use tools.available() to list the tools you can call.") if tool.plugin in BLOCKED_PLUGINS or tool.name in BLOCKED_TOOLS: raise Refused(f"'{name}' is not available inside scripts. Call it directly as a normal tool call instead.") + if tool.name in self.withheld: + raise Refused(MEMORY_OFF_ERROR) rules = self.current_rules() rule = rule_for(rules, tool) label = rule_label(tool, rules, (getattr(ctx, "extra", None) or {}).get("registry")) diff --git a/sentient/sandbox/service.py b/sentient/sandbox/service.py index 685a0fed6..32b7580df 100644 --- a/sentient/sandbox/service.py +++ b/sentient/sandbox/service.py @@ -235,8 +235,12 @@ async def _run_in_folder( rules=self.app.config.tools.approvals.rules, rules_source=lambda: self.app.config.tools.approvals.rules, read_only_apps_source=lambda: self.app.config.integrations.read_only, + memory_allowed=call_ctx is None or call_ctx.memory_allowed, ) ctx = self._tool_context(session_id, channel) + if call_ctx is not None: # the script's calls are never wider than the run that started it (#525) + ctx.memory_allowed = call_ctx.memory_allowed + ctx.incognito = call_ctx.incognito transport = self._transport_for(backend) bind = "0.0.0.0" if backend == "docker" and sys.platform.startswith("linux") else "127.0.0.1" bridge = ToolBridge(self.app.registry, policy, ctx, run_dir=run_dir, transport=transport, host=bind) diff --git a/sentient/tools/base.py b/sentient/tools/base.py index 0812999f9..f9a34575b 100644 --- a/sentient/tools/base.py +++ b/sentient/tools/base.py @@ -36,7 +36,7 @@ import inspect import logging import typing -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass, field from enum import IntEnum from typing import Any, get_args, get_origin @@ -95,6 +95,20 @@ def bind_call(sink: ProgressSink | None, call_id: str, name: str) -> contextvars return _CURRENT_CALL.set((sink, call_id, name)) +# Tools that read (or remove) what Sentient knows about the user: remembered facts, the user model and past chats. +# A run without memory (``ToolContext.memory_allowed`` False) is never offered them and they refuse to run (#154, #525). +MEMORY_TOOLS = frozenset({ + "memory_recall", "memory_search_by_source", "memory_search_history", "history_semantic_search", + "history_time_search", "user_model_ask", "memory_forget", +}) +MEMORY_OFF_ERROR = "This chat doesn't use what Sentient knows about the user. Answer from this chat alone." + + +def without_memory_tools(names: Iterable[str]) -> list[str]: + """``names`` without ``MEMORY_TOOLS``, in order.""" + return [n for n in names if n not in MEMORY_TOOLS] + + @dataclass class ToolContext: """Passed to every tool call. Gives tools access to shared services without globals.""" @@ -126,6 +140,15 @@ class ToolContext: # the memory scope of this run (#155): a chat's or a task's ("personal", "work", a project). Empty: the chat's own, # or every scope for work not tied to a chat (``memory.scopes.for_context``) memory_scope: str = "" + # False when this run may not use what Sentient knows about the user (#525). Decided once per chat turn + # (``Agent.memory_allowed``: an incognito chat without ``chat.incognito_uses_memory``; an app's turn without the + # user's leave) and copied into everything the run starts (helpers, the reader, scripts), so their tools are never + # wider than the turn's: ``MEMORY_TOOLS`` are not offered and refuse to run (``Tool.call``). Set in code only. + memory_allowed: bool = True + + def withholds(self, tool_name: str) -> bool: + """True when this run may not use ``tool_name`` because it reads what Sentient knows (#525).""" + return not self.memory_allowed and tool_name in MEMORY_TOOLS async def is_incognito(self) -> bool: """True when this run belongs to an incognito chat (#154).""" @@ -229,6 +252,8 @@ def openai_schema(self) -> dict: } async def call(self, ctx: ToolContext, arguments: dict[str, Any]) -> Any: + if self.name in MEMORY_TOOLS and getattr(ctx, "memory_allowed", True) is False: + return {"error": MEMORY_OFF_ERROR} # whoever calls it: the loop, the reader, a script (#525) parsed = self.params_model.model_validate(arguments or {}) kwargs = parsed.model_dump() return await self.fn(ctx, **kwargs) diff --git a/sentient/tools/builtin/subagents_tool.py b/sentient/tools/builtin/subagents_tool.py index 9bde71865..c1dde3dde 100644 --- a/sentient/tools/builtin/subagents_tool.py +++ b/sentient/tools/builtin/subagents_tool.py @@ -62,6 +62,7 @@ async def delegate_task( on_update=None if background else _progress(ctx), origin=ctx.origin, untrusted=ctx.untrusted, + memory_allowed=ctx.memory_allowed, # never wider than this turn (#525) ) @@ -82,16 +83,19 @@ async def delegate_tasks(ctx: ToolContext, tasks: list[dict]) -> dict: return {"error": f"At most {MAX_PARALLEL_TASKS} tasks at once. Split the work or combine related goals."} results = await mgr.delegate_many( valid, session_id=ctx.session_id, parent_call_id=ctx.call_id, on_update=_progress(ctx), origin=ctx.origin, - untrusted=ctx.untrusted, + untrusted=ctx.untrusted, memory_allowed=ctx.memory_allowed, ) return {"results": results} -def _refuse_source(app: Any, name: str) -> str: - """Why the reader can't use ``name`` to fetch its document, or "".""" +def _refuse_source(app: Any, name: str, ctx: ToolContext) -> str: + """Why the reader can't use ``name`` to fetch its document, or "". Never a tool the turn itself can't use: in a + turn without memory (#525) the memory and history look-ups are refused here before anything runs.""" source = app.registry.get(name) if app is not None else None if source is None: return f"There is no tool called {name}. Give the look-up tool that fetches the email, page or file." + if ctx.withholds(name): + return f"{name} isn't available in this chat: it doesn't use what Sentient knows about the user." if source.plugin == PLUGIN_ID or app.registry.is_hidden(source.plugin) or app.approvals.is_hidden(source): return f"{name} can't be used here." if Risk(source.risk) != Risk.read: @@ -118,7 +122,7 @@ async def read_with_reader(ctx: ToolContext, tool: str, fields: dict[str, str], wanted = reader.parse_fields(fields) except ValueError as exc: return {"error": str(exc)} - refusal = _refuse_source(app, tool) + refusal = _refuse_source(app, tool, ctx) if refusal: return {"error": refusal} source = app.registry.get(tool) diff --git a/tests/test_memory_off_turns.py b/tests/test_memory_off_turns.py new file mode 100644 index 000000000..0b7dd8575 --- /dev/null +++ b/tests/test_memory_off_turns.py @@ -0,0 +1,213 @@ +"""A chat turn without memory (#525): what it starts (helpers, the reader, scripts) is never wider than the turn. + +The turn decides once (``Agent.memory_allowed``) and carries the answer in ``ToolContext.memory_allowed``. Without +memory, no memory or history tool is offered or runs, and no memory reaches a helper's prompt, whoever builds the +tool list. +""" + +from __future__ import annotations + +import json +import textwrap + +import pytest + +from sentient.app import SentientApp +from sentient.llm.events import ToolResultEvent +from sentient.sandbox.policy import BridgePolicy, Refused +from sentient.tools.base import MEMORY_TOOLS, ToolContext +from tests.conftest import FakeProvider, tool_call +from tests.memory.helpers import add_fact + +FACT = "Maya Rao's sister Anika lives in Pune" + + +@pytest.fixture +async def app(config, isolated_home): + config.memory.min_similarity = 0.0 + config.sandbox.backend = "process" + llm = FakeProvider() + a = SentientApp(config, llm=llm, db_path=isolated_home / "memoff.db", enable_background=False) + await a.start() + a.fake = llm + await add_fact(a.memory, FACT, source="manual") + yield a + await a.stop() + + +def _offered(call: dict) -> set[str]: + return {t["function"]["name"] for t in call.get("tools") or []} + + +def _said(app) -> str: + """Everything any model was shown in this test.""" + return json.dumps([c["messages"] for c in app.fake.calls], default=str) + + +async def _private_chat(app) -> str: + """An incognito chat with memory off: the case that exists today.""" + app.config.chat.incognito_uses_memory = False + return await app.store.create_session(channel="desktop", incognito=True) + + +async def _turn(app, sid: str, text: str) -> list: + events = [ev async for ev in app.agent.run_turn(sid, text, channel="desktop")] + await app.agent.drain(timeout=5) + return events + + +def _result(events, name: str): + return next(e for e in events if isinstance(e, ToolResultEvent) and e.name == name) + + +def _helper_calls(app) -> list[dict]: + return [c for c in app.fake.calls if "You are a subagent" in str((c.get("messages") or [{}])[0].get("content"))] + + +async def test_a_helper_of_a_chat_without_memory_gets_no_memory_tools_or_context(app): + sid = await _private_chat(app) + app.fake.replies.extend([ + [tool_call("delegate_task", goal="Find everything Sentient knows about Maya's family")], # the chat + [tool_call("memory_recall", query="Maya sister")], # the helper tries anyway + "I could not find anything.", # the helper's report + "I can't see that from here.", # the chat's reply + ]) + events = await _turn(app, sid, "Use a helper to find what you know about Maya's sister") + + helpers = _helper_calls(app) + assert len(helpers) == 2 + for helper in helpers: + assert not MEMORY_TOOLS & _offered(helper) # the helper's list is never wider than the turn's + assert not {"memory_remember", "skill_save"} & _offered(helper) # nor does it save anything (#154) + assert "Maya" not in helper["messages"][0]["content"] # no name, profile or memories in its prompt + subs = await app.subagents.list_for_session(sid) + [recall] = [e["message"] for e in subs[0]["events"] if e["message"].get("tool_name") == "memory_recall" + and e["message"]["type"] == "tool_result"] + assert recall["is_error"] + assert _result(events, "delegate_task").result["status"] == "completed" + assert "Anika" not in _said(app) # the stored fact reached no model + + +async def test_parallel_and_background_helpers_inherit_the_turn(app): + sid = await _private_chat(app) + app.fake.replies.extend([ + [tool_call("delegate_tasks", tasks=[{"goal": "Search past chats for Maya"}, {"goal": "Ask the user model"}])], + [tool_call("memory_search_history", query="Maya")], + [tool_call("user_model_ask", question="What does Maya like?")], + "Nothing.", "Nothing.", + "Neither helper found anything.", + ]) + await _turn(app, sid, "Ask two helpers what you know about me") + helpers = _helper_calls(app) + assert len(helpers) == 4 and all(not MEMORY_TOOLS & _offered(c) for c in helpers if c.get("tools")) + assert "Anika" not in _said(app) + + # a background helper started from the same turn, straight through the manager, keeps the turn's answer too + app.fake.replies.extend([[tool_call("history_time_search", start_date="2020-01-01", end_date="2030-01-01")], + "Done."]) + out = await app.subagents.delegate("Look through old chats", session_id=sid, memory_allowed=False) + assert out["status"] == "completed" + later = _helper_calls(app)[-2:] + assert all(not MEMORY_TOOLS & _offered(c) for c in later) + assert "Anika" not in _said(app) + + +async def test_a_helper_of_an_incognito_chat_with_memory_reads_but_never_saves(app): + app.config.chat.incognito_uses_memory = True + sid = await app.store.create_session(channel="desktop", incognito=True) + app.fake.replies.extend([[tool_call("delegate_task", goal="Recall Maya's family")], "Done.", "Done."]) + await _turn(app, sid, "Ask a helper about my family") + [helper] = _helper_calls(app) + assert "memory_recall" in _offered(helper) and not {"memory_remember", "skill_save"} & _offered(helper) + + +async def test_the_reader_refuses_a_memory_tool_in_a_chat_without_memory(app): + sid = await _private_chat(app) + app.fake.replies.extend([ + [tool_call("read_with_reader", tool="memory_recall", arguments={"query": "Maya"}, + fields={"where_her_sister_lives": "text"})], + "I can't look that up here.", + ]) + events = await _turn(app, sid, "Use the reader to find where Maya's sister lives") + res = _result(events, "read_with_reader") + assert "isn't available in this chat" in res.result["error"] + assert await app.subagents.list_for_session(sid) == [] # no reader ran + assert not [c for c in app.fake.calls if c.get("json")] and "Anika" not in _said(app) + + +async def test_a_normal_chat_still_hands_memory_on(app): + sid = await app.store.create_session(channel="desktop") + app.fake.replies.extend([ + [tool_call("delegate_task", goal="Find what Sentient knows about Maya's sister")], + [tool_call("memory_recall", query="Maya sister")], + "Her sister Anika lives in Pune.", + "Anika lives in Pune.", + ]) + await _turn(app, sid, "Use a helper to find what you know about Maya's sister") + [helper, report] = _helper_calls(app) + assert {"memory_recall", "memory_search_history"} <= _offered(helper) + assert "Maya" in helper["messages"][0]["content"] + assert "Anika" in json.dumps(report["messages"], default=str) # the helper's recall found the fact + ctx = app.agent.tool_context(sid, "desktop") + from sentient.tools.builtin.subagents_tool import _refuse_source + + assert _refuse_source(app, "memory_recall", ctx) == "" # the reader takes it as before + + +async def test_another_rule_for_a_turn_without_memory_plugs_in_once(app, monkeypatch): + """The hook #479's /v1 switch uses: whatever makes ``Agent.memory_allowed`` False covers the turn's prompt, its own + tools and everything it starts, in a chat that isn't incognito.""" + monkeypatch.setattr(app.agent, "memory_allowed", lambda session, channel: False) + sid = await app.store.create_session(channel="desktop") + app.fake.replies.extend([ + [tool_call("memory_recall", query="Maya")], # not offered: refused + [tool_call("delegate_task", goal="Recall Maya's family")], + [tool_call("memory_recall", query="Maya")], + "Nothing.", + "I don't know.", + ]) + events = await _turn(app, sid, "Where does my sister live?") + chat = app.fake.calls[0] + assert not MEMORY_TOOLS & _offered(chat) and "Anika" not in chat["messages"][0]["content"] + assert _result(events, "memory_recall").is_error + [helper, _] = _helper_calls(app) + assert not MEMORY_TOOLS & _offered(helper) and "Maya" not in helper["messages"][0]["content"] + assert "Anika" not in _said(app) + + +async def test_a_memory_tool_refuses_to_run_for_a_run_without_memory(app): + sid = await app.store.create_session(channel="desktop") # not incognito: only the run's own answer says no + recall = app.registry.get("memory_recall") + ctx = app.agent.tool_context(sid, "desktop") + assert any(f["fact"] == FACT for f in await recall.call(ctx, {"query": "Maya sister"})) + ctx.memory_allowed = False + out = await recall.call(ctx, {"query": "Maya sister"}) + assert "doesn't use what Sentient knows" in out["error"] + assert ctx.withholds("memory_search_history") and not ctx.withholds("current_datetime") + + +async def test_a_script_gets_no_memory_tools_from_a_turn_without_memory(app): + policy = BridgePolicy.build(approvals_mode="ask", allowed_tools=None, read_only=False, max_tool_calls=10, + memory_allowed=False) + recall = app.registry.get("memory_recall") + assert not policy.is_available(recall) + with pytest.raises(Refused, match="doesn't use what Sentient knows"): + await policy.check(recall, "memory_recall", {"query": "x"}, ToolContext(store=None, config=None, llm=None), 0) + + sid = await app.store.create_session(channel="desktop") + ctx = app.agent.tool_context(sid, "desktop") + ctx.memory_allowed = False + script = textwrap.dedent( + """ + from sentient_tools import tools, result, ToolRefused + try: + got = tools.memory_recall(query="Maya sister") + except ToolRefused as exc: + got = "refused: " + str(exc) + result({"got": got, "available": tools.available()}) + """ + ) + res = await app.sandbox.run(script, session_id=sid, channel="desktop", ctx=ctx) + assert res["ok"], res + assert str(res["result"]["got"]).startswith("refused") and not MEMORY_TOOLS & set(res["result"]["available"]) + assert "Anika" not in json.dumps(res)