feat(cron): bind scheduled automations to sessions
This commit is contained in:
+97
-1
@@ -39,6 +39,11 @@ 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_run_id,
|
||||
automation_trigger,
|
||||
defer_until_session_idle,
|
||||
)
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.security.workspace_access import (
|
||||
@@ -53,6 +58,7 @@ from nanobot.session.goal_state import (
|
||||
sustained_goal_active,
|
||||
)
|
||||
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
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
@@ -300,6 +306,10 @@ class AgentLoop:
|
||||
# When a session has an active task, new messages for that session
|
||||
# are routed here instead of creating a new task.
|
||||
self._pending_queues: dict[str, asyncio.Queue] = {}
|
||||
# Scheduled automations wait for the current visible turn to finish.
|
||||
# They must not be injected into the active model call as follow-up text.
|
||||
self._deferred_automation_queues: dict[str, list[InboundMessage]] = {}
|
||||
self._automation_waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
|
||||
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
|
||||
self._concurrency_gate: asyncio.Semaphore | None = (
|
||||
@@ -565,6 +575,55 @@ class AgentLoop:
|
||||
def _runtime_events(self) -> RuntimeEventPublisher:
|
||||
return ensure_runtime_event_publisher(self)
|
||||
|
||||
async def submit_automation_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""Submit a scheduled automation as an internal session turn and wait for it."""
|
||||
run_id = automation_run_id(msg.metadata)
|
||||
if not run_id:
|
||||
raise ValueError("automation turn metadata must include a run_id")
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||
if run_id in self._automation_waiters:
|
||||
raise RuntimeError(f"automation run {run_id!r} is already pending")
|
||||
self._automation_waiters[run_id] = future
|
||||
try:
|
||||
if self._running:
|
||||
await self.bus.publish_inbound(msg)
|
||||
else:
|
||||
await self._dispatch(msg)
|
||||
return await future
|
||||
finally:
|
||||
self._automation_waiters.pop(run_id, None)
|
||||
|
||||
def _complete_automation_turn(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
response: OutboundMessage | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
run_id = automation_run_id(msg.metadata)
|
||||
if not run_id:
|
||||
return
|
||||
future = self._automation_waiters.get(run_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error is not None:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(response)
|
||||
|
||||
def _defer_automation_turn(self, session_key: str, msg: InboundMessage) -> None:
|
||||
self._deferred_automation_queues.setdefault(session_key, []).append(msg)
|
||||
|
||||
async def _publish_next_deferred_automation(self, session_key: str) -> None:
|
||||
queue = self._deferred_automation_queues.get(session_key)
|
||||
if not queue:
|
||||
return
|
||||
msg = queue.pop(0)
|
||||
if not queue:
|
||||
self._deferred_automation_queues.pop(session_key, None)
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
def _persist_user_message_early(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
@@ -583,6 +642,17 @@ class AgentLoop:
|
||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||
extra.update(kwargs)
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
if trigger := automation_trigger(msg.metadata):
|
||||
persist_content = trigger.get("persist_content")
|
||||
if isinstance(persist_content, str) and persist_content.strip():
|
||||
text = persist_content
|
||||
extra.update({
|
||||
"_automation_trigger": True,
|
||||
"automation_id": trigger.get("job_id"),
|
||||
"automation_name": trigger.get("job_name"),
|
||||
"automation_run_id": trigger.get("run_id"),
|
||||
"automation_prompt_ref": trigger.get("prompt_ref"),
|
||||
})
|
||||
session.add_message("user", text, **extra)
|
||||
self._mark_pending_user_turn(session)
|
||||
self.sessions.save(session)
|
||||
@@ -883,6 +953,22 @@ class AgentLoop:
|
||||
self.commands.dispatch_priority,
|
||||
)
|
||||
continue
|
||||
if (
|
||||
defer_until_session_idle(msg.metadata)
|
||||
and effective_key in self._pending_queues
|
||||
):
|
||||
pending_msg = msg
|
||||
if effective_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
msg,
|
||||
session_key_override=effective_key,
|
||||
)
|
||||
self._defer_automation_turn(effective_key, pending_msg)
|
||||
logger.info(
|
||||
"Deferred automation turn for active session {}",
|
||||
effective_key,
|
||||
)
|
||||
continue
|
||||
# If this session already has an active pending queue (i.e. a task
|
||||
# is processing this session), route the message there for mid-turn
|
||||
# injection instead of creating a competing task.
|
||||
@@ -996,7 +1082,12 @@ class AgentLoop:
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._complete_automation_turn(msg, response=response)
|
||||
except asyncio.CancelledError:
|
||||
self._complete_automation_turn(
|
||||
msg,
|
||||
error=asyncio.CancelledError(),
|
||||
)
|
||||
logger.info("Task cancelled for session {}", session_key)
|
||||
# Preserve partial context from the interrupted turn so
|
||||
# the user does not lose tool results and assistant
|
||||
@@ -1022,7 +1113,7 @@ class AgentLoop:
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("Error processing message for session {}", session_key)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
@@ -1035,6 +1126,7 @@ class AgentLoop:
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._complete_automation_turn(msg, error=exc)
|
||||
finally:
|
||||
# Drain any messages still in the pending queue and re-publish
|
||||
# them to the bus so they are processed as fresh inbound messages
|
||||
@@ -1065,12 +1157,14 @@ class AgentLoop:
|
||||
msg, session_key, "idle"
|
||||
)
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
await self._publish_next_deferred_automation(session_key)
|
||||
finally:
|
||||
if pending is None:
|
||||
await self._runtime_events().run_status_changed(
|
||||
msg, session_key, "idle"
|
||||
)
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
await self._publish_next_deferred_automation(session_key)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Drain pending background archives, then close MCP connections."""
|
||||
@@ -1342,6 +1436,8 @@ class AgentLoop:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
await self._runtime_events().session_turn_started(msg, ctx.session_key)
|
||||
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
||||
if persist_routing_context(ctx.session, msg):
|
||||
self.sessions.save(ctx.session)
|
||||
|
||||
if self._restore_runtime_checkpoint(ctx.session):
|
||||
self.sessions.save(ctx.session)
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
@@ -38,10 +37,6 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
||||
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
|
||||
"Naive values use the tool's default timezone."
|
||||
),
|
||||
deliver=BooleanSchema(
|
||||
description="Whether to deliver the execution result to the user channel (default true)",
|
||||
default=True,
|
||||
),
|
||||
job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
|
||||
required=["action"],
|
||||
description=(
|
||||
@@ -76,11 +71,11 @@ class CronTool(Tool, ContextAware):
|
||||
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
|
||||
|
||||
def set_context(self, ctx: RequestContext) -> None:
|
||||
"""Set the current session context for delivery."""
|
||||
"""Set the current session context for scheduled automation ownership."""
|
||||
self._channel.set(ctx.channel)
|
||||
self._chat_id.set(ctx.chat_id)
|
||||
self._metadata.set(ctx.metadata)
|
||||
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
|
||||
self._session_key.set(ctx.session_key or "")
|
||||
|
||||
def set_cron_context(self, active: bool):
|
||||
"""Mark whether the tool is executing inside a cron job callback."""
|
||||
@@ -170,10 +165,9 @@ class CronTool(Tool, ContextAware):
|
||||
"describing what to do when the job triggers "
|
||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||
)
|
||||
channel = self._channel.get()
|
||||
chat_id = self._chat_id.get()
|
||||
if not channel or not chat_id:
|
||||
return "Error: no session context (channel/chat_id)"
|
||||
session_key = self._session_key.get()
|
||||
if not session_key:
|
||||
return "Error: scheduled automations must be created from a chat session"
|
||||
if tz and not cron_expr:
|
||||
return "Error: tz can only be used with cron_expr"
|
||||
if tz:
|
||||
@@ -210,12 +204,8 @@ class CronTool(Tool, ContextAware):
|
||||
name=name or message[:30],
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=deliver,
|
||||
channel=channel,
|
||||
to=chat_id,
|
||||
delete_after_run=delete_after,
|
||||
channel_meta=self._metadata.get(),
|
||||
session_key=self._session_key.get() or None,
|
||||
session_key=session_key,
|
||||
)
|
||||
return f"Created job '{job.name}' (id: {job.id})"
|
||||
|
||||
|
||||
+159
-1
@@ -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 — "
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared metadata helpers for scheduled automation turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
AUTOMATION_TRIGGER_META = "_automation_trigger"
|
||||
AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle"
|
||||
|
||||
|
||||
def automation_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Return structured automation trigger metadata when present."""
|
||||
raw = (metadata or {}).get(AUTOMATION_TRIGGER_META)
|
||||
return raw if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
def is_automation_turn(metadata: Mapping[str, Any] | None) -> bool:
|
||||
return automation_trigger(metadata) is not None
|
||||
|
||||
|
||||
def defer_until_session_idle(metadata: Mapping[str, Any] | None) -> bool:
|
||||
return bool(
|
||||
is_automation_turn(metadata)
|
||||
and (metadata or {}).get(AUTOMATION_DEFER_UNTIL_IDLE_META) is True
|
||||
)
|
||||
|
||||
|
||||
def automation_run_id(metadata: Mapping[str, Any] | None) -> str | None:
|
||||
trigger = automation_trigger(metadata)
|
||||
if not trigger:
|
||||
return None
|
||||
value = trigger.get("run_id")
|
||||
return value if isinstance(value, str) and value else None
|
||||
@@ -84,6 +84,7 @@ class CronService:
|
||||
):
|
||||
self.store_path = store_path
|
||||
self._action_path = store_path.parent / "action.jsonl"
|
||||
self._run_records_dir = store_path.parent / "runs"
|
||||
self._lock = FileLock(str(self._action_path.parent) + ".lock")
|
||||
self.on_job = on_job
|
||||
self._store: CronStore | None = None
|
||||
@@ -325,6 +326,23 @@ class CronService:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _safe_run_record_name(run_id: str) -> str:
|
||||
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
|
||||
|
||||
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
||||
"""Write an internal audit record for one cron execution."""
|
||||
name = self._safe_run_record_name(run_id)
|
||||
if not name:
|
||||
name = str(uuid.uuid4())
|
||||
path = self._run_records_dir / f"{name}.json"
|
||||
payload = {
|
||||
**record,
|
||||
"run_id": run_id,
|
||||
"updated_at_ms": _now_ms(),
|
||||
}
|
||||
self._atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the cron service."""
|
||||
self._running = True
|
||||
@@ -473,6 +491,20 @@ class CronService:
|
||||
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
||||
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
|
||||
|
||||
def list_bound_agent_jobs_for_session(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
) -> list[CronJob]:
|
||||
"""Return user-created bound automation jobs owned by *session_key*."""
|
||||
return [
|
||||
job
|
||||
for job in self.list_jobs(include_disabled=include_disabled)
|
||||
if job.payload.kind == "agent_turn"
|
||||
and job.payload.session_key == session_key
|
||||
]
|
||||
|
||||
def add_job(
|
||||
self,
|
||||
name: str,
|
||||
|
||||
@@ -36,6 +36,7 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
||||
"pending_user_turn",
|
||||
"runtime_checkpoint",
|
||||
"thread_goal",
|
||||
"_routing_context",
|
||||
"title",
|
||||
"title_user_edited",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Persisted session routing context for proactive turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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"
|
||||
|
||||
_ROUTING_METADATA_KEYS = {
|
||||
"chat_type",
|
||||
"context_chat_id",
|
||||
"conversation_type",
|
||||
"event_id",
|
||||
"message_thread_id",
|
||||
"msg_type",
|
||||
"parent_channel_id",
|
||||
"parent_id",
|
||||
"platform",
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"thread_reply_to_event_id",
|
||||
"thread_root_event_id",
|
||||
}
|
||||
_CHANNEL_ROUTING_METADATA_KEYS = {
|
||||
# Feishu needs a message anchor to reply into an existing topic. Other
|
||||
# channels should avoid stale reply anchors for scheduled automation turns.
|
||||
"feishu": {"message_id"},
|
||||
}
|
||||
_SLACK_ROUTING_KEYS = {"channel_type", "thread_ts"}
|
||||
|
||||
|
||||
def _scalar(value: Any) -> str | int | float | bool | None:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _routing_metadata(channel: str, metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(metadata, Mapping):
|
||||
return {}
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
keys = _ROUTING_METADATA_KEYS | _CHANNEL_ROUTING_METADATA_KEYS.get(channel, set())
|
||||
for key in keys:
|
||||
if key not in metadata:
|
||||
continue
|
||||
value = _scalar(metadata.get(key))
|
||||
if value is not None:
|
||||
out[key] = value
|
||||
|
||||
slack = metadata.get("slack")
|
||||
if isinstance(slack, Mapping):
|
||||
slack_out = {
|
||||
key: value
|
||||
for key in _SLACK_ROUTING_KEYS
|
||||
if (value := _scalar(slack.get(key))) is not None
|
||||
}
|
||||
if slack_out:
|
||||
out["slack"] = slack_out
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def routing_context_for_message(msg: InboundMessage) -> dict[str, Any]:
|
||||
"""Return the stable routing context needed to deliver future session turns."""
|
||||
return {
|
||||
"channel": msg.channel,
|
||||
"chat_id": msg.chat_id,
|
||||
"metadata": _routing_metadata(msg.channel, msg.metadata),
|
||||
}
|
||||
|
||||
|
||||
def persist_routing_context(session: Session, msg: InboundMessage) -> bool:
|
||||
"""Persist the latest non-automation delivery context for a session."""
|
||||
if is_automation_turn(msg.metadata):
|
||||
return False
|
||||
context = routing_context_for_message(msg)
|
||||
if session.metadata.get(SESSION_ROUTING_METADATA_KEY) == context:
|
||||
return False
|
||||
session.metadata[SESSION_ROUTING_METADATA_KEY] = context
|
||||
return True
|
||||
|
||||
|
||||
def read_routing_context(metadata: Mapping[str, Any] | None) -> tuple[str, str, dict[str, Any]] | None:
|
||||
"""Decode a persisted routing context from session metadata."""
|
||||
if not isinstance(metadata, Mapping):
|
||||
return None
|
||||
raw = metadata.get(SESSION_ROUTING_METADATA_KEY)
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
|
||||
channel = raw.get("channel")
|
||||
chat_id = raw.get("chat_id")
|
||||
if not isinstance(channel, str) or not channel:
|
||||
return None
|
||||
if not isinstance(chat_id, str) or not chat_id:
|
||||
return None
|
||||
|
||||
route_meta = raw.get("metadata")
|
||||
metadata_out = dict(route_meta) if isinstance(route_meta, Mapping) else {}
|
||||
return channel, chat_id, metadata_out
|
||||
@@ -0,0 +1,9 @@
|
||||
The scheduled time has arrived. Execute this scheduled automation now and report the result to the user in the same session.
|
||||
|
||||
Rules:
|
||||
- Speak directly to the user in their language.
|
||||
- Do not narrate internal progress.
|
||||
- Do not include user IDs.
|
||||
- Do not add status reports like "Done" or "Reminded" unless they are the natural response.
|
||||
|
||||
Automation: {{ message }}
|
||||
@@ -8,7 +8,25 @@ from nanobot.cron.types import CronJob
|
||||
|
||||
|
||||
class _CronServiceLike(Protocol):
|
||||
def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ...
|
||||
def list_bound_agent_jobs_for_session(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
) -> list[CronJob]: ...
|
||||
|
||||
|
||||
def bound_session_automation_jobs(
|
||||
cron_service: _CronServiceLike | None,
|
||||
session_key: str,
|
||||
) -> list[CronJob]:
|
||||
"""Return agent-turn automation jobs explicitly bound to *session_key*."""
|
||||
if cron_service is None:
|
||||
return []
|
||||
return cron_service.list_bound_agent_jobs_for_session(
|
||||
session_key,
|
||||
include_disabled=True,
|
||||
)
|
||||
|
||||
|
||||
def session_automations_payload(
|
||||
@@ -16,22 +34,11 @@ def session_automations_payload(
|
||||
session_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return user-created automation jobs attached to a WebUI session."""
|
||||
jobs: list[CronJob] = []
|
||||
if cron_service is not None:
|
||||
all_jobs = cron_service.list_jobs(include_disabled=True)
|
||||
jobs = [job for job in all_jobs if _job_matches_session(job, session_key)]
|
||||
return {"jobs": [_serialize_job(job) for job in jobs]}
|
||||
return {"jobs": serialize_automation_jobs(bound_session_automation_jobs(cron_service, session_key))}
|
||||
|
||||
|
||||
def _job_matches_session(job: CronJob, session_key: str) -> bool:
|
||||
payload = job.payload
|
||||
if payload.kind != "agent_turn":
|
||||
return False
|
||||
if payload.session_key:
|
||||
return payload.session_key == session_key
|
||||
if payload.channel and payload.to:
|
||||
return f"{payload.channel}:{payload.to}" == session_key
|
||||
return False
|
||||
def serialize_automation_jobs(jobs: list[CronJob]) -> list[dict[str, Any]]:
|
||||
return [_serialize_job(job) for job in jobs]
|
||||
|
||||
|
||||
def _serialize_job(job: CronJob) -> dict[str, Any]:
|
||||
|
||||
@@ -61,7 +61,11 @@ from nanobot.webui.http_utils import (
|
||||
safe_host_header as _safe_host_header,
|
||||
)
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.session_automations import session_automations_payload
|
||||
from nanobot.webui.session_automations import (
|
||||
bound_session_automation_jobs,
|
||||
serialize_automation_jobs,
|
||||
session_automations_payload,
|
||||
)
|
||||
from nanobot.webui.session_list_index import list_webui_sessions
|
||||
from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
@@ -446,6 +450,20 @@ class GatewayHTTPHandler:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
query = _parse_query(request.path)
|
||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||
bound_jobs = bound_session_automation_jobs(self.cron_service, decoded_key)
|
||||
if bound_jobs and delete_automations not in {"1", "true", "yes"}:
|
||||
return _http_json_response(
|
||||
{
|
||||
"deleted": False,
|
||||
"blocked_by_automations": True,
|
||||
"automations": serialize_automation_jobs(bound_jobs),
|
||||
}
|
||||
)
|
||||
if bound_jobs and self.cron_service is not None:
|
||||
for job in bound_jobs:
|
||||
self.cron_service.remove_job(job.id)
|
||||
deleted = self.session_manager.delete_session(decoded_key)
|
||||
delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
Reference in New Issue
Block a user