diff --git a/changes/552.fixed.md b/changes/552.fixed.md new file mode 100644 index 00000000..0212b6cc --- /dev/null +++ b/changes/552.fixed.md @@ -0,0 +1,3 @@ +**Connected apps and memory-off chats no longer see your name in Sentient's personality.** The name you gave at setup is +written into the personality file, so a chat with memory off (a connected app, or an incognito chat) could still reveal +it. Now that chat reads "the user" there, and keeps the rest of the personality. Normal chats still use your name. diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index 8dfc3901..2a735468 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -56,7 +56,7 @@ from sentient.agent import undo as undo_rules from sentient.agent.approvals import ApprovalBroker from sentient.agent.chat_rules import WAIT_S, pending_asks, standing_text -from sentient.agent.prompt import build_system_prompt, frame_api_request, mark_outside +from sentient.agent.prompt import build_system_prompt, frame_api_request, mark_outside, without_name from sentient.agent.sources import AnswerSources from sentient.agent.toolselect import ToolSelector, is_local_model from sentient.app_names import clean_app_name, framed_app_name @@ -794,8 +794,11 @@ async def _system_prompt_with_sources( sources.add_facts(facts) sources.add_insights(insights) snapshot = self.workspace.snapshot() - if not reads_memory: # only the persona: no profile, memory file or daily notes - snapshot = {k: (v if k == "soul" else "") for k, v in snapshot.items()} + if not reads_memory: # only the persona, without the user's name: no profile, memory file or daily notes (#552) + snapshot = { + k: (without_name(v, self.config.assistant.user_name) if k == "soul" else "") + for k, v in snapshot.items() + } elif not personal: # MEMORY.md, daily notes and USER.md's learned facts come from personal memories (#155) snapshot = { **snapshot, "memory": "", "today": "", "yesterday": "", diff --git a/sentient/agent/prompt.py b/sentient/agent/prompt.py index c8c99ba5..27e1a82a 100644 --- a/sentient/agent/prompt.py +++ b/sentient/agent/prompt.py @@ -27,6 +27,21 @@ - Keep replies short. Plain language. Match the user's tone. """ + +def replace_name(text: str, name: str, replacement: str) -> str: + """``text`` with the user's ``name`` replaced by ``replacement``. Whole-word, any case.""" + name = (name or "").strip() + if not name: + return text + return re.sub(rf"(? str: + """``text`` with the user's ``name`` replaced by "the user" (#552), for a turn that must not reveal who they are: + SOUL.md is written with the name in it ("You are Sentient, Maya's assistant...").""" + return replace_name(text, name, "the user") + + # ---------------------------------------------------------------------- outside content (spotlighting, ADR 0023) # Tool results that can carry text someone else wrote (rules.brings_untrusted) are wrapped in markers before the model # reads them, and the system prompt says once that marked text is data. The code in the markers comes from a secret diff --git a/sentient/app.py b/sentient/app.py index 33fcf7a8..7a59ca04 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -32,6 +32,7 @@ from sentient.agent.grants import GrantStore from sentient.agent.incognito import IncognitoSweeper from sentient.agent.loop import Agent +from sentient.agent.prompt import replace_name from sentient.agent.subagents import SubagentManager from sentient.agent.undo import UndoBroker from sentient.backup import BackupService @@ -184,10 +185,12 @@ async def user_seen(self, surface: str) -> None: def save_config(self, config: SentientConfig | None = None) -> None: """Persist and hot-apply configuration.""" + previous_name = self.config.assistant.user_name if config is not None: self.config = config moved = self._claude_code_switched() save_config(self.config) + self._carry_name_into_persona(previous_name, self.config.assistant.user_name) if self.config.integrations.mcp_server_enabled: # turning the MCP server on issues its own narrow token in the keychain (#443, ADR 0034); # a keychain problem must never break saving settings, the status endpoint reports it @@ -210,6 +213,16 @@ def save_config(self, config: SentientConfig | None = None) -> None: if moved: self._note_claude_code(moved) + def _carry_name_into_persona(self, previous: str, current: str) -> None: + """SOUL.md is written with the user's name (#552): when the name changes in settings, the persona follows it, + so a turn without memory never shows the old name. Only that name is replaced; the rest of the file stays.""" + if not previous.strip() or previous.strip() == current.strip() or not self.workspace.soul.exists(): + return + text = self.workspace.soul.read_text(encoding="utf-8") + updated = replace_name(text, previous, current.strip() or "the user") + if updated != text: + self.workspace.write("soul", updated) + def _claude_code_switched(self) -> list[dict]: """Claude Code doing everything (#444, ADR 0032) was just turned off: move the roles still on it that refuse it now to another model (in place, before saving). Returns what moved, or [] when nothing changed.""" diff --git a/tests/test_memory_off_turns.py b/tests/test_memory_off_turns.py index 0b7dd857..e0e8be94 100644 --- a/tests/test_memory_off_turns.py +++ b/tests/test_memory_off_turns.py @@ -211,3 +211,50 @@ async def test_a_script_gets_no_memory_tools_from_a_turn_without_memory(app): 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) + + +async def test_a_turn_without_memory_keeps_the_persona_but_not_the_users_name(app): + """SOUL.md is written with the name in it at onboarding (#552): without memory the prompt says "the user".""" + from sentient.memory.personas import render_persona + + app.workspace.write("soul", render_persona("professional", name="Sentient", user="Maya")) + sid = await _private_chat(app) + app.fake.replies.append("Hello.") + await _turn(app, sid, "what's my name?") + private = app.fake.calls[0]["messages"][0]["content"] + assert "Maya" not in private + assert "the user's chief of staff" in private # the persona stays + + sid = await app.store.create_session(channel="desktop") + app.fake.replies.append("Hello.") + await _turn(app, sid, "hi") + assert "Maya's chief of staff" in app.fake.calls[-1]["messages"][0]["content"] # a normal chat still has it + + +async def test_the_v1_api_without_memory_does_not_reveal_the_name_from_soul(app): + app.config.openai_api.allow_memory = False + assert "Maya" in app.workspace.snapshot()["soul"] # the default persona names the user + prompt = await app.agent.system_prompt("what's my name?", "api") + assert "Maya" not in prompt + + +async def test_renaming_the_user_in_settings_moves_the_persona_to_the_new_name(app): + """The settings routes change the name without rewriting SOUL.md, so the old name must not stay behind (#552).""" + from sentient.memory.personas import render_persona + + app.workspace.write("soul", render_persona("professional", name="Sentient", user="Maya")) + cfg = app.config.model_copy(deep=True) + cfg.assistant.user_name = "Sam" + app.save_config(cfg) + assert "Maya" not in app.workspace.snapshot()["soul"] + assert "Sam's chief of staff" in app.workspace.snapshot()["soul"] + app.config.openai_api.allow_memory = False + assert "Maya" not in await app.agent.system_prompt("what's my name?", "api") + + +def test_without_name_replaces_whole_words_in_any_case(): + from sentient.agent.prompt import without_name + + assert without_name("Maya's bot. MAYA and Mayan.", "Maya") == "the user's bot. the user and Mayan." + assert without_name("Dr. Maya Rao's bot", "Maya Rao") == "Dr. the user's bot" + assert without_name("unchanged", "") == "unchanged"