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"] == ""