diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md
index 82254b3c6..ecc2ee75a 100644
--- a/.agent/cron-session-memory.md
+++ b/.agent/cron-session-memory.md
@@ -130,7 +130,7 @@ Instead, persist a readable automation trigger event, for example:
{
"role": "user",
"content": "Scheduled automation triggered: daily monitor\n\nCheck ...",
- "_automation_trigger": true,
+ "_automation_turn": true,
"automation_id": "abc123",
"automation_name": "daily monitor",
"automation_run_id": "abc123:1770000000000",
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 151621748..2c1cf2375 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -40,6 +40,7 @@ from nanobot.bus.runtime_events import (
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.cron.automation import (
+ AUTOMATION_HISTORY_META,
automation_run_id,
automation_trigger,
defer_until_session_idle,
@@ -57,6 +58,7 @@ from nanobot.session.goal_state import (
runner_wall_llm_timeout_s,
sustained_goal_active,
)
+from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
from nanobot.session.manager import Session, SessionManager
from nanobot.session.routing import persist_routing_context
from nanobot.utils.document import extract_documents, reference_non_image_attachments
@@ -78,8 +80,6 @@ if TYPE_CHECKING:
from nanobot.cron.service import CronService
-UNIFIED_SESSION_KEY = "unified:default"
-
class TurnState(Enum):
RESTORE = auto()
COMPACT = auto()
@@ -522,12 +522,11 @@ class AgentLoop:
"""Update context for all tools that need routing info."""
from nanobot.agent.tools.context import ContextAware
- if session_key is not None:
- effective_key = session_key
- elif self._unified_session:
- effective_key = UNIFIED_SESSION_KEY
- else:
- effective_key = f"{channel}:{chat_id}"
+ effective_key = session_key or session_key_for_channel(
+ channel,
+ chat_id,
+ unified_session=self._unified_session,
+ )
request_ctx = RequestContext(
channel=channel,
chat_id=chat_id,
@@ -646,7 +645,7 @@ class AgentLoop:
if isinstance(persist_content, str) and persist_content.strip():
text = persist_content
extra.update({
- "_automation_trigger": True,
+ AUTOMATION_HISTORY_META: True,
"automation_id": trigger.get("job_id"),
"automation_name": trigger.get("job_name"),
"automation_run_id": trigger.get("run_id"),
diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py
index 7268b49c9..b1d3b41e2 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -142,7 +142,7 @@ class CronTool(Tool, ContextAware):
if action == "add":
if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution"
- return self._add_job(name, message, every_seconds, cron_expr, tz, at, deliver)
+ return self._add_job(name, message, every_seconds, cron_expr, tz, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
@@ -157,7 +157,6 @@ class CronTool(Tool, ContextAware):
cron_expr: str | None,
tz: str | None,
at: str | None,
- deliver: bool = True,
) -> str:
if not message:
return (
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 3fb327c51..0f48635e2 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -64,6 +64,10 @@ from nanobot.utils.restart import ( # noqa: E402
format_restart_completed_message,
should_show_cli_restart_notice,
)
+from nanobot.webui.metadata import ( # noqa: E402
+ WEBUI_MESSAGE_SOURCE_METADATA_KEY,
+ WEBUI_TURN_METADATA_KEY,
+)
def _sanitize_surrogates(text: str) -> str:
@@ -89,8 +93,6 @@ class SafeFileHistory(FileHistory):
super().store_string(_sanitize_surrogates(string))
-_WEBUI_TURN_META_KEY = "webui_turn_id"
-_WEBUI_MESSAGE_SOURCE_META_KEY = "_webui_message_source"
_PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar(
"proactive_webui_metadata",
default=None,
@@ -106,13 +108,13 @@ def _proactive_delivery_metadata(
) -> dict[str, Any]:
"""Return channel metadata for a fresh proactive delivery turn."""
out = dict(metadata or {})
- out.pop(_WEBUI_TURN_META_KEY, None)
+ out.pop(WEBUI_TURN_METADATA_KEY, None)
if channel == "websocket":
- out[_WEBUI_TURN_META_KEY] = f"{turn_seed}:{uuid.uuid4().hex}"
+ out[WEBUI_TURN_METADATA_KEY] = f"{turn_seed}:{uuid.uuid4().hex}"
source: dict[str, str] = {"kind": "cron"}
if source_label:
source["label"] = source_label
- out[_WEBUI_MESSAGE_SOURCE_META_KEY] = source
+ out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
return out
app = typer.Typer(
@@ -1034,14 +1036,14 @@ def _run_gateway(
schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events)
- from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import InboundMessage, OutboundMessage
+ from nanobot.session.keys import session_key_for_channel
def _channel_session_key(channel: str, chat_id: str) -> str:
- return (
- UNIFIED_SESSION_KEY
- if config.agents.defaults.unified_session
- else f"{channel}:{chat_id}"
+ return session_key_for_channel(
+ channel,
+ chat_id,
+ unified_session=config.agents.defaults.unified_session,
)
def _session_metadata(session_key: str) -> dict[str, Any]:
@@ -1125,17 +1127,20 @@ def _run_gateway(
),
}
metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] = True
+ run_record_base: dict[str, Any] = {
+ "job_id": job.id,
+ "job_name": job.name,
+ "session_key": session_key,
+ "prompt_ref": prompt_ref,
+ "prompt_vars": {"message": job.payload.message},
+ "rendered_prompt": prompt,
+ }
cron.write_run_record(
run_id,
{
- "job_id": job.id,
- "job_name": job.name,
- "session_key": session_key,
+ **run_record_base,
"status": "queued",
- "prompt_ref": prompt_ref,
- "prompt_vars": {"message": job.payload.message},
- "rendered_prompt": prompt,
},
)
@@ -1159,14 +1164,9 @@ def _run_gateway(
cron.write_run_record(
run_id,
{
- "job_id": job.id,
- "job_name": job.name,
- "session_key": session_key,
+ **run_record_base,
"status": "error",
"error": error_text,
- "prompt_ref": prompt_ref,
- "prompt_vars": {"message": job.payload.message},
- "rendered_prompt": prompt,
},
)
raise
@@ -1178,13 +1178,8 @@ def _run_gateway(
cron.write_run_record(
run_id,
{
- "job_id": job.id,
- "job_name": job.name,
- "session_key": session_key,
+ **run_record_base,
"status": "ok",
- "prompt_ref": prompt_ref,
- "prompt_vars": {"message": job.payload.message},
- "rendered_prompt": prompt,
"response": response,
},
)
diff --git a/nanobot/cron/automation.py b/nanobot/cron/automation.py
index 298680a2f..f619870ff 100644
--- a/nanobot/cron/automation.py
+++ b/nanobot/cron/automation.py
@@ -8,6 +8,7 @@ from nanobot.cron.types import CronJob
AUTOMATION_TRIGGER_META = "_automation_trigger"
AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle"
+AUTOMATION_HISTORY_META = "_automation_turn"
def automation_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
diff --git a/nanobot/session/keys.py b/nanobot/session/keys.py
new file mode 100644
index 000000000..ce581bdc8
--- /dev/null
+++ b/nanobot/session/keys.py
@@ -0,0 +1,12 @@
+"""Shared session key constants and helpers."""
+
+from __future__ import annotations
+
+UNIFIED_SESSION_KEY = "unified:default"
+
+
+def session_key_for_channel(channel: str, chat_id: str, *, unified_session: bool = False) -> str:
+ """Return the session key for a channel/chat pair."""
+ if unified_session:
+ return UNIFIED_SESSION_KEY
+ return f"{channel}:{chat_id}"
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 9041aa27e..503f43146 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -14,6 +14,7 @@ from typing import Any
from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir
+from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY
from nanobot.utils.helpers import (
ensure_dir,
estimate_message_tokens,
@@ -36,7 +37,7 @@ _FORK_VOLATILE_METADATA_KEYS = {
"pending_user_turn",
"runtime_checkpoint",
"thread_goal",
- "_routing_context",
+ SESSION_ROUTING_METADATA_KEY,
"title",
"title_user_edited",
}
diff --git a/nanobot/session/metadata.py b/nanobot/session/metadata.py
new file mode 100644
index 000000000..f756a07bd
--- /dev/null
+++ b/nanobot/session/metadata.py
@@ -0,0 +1,3 @@
+"""Shared session metadata keys."""
+
+SESSION_ROUTING_METADATA_KEY = "_routing_context"
diff --git a/nanobot/session/routing.py b/nanobot/session/routing.py
index cad4578c0..5ae53c552 100644
--- a/nanobot/session/routing.py
+++ b/nanobot/session/routing.py
@@ -7,8 +7,7 @@ from typing import Any, Mapping
from nanobot.bus.events import InboundMessage
from nanobot.cron.automation import is_automation_turn
from nanobot.session.manager import Session
-
-SESSION_ROUTING_METADATA_KEY = "_routing_context"
+from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY
_ROUTING_METADATA_KEYS = {
"chat_type",
diff --git a/nanobot/webui/metadata.py b/nanobot/webui/metadata.py
new file mode 100644
index 000000000..03d426bfd
--- /dev/null
+++ b/nanobot/webui/metadata.py
@@ -0,0 +1,4 @@
+"""Shared WebUI metadata keys."""
+
+WEBUI_TURN_METADATA_KEY = "webui_turn_id"
+WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py
index 0d19a7119..e3a8f1dfc 100644
--- a/nanobot/webui/transcript.py
+++ b/nanobot/webui/transcript.py
@@ -18,6 +18,7 @@ from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
+from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
WEBUI_FORK_MARKER_EVENT = "fork_marker"
@@ -29,8 +30,6 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
-WEBUI_TURN_METADATA_KEY = "webui_turn_id"
-WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py
index 37397aa70..648f35ecf 100644
--- a/nanobot/webui/ws_http.py
+++ b/nanobot/webui/ws_http.py
@@ -20,8 +20,8 @@ from loguru import logger
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
-from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import builtin_command_palette
+from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index 1c1f9de64..0b212f5fc 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -8,6 +8,7 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
+from nanobot.cron.automation import AUTOMATION_HISTORY_META, AUTOMATION_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
@@ -65,6 +66,41 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
assert runtime.model == "next-model"
+def test_persist_automation_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
+ loop = _make_full_loop(tmp_path)
+ session = loop.sessions.get_or_create("websocket:auto")
+ prompt_ref = {"id": "cron.agent_turn.reminder", "version": 1, "sha256": "abc"}
+
+ persisted = loop._persist_user_message_early(
+ InboundMessage(
+ channel="websocket",
+ sender_id="cron",
+ chat_id="auto",
+ content="Automation: internal prompt",
+ metadata={
+ AUTOMATION_TRIGGER_META: {
+ "job_id": "job-1",
+ "job_name": "Daily check",
+ "run_id": "job-1:1",
+ "prompt_ref": prompt_ref,
+ "persist_content": "Scheduled automation triggered: Daily check",
+ }
+ },
+ ),
+ session,
+ )
+
+ assert persisted is True
+ message = session.messages[-1]
+ assert message["content"] == "Scheduled automation triggered: Daily check"
+ assert message[AUTOMATION_HISTORY_META] is True
+ assert AUTOMATION_TRIGGER_META not in message
+ assert message["automation_id"] == "job-1"
+ assert message["automation_name"] == "Daily check"
+ assert message["automation_run_id"] == "job-1:1"
+ assert message["automation_prompt_ref"] == prompt_ref
+
+
def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("reasoning WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: The user said hello") == ""
diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py
index b5d970a11..3133a1698 100644
--- a/tests/agent/test_runner_injections.py
+++ b/tests/agent/test_runner_injections.py
@@ -592,8 +592,8 @@ async def test_waiting_dispatch_does_not_replace_active_pending_queue(tmp_path):
@pytest.mark.asyncio
async def test_followup_routed_to_pending_queue(tmp_path):
"""Unified-session follow-ups should route into the active pending queue."""
- from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import InboundMessage
+ from nanobot.session.keys import UNIFIED_SESSION_KEY
loop = _make_loop(tmp_path)
loop._unified_session = True
diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py
index a62d79d96..f8fd70c15 100644
--- a/tests/channels/test_websocket_http_routes.py
+++ b/tests/channels/test_websocket_http_routes.py
@@ -11,10 +11,10 @@ from urllib.parse import urlencode
import httpx
import pytest
-from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
+from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index 86901dab3..975126ad8 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -11,11 +11,13 @@ from typer.testing import CliRunner
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli.commands import _proactive_delivery_metadata, app
from nanobot.config.schema import Config
+from nanobot.cron.automation import AUTOMATION_DEFER_UNTIL_IDLE_META, AUTOMATION_TRIGGER_META
from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
+from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
runner = CliRunner()
@@ -23,7 +25,7 @@ runner = CliRunner()
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
metadata = {
"webui": True,
- "webui_turn_id": "turn-that-created-the-reminder",
+ WEBUI_TURN_METADATA_KEY: "turn-that-created-the-reminder",
"workspace_scope": {"mode": "default"},
}
@@ -36,9 +38,9 @@ def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
assert out["webui"] is True
assert out["workspace_scope"] == {"mode": "default"}
- assert out["webui_turn_id"].startswith("cron:drink-water:")
- assert out["webui_turn_id"] != metadata["webui_turn_id"]
- assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"}
+ assert out[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:")
+ assert out[WEBUI_TURN_METADATA_KEY] != metadata[WEBUI_TURN_METADATA_KEY]
+ assert out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {"kind": "cron", "label": "drink water"}
def _fake_provider():
@@ -1350,7 +1352,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
to="chat-1",
channel_meta={
"webui": True,
- "webui_turn_id": old_turn_id,
+ WEBUI_TURN_METADATA_KEY: old_turn_id,
"workspace_scope": {"mode": "default"},
},
),
@@ -1365,9 +1367,9 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert delivered.chat_id == "chat-1"
assert delivered.metadata["webui"] is True
assert delivered.metadata["workspace_scope"] == {"mode": "default"}
- assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:")
- assert delivered.metadata["webui_turn_id"] != old_turn_id
- assert delivered.metadata["_webui_message_source"] == {
+ assert delivered.metadata[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:")
+ assert delivered.metadata[WEBUI_TURN_METADATA_KEY] != old_turn_id
+ assert delivered.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "cron",
"label": "drink water",
}
@@ -1653,14 +1655,17 @@ def test_gateway_bound_cron_runs_as_session_turn(
assert "Automation: Check repository health." in msg.content
assert msg.metadata["webui"] is True
assert msg.metadata["workspace_scope"]["project_path"] == str(tmp_path)
- assert msg.metadata["_webui_message_source"] == {"kind": "cron", "label": "Repo check"}
- trigger = msg.metadata["_automation_trigger"]
+ assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
+ "kind": "cron",
+ "label": "Repo check",
+ }
+ trigger = msg.metadata[AUTOMATION_TRIGGER_META]
assert trigger["job_id"] == "repo-check"
assert trigger["job_name"] == "Repo check"
assert trigger["persist_content"] == (
"Scheduled automation triggered: Repo check\n\nCheck repository health."
)
- assert msg.metadata["_defer_until_session_idle"] is True
+ assert msg.metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] is True
statuses = [record["status"] for _run_id, record in seen["run_records"]]
assert statuses == ["queued", "ok"]
assert seen["run_records"][0][0] == seen["run_records"][1][0]
diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py
index 23d426031..499979a66 100644
--- a/tests/cron/test_cron_tool_list.py
+++ b/tests/cron/test_cron_tool_list.py
@@ -347,7 +347,7 @@ def test_add_job_requires_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
- result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False)
+ result = tool._add_job(None, "Background refresh", 60, None, None, None)
assert result == "Error: scheduled automations must be created from a chat session"
assert tool._cron.list_jobs() == []
diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py
index ff02b7f56..5f413a9c2 100644
--- a/tests/test_tool_contextvars.py
+++ b/tests/test_tool_contextvars.py
@@ -10,6 +10,7 @@ from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.cron.service import CronService
+from nanobot.session.keys import UNIFIED_SESSION_KEY
@pytest.mark.asyncio
@@ -262,7 +263,7 @@ async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> No
"websocket",
"chat-123",
metadata={"webui": True},
- session_key="unified:default",
+ session_key=UNIFIED_SESSION_KEY,
)
result = await tool.execute(action="add", message="standup", every_seconds=300)
@@ -270,7 +271,7 @@ async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> No
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
- assert jobs[0].payload.session_key == "unified:default"
+ assert jobs[0].payload.session_key == UNIFIED_SESSION_KEY
@pytest.mark.asyncio