refactor: centralize cron session metadata keys

This commit is contained in:
chengyongru 2026-06-12 11:43:23 +08:00
parent 0e3a57b371
commit d9d481bc15
18 changed files with 116 additions and 62 deletions

View File

@ -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",

View File

@ -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"),

View File

@ -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 (

View File

@ -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,
},
)

View File

@ -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:

12
nanobot/session/keys.py Normal file
View File

@ -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}"

View File

@ -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",
}

View File

@ -0,0 +1,3 @@
"""Shared session metadata keys."""
SESSION_ROUTING_METADATA_KEY = "_routing_context"

View File

@ -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",

View File

@ -0,0 +1,4 @@
"""Shared WebUI metadata keys."""
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"

View File

@ -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+(?:\"[^\"]*\"|'[^']*'))?\)"
)

View File

@ -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

View File

@ -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("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == ""

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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() == []

View File

@ -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