diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 371eca89..eae6bee1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -162,8 +162,9 @@ budget), context compression first calls `memory.flush_conversation`, and ### Memory is pushed, not pulled Each turn the system prompt carries SOUL.md (persona), USER.md, MEMORY.md, -recent daily notes, recalled facts, the skill index, the clock and a running -summary of long conversations. v2 relied on the model deciding to call a memory +recent daily notes, recalled facts, the skill index, and a running +summary of long conversations. The current clock goes with the latest user +message instead. v2 relied on the model deciding to call a memory tool before it knew anything about you. Tools remain for explicit recall, remembering, forgetting and history search. Memories learned from outside content (an email, a web page), from work nobody asked for, or from an import diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index 263f8fbc..49161210 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -11,7 +11,7 @@ look-up tools requested together run concurrently; results keep their order. - ``Agent.run_turn`` is a chat turn: persists the user message (with attachments), builds the system prompt (persona, profile, recalled memory, - user model, skills, clock, running conversation summary), runs the loop, + user model, skills, running conversation summary), runs the loop, persists the transcript (the final reply carries its memory sources: the facts and insights in its prompt and the facts memory tools returned), then kicks off background work (fact extraction, auto title, context compression). Messages the user sends while a reply runs @@ -34,6 +34,7 @@ import weakref from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass, field +from datetime import datetime from typing import Any from pydantic import ValidationError @@ -87,6 +88,7 @@ effective_risk, is_unprompted, ) +from sentient.tools.builtin.time_tool import resolve_tz from sentient.tools.registry import ToolRegistry from sentient.tools.rules import ( SCREEN_SOURCE, @@ -500,7 +502,6 @@ async def _system_prompt_with_sources( skills_index=self.skills.prompt_index(), assistant_name=self.config.assistant.name, user_name=self.config.assistant.user_name, - timezone=self.config.assistant.timezone, channel=channel, location=self.config.assistant.location, user_context=user_context, @@ -1126,9 +1127,23 @@ def release() -> list[str]: system, sources = await self._system_prompt_with_sources(user_text, channel, session) history = await self.store.recent_messages(session_id, self.config.chat.history_window) convo = history_to_openai(history) - # replace the just-stored user message with the rich version (attachments inlined) + + # Replace the just-stored user message with the rich version (attachments inlined). + # Keep the stored user message unchanged. Add the current time to the + # model-facing user message so it doesn't invalidate the system prompt cache. if convo and convo[-1]["role"] == "user": - convo[-1] = {"role": "user", "content": await self._user_content(user_text, attachments)} + content = await self._user_content(user_text, attachments) + tz = resolve_tz(self.config.assistant.timezone) + now = datetime.now(tz) + time_note = f"[Current time: {now.strftime('%A %Y-%m-%d %H:%M')} ({now.tzinfo})]" + + if isinstance(content, str): + content = f"{time_note}\n\n{content}" + else: + content = [{"type": "text", "text": time_note}, *content] + + convo[-1] = {"role": "user", "content": content} + # spoken channels use the voice role: its own model and reasoning effort (default: thinking off) role = "voice" if channel in SPOKEN_CHANNELS else "primary" if attachments and any(is_image(paths.files_dir() / a) for a in attachments) and self.config.models.roles.vision: diff --git a/sentient/agent/prompt.py b/sentient/agent/prompt.py index e04099b8..e027cbb3 100644 --- a/sentient/agent/prompt.py +++ b/sentient/agent/prompt.py @@ -2,16 +2,13 @@ Everything the model needs to *be* the user's assistant is injected here every turn: persona, who the user is, curated notes, recalled facts, the user model, -the skill index, and the clock. The legacy system relied on the model calling a +and the skill index. The legacy system relied on the model calling a memory tool before it knew anything; here memory is pushed, and tools are the fallback. """ from __future__ import annotations from collections.abc import Iterable -from datetime import datetime - -from sentient.tools.builtin.time_tool import resolve_tz TOOL_RULES = """## Working rules - If a request can be done with a tool, do it and report the outcome briefly. Do not narrate what you are about to do. @@ -100,15 +97,12 @@ def build_system_prompt( skills_index: str, assistant_name: str, user_name: str, - timezone: str, channel: str, location: str = "", user_context: str = "", tool_names: Iterable[str] | None = None, read_only_apps: Iterable[str] | None = None, ) -> str: - tz = resolve_tz(timezone) - now = datetime.now(tz) parts: list[str] = [] soul = snapshot.get("soul") or f"You are {assistant_name}, a personal assistant." @@ -137,7 +131,6 @@ def build_system_prompt( parts.append( "## Context\n" - f"- Now: {now.strftime('%A %Y-%m-%d %H:%M')} ({now.tzinfo})\n" f"- User: {user_name or 'unknown (ask once, then remember)'}\n" f"- User location: {location or 'unknown'}\n" f"- Channel: {channel}\n" diff --git a/tests/channels/test_telegram.py b/tests/channels/test_telegram.py index af5302d5..13d50170 100644 --- a/tests/channels/test_telegram.py +++ b/tests/channels/test_telegram.py @@ -288,7 +288,9 @@ async def test_message_while_running_is_queued_without_steer(tg, gated, monkeypa gated.gate.set() await tg.ch.wait_idle() assert gated.stream_calls == 2 - assert gated.calls[-1]["messages"][-1]["content"] == "two" + content = gated.calls[-1]["messages"][-1]["content"] + assert "[Current time:" in content + assert content.endswith("two") assert tg.api.screen()[-1] == "second reply" @@ -364,7 +366,9 @@ async def spy(method, **kwargs): await tg.say(42, None, **VOICE) assert voice.received == [(b"OggS-voice", "telegram-voice-u1.ogg")] assert any("Heard: what's the weather" in t or "Heard: what's the weather" in t for t in tg.api.screen()) - assert llm.calls[-1]["messages"][-1]["content"] == "what's the weather in Pune" + content = llm.calls[-1]["messages"][-1]["content"] + assert "[Current time:" in content + assert content.endswith("what's the weather in Pune") assert tg.api.screen()[-1] == "It's sunny." voices = [kwargs for method, kwargs in sent if method == "sendVoice"] assert len(voices) == 1 @@ -424,7 +428,13 @@ async def test_photo_and_document_become_attachments(tg, llm): saved = paths.files_dir() / "uploads" / "telegram-photo-big.jpg" assert saved.read_bytes() == b"\xff\xd8\xff\xe0fakejpeg" content = llm.calls[-1]["messages"][-1]["content"] - assert isinstance(content, list) and "what is this" in content[0]["text"] + assert isinstance(content, list) + assert "[Current time:" in content[0]["text"] + assert any( + block.get("type") == "text" and "what is this" in block.get("text", "") + for block in content + ) + assert any(block.get("type") == "image_url" for block in content) doc = {"document": {"file_id": "d1", "file_unique_id": "doc", "file_name": "notes.txt", "mime_type": "text/plain", "file_size": 22}} await tg.say(42, None, **doc) assert "hello from a text file" in llm.calls[-1]["messages"][-1]["content"] diff --git a/tests/channels/test_whatsapp.py b/tests/channels/test_whatsapp.py index a63525fc..76161449 100644 --- a/tests/channels/test_whatsapp.py +++ b/tests/channels/test_whatsapp.py @@ -314,7 +314,12 @@ async def test_photo_and_document_become_attachments(wa, llm): await wa.say("what is this", media=[WAMedia(kind="image", name="whatsapp-image-1.jpg", mime="image/jpeg", size=12, ref="img")]) assert (paths.files_dir() / "uploads" / "whatsapp-image-1.jpg").read_bytes() == b"\xff\xd8\xff\xe0fakejpeg" content = llm.calls[-1]["messages"][-1]["content"] - assert isinstance(content, list) and "what is this" in content[0]["text"] + assert isinstance(content, list) + assert any( + block.get("type") == "text" + and "what is this" in block.get("text", "") + for block in content + ) await wa.say(media=[WAMedia(kind="document", name="notes.txt", mime="text/plain", size=22, ref="doc")]) assert "hello from a text file" in llm.calls[-1]["messages"][-1]["content"] await wa.say(media=[WAMedia(kind="document", name="huge.zip", size=50 * 1024 * 1024, ref="big")]) @@ -426,9 +431,15 @@ async def test_voice_note_in_and_voice_note_out(wa, llm, monkeypatch): await wa.say(media=[WAMedia(kind="voice", name="whatsapp-voice-ABC.ogg", mime="audio/ogg; codecs=opus", size=10, ref="v1")]) assert voice.received == [(b"OggS-voice", "whatsapp-voice-ABC.ogg")] assert "_Heard:_ what's the weather in Pune" in wa.hub.screen() - assert llm.calls[-1]["messages"][-1]["content"] == "what's the weather in Pune" + content = llm.calls[-1]["messages"][-1]["content"] + assert isinstance(content, str) + assert content.endswith("what's the weather in Pune") assert wa.hub.last() == "It's sunny." notes = [s for s in wa.hub.sent if s["op"] == "voice"] + print("Voice replies enabled:", wa.app.config.channels.whatsapp.voice_replies) + print("Voice service:", repr(wa.app.voice)) + print("Sent operations:", [s["op"] for s in wa.hub.sent]) + print("All sent messages:", repr(wa.hub.sent)) assert len(notes) == 1 and notes[0]["data"].startswith(b"OggS") and notes[0]["seconds"] >= 1 diff --git a/tests/test_agent_upgrades.py b/tests/test_agent_upgrades.py index dfcdaf36..80a06ca4 100644 --- a/tests/test_agent_upgrades.py +++ b/tests/test_agent_upgrades.py @@ -422,6 +422,58 @@ def broken(res): # ---------------------------------------------------------------------------- prompt caching +async def test_current_time_is_in_user_message_not_system_prompt(config, isolated_home): + llm = FakeProvider(replies=["First answer.", "Second answer."]) + s = await start(config, isolated_home, llm, "current-time") + try: + sid = await s.store.create_session(channel="cli") + + async for _ in s.agent.run_turn(sid, "What time is it?", channel="cli"): + pass + + first_messages = llm.calls[0]["messages"] + first_system = first_messages[0]["content"] + first_user = first_messages[-1]["content"] + + assert "[Current time:" not in first_system + assert "[Current time:" in first_user + assert "What time is it?" in first_user + + async for _ in s.agent.run_turn(sid, "And now?", channel="cli"): + pass + + second_messages = llm.calls[1]["messages"] + second_system = second_messages[0]["content"] + second_user = second_messages[-1]["content"] + + assert second_system == first_system + assert "[Current time:" in second_user + assert "And now?" in second_user + + rows = await s.store.recent_messages(sid, 20) + assert rows[0]["content"] == "What time is it?" + assert rows[2]["content"] == "And now?" + finally: + await s.stop() + + +def test_system_prompt_does_not_depend_on_current_time(): + from sentient.agent.prompt import build_system_prompt + + kwargs = { + "snapshot": {}, + "facts": [], + "skills_index": "", + "assistant_name": "Sentient", + "user_name": "Maya", + "channel": "desktop", + } + + prompt = build_system_prompt(**kwargs) + + assert "- Now:" not in prompt + assert "[Current time:" not in prompt + def test_prompt_cache_only_for_anthropic(): messages = [{"role": "system", "content": "persona"}, {"role": "user", "content": "hi"}] tools = [{"type": "function", "function": {"name": "a"}}, {"type": "function", "function": {"name": "b"}}] @@ -603,7 +655,7 @@ async def test_selection_triggers_surface_new_capabilities(): def test_capability_rules_follow_available_tools(): base = dict(snapshot={}, facts=[], skills_index="", assistant_name="Sentient", user_name="Sarthak", - timezone="UTC", channel="desktop") + channel="desktop") plain = build_system_prompt(**base, tool_names=["memory_recall"]) assert "Open browser button" not in plain and "execute_code" not in plain rich = build_system_prompt( @@ -678,7 +730,7 @@ async def test_steer_messages_are_marked_and_package_schemas_listed(config, isol async def test_messaging_and_phone_channels(config, isolated_home): - base = dict(snapshot={}, facts=[], skills_index="", assistant_name="Sentient", user_name="Sarthak", timezone="UTC") + base = dict(snapshot={}, facts=[], skills_index="", assistant_name="Sentient", user_name="Sarthak") assert "chatting in Telegram" in build_system_prompt(**base, channel="telegram") assert "No wide tables" in build_system_prompt(**base, channel="discord") llm = FakeProvider(replies=["Hi."]) diff --git a/tests/test_claude_code.py b/tests/test_claude_code.py index a0720a31..999a9e0f 100644 --- a/tests/test_claude_code.py +++ b/tests/test_claude_code.py @@ -179,7 +179,12 @@ async def test_chat_reply_streams_through_claude_code(make_app, fake_claude, iso assert not run["has_gateway_token"] and run["tool_search"] == "false" assert "Sentient" in run["system"] # Sentient's own system prompt replaces Claude Code's [message] = run["stdin"] - assert message["type"] == "user" and message["message"]["content"][0]["text"] == "say hello" + content = message["message"]["content"] + assert isinstance(content, list) + assert any( + block.get("type") == "text" and block.get("text", "").endswith("say hello") + for block in content + ) # it ran in a scratch folder under Sentient's home, removed afterwards; nothing is left running assert Path(run["cwd"]).is_relative_to(isolated_home) and not Path(run["cwd"]).exists() assert not claude_code._live diff --git a/tests/test_screen_share.py b/tests/test_screen_share.py index 2225503b..5ae3b699 100644 --- a/tests/test_screen_share.py +++ b/tests/test_screen_share.py @@ -90,8 +90,17 @@ def test_a_screen_capture_goes_to_the_vision_role_and_marks_the_chat(config, iso role = "vision" if vision else "primary" content = _user_message(llm, role)["content"] assert isinstance(content, list) - assert content[1]["image_url"]["url"].startswith("data:image/png;base64,") - assert "Screen capture attached: screens/Inbox - Mail.png" in content[0]["text"] + image_block = next( + block for block in content + if block.get("type") == "image_url" + ) + image_url = image_block["image_url"]["url"] + assert image_url.startswith("data:image/png;base64,") + assert any( + block.get("type") == "text" + and "Screen capture attached: screens/Inbox - Mail.png" in block.get("text", "") + for block in content + ) listed = next(s for s in client.get("/api/sessions").json() if s["id"] == sid) assert listed["untrusted"] == "your screen" @@ -104,7 +113,13 @@ def test_a_plain_image_upload_does_not_mark_the_chat(config, isolated_home, monk r = client.post("/api/chat", json={"text": "what is this?", "attachments": [up["name"]]}) sid = json.loads(r.text.splitlines()[0])["session_id"] assert llm.calls[-1]["role"] == "vision" - assert "Image attached: uploads/cat.png" in _user_message(llm, "vision")["content"][0]["text"] + content = _user_message(llm, "vision")["content"] + assert isinstance(content, list) + assert any( + block.get("type") == "text" + and "Image attached: uploads/cat.png" in block.get("text", "") + for block in content + ) listed = next(s for s in client.get("/api/sessions").json() if s["id"] == sid) assert listed["untrusted"] == ""