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