feat(cron): bind scheduled automations to sessions

This commit is contained in:
chengyongru
2026-06-11 19:48:07 +08:00
parent ffae1dca6d
commit a326ba40f4
28 changed files with 1277 additions and 82 deletions
+159 -1
View File
@@ -1,10 +1,12 @@
"""CLI commands for nanobot."""
import asyncio
import hashlib
import os
import select
import signal
import sys
import time
import uuid
from collections.abc import Callable
from contextlib import nullcontext, suppress
@@ -975,12 +977,19 @@ def _run_gateway(
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.channels.manager import ChannelManager
from nanobot.cron.automation import (
AUTOMATION_DEFER_UNTIL_IDLE_META,
AUTOMATION_TRIGGER_META,
)
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session.manager import SessionManager
from nanobot.session.routing import read_routing_context
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.utils.prompt_templates import render_template
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
@@ -1025,7 +1034,7 @@ def _run_gateway(
).subscribe(runtime_events)
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import OutboundMessage
from nanobot.bus.events import InboundMessage, OutboundMessage
def _channel_session_key(channel: str, chat_id: str) -> str:
return (
@@ -1034,6 +1043,152 @@ def _run_gateway(
else f"{channel}:{chat_id}"
)
def _session_metadata(session_key: str) -> dict[str, Any]:
data = session_manager.read_session_file(session_key)
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
return dict(metadata) if isinstance(metadata, dict) else {}
def _bound_session_delivery_context(
session_key: str,
*,
turn_seed: str,
source_label: str | None,
) -> tuple[str, str, dict[str, Any]]:
if ":" not in session_key:
raise ValueError(f"bound cron session_key is invalid: {session_key!r}")
channel, rest = session_key.split(":", 1)
if not channel or not rest:
raise ValueError(f"bound cron session_key is invalid: {session_key!r}")
session_metadata = _session_metadata(session_key)
routed = read_routing_context(session_metadata)
if routed is not None:
channel, rest, metadata = routed
else:
metadata: dict[str, Any] = {}
if channel == "websocket":
metadata["webui"] = True
scope = session_metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
if isinstance(scope, dict):
metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(scope)
metadata.update(
_proactive_delivery_metadata(
"websocket",
metadata,
turn_seed=turn_seed,
source_label=source_label,
)
)
return channel, rest, metadata
if channel == "slack" and ":" in rest:
chat_id, thread_ts = rest.split(":", 1)
if thread_ts:
metadata["slack"] = {"thread_ts": thread_ts}
return channel, chat_id, metadata
return channel, rest, metadata
def _automation_prompt_ref(prompt: str) -> dict[str, Any]:
return {
"id": "cron.agent_turn.reminder",
"version": 1,
"sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
}
async def _run_bound_cron_job(job: CronJob) -> str | None:
session_key = job.payload.session_key
if not session_key:
raise ValueError(f"cron job {job.id} is missing payload.session_key")
prompt = render_template(
"agent/cron_reminder.md",
strip=True,
message=job.payload.message,
)
prompt_ref = _automation_prompt_ref(prompt)
run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}"
channel, chat_id, metadata = _bound_session_delivery_context(
session_key,
turn_seed=f"cron:{job.id}",
source_label=job.name,
)
metadata[AUTOMATION_TRIGGER_META] = {
"job_id": job.id,
"job_name": job.name,
"run_id": run_id,
"prompt_ref": prompt_ref,
"persist_content": (
f"Scheduled automation triggered: {job.name}\n\n{job.payload.message}"
),
}
metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] = True
cron.write_run_record(
run_id,
{
"job_id": job.id,
"job_name": job.name,
"session_key": session_key,
"status": "queued",
"prompt_ref": prompt_ref,
"prompt_vars": {"message": job.payload.message},
"rendered_prompt": prompt,
},
)
cron_tool = agent.tools.get("cron")
cron_token = None
if isinstance(cron_tool, CronTool):
cron_token = cron_tool.set_cron_context(True)
try:
resp = await agent.submit_automation_turn(
InboundMessage(
channel=channel,
sender_id="cron",
chat_id=chat_id,
content=prompt,
metadata=metadata,
session_key_override=session_key,
)
)
except (Exception, asyncio.CancelledError) as exc:
error_text = str(exc) or exc.__class__.__name__
cron.write_run_record(
run_id,
{
"job_id": job.id,
"job_name": job.name,
"session_key": session_key,
"status": "error",
"error": error_text,
"prompt_ref": prompt_ref,
"prompt_vars": {"message": job.payload.message},
"rendered_prompt": prompt,
},
)
raise
finally:
if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token)
response = resp.content if resp else ""
cron.write_run_record(
run_id,
{
"job_id": job.id,
"job_name": job.name,
"session_key": session_key,
"status": "ok",
"prompt_ref": prompt_ref,
"prompt_vars": {"message": job.payload.message},
"rendered_prompt": prompt,
"response": response,
},
)
return response
async def _deliver_to_channel(
msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
) -> None:
@@ -1194,6 +1349,9 @@ def _run_gateway(
logger.info("Heartbeat: silenced by post-run evaluation")
return response
if job.payload.kind == "agent_turn" and job.payload.session_key:
return await _run_bound_cron_job(job)
reminder_note = (
"The scheduled time has arrived. Deliver this reminder to the user now, "
"as a brief and natural message in their language. Speak directly to them — "