refactor(trigger): share automation turn delivery

This commit is contained in:
chengyongru 2026-07-01 21:09:36 +08:00 committed by Xubin Ren
parent afef27dd6c
commit acb0e853ff
16 changed files with 413 additions and 299 deletions

View File

@ -78,17 +78,18 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
Replace `"Review PR #4502"` with the message you want nanobot to receive. The Replace `"Review PR #4502"` with the message you want nanobot to receive. The
trigger is bound to the session where it was created, so the message goes back trigger is bound to the session where it was created, so the message goes back
to that same chat. Keep `nanobot gateway` running so trigger messages can be to that same chat. Keep `nanobot gateway` running so trigger messages can be
delivered. The trigger message starts an automation turn; it is not shown in delivered. The trigger message starts an automation turn recorded in that
the chat as a normal user message. If that session is already running a turn, session with the message you passed to the CLI; it is not treated as a normal
the trigger waits until the session is idle instead of being injected into the user message. If that session is already running a turn, the trigger waits
active turn. until the session is idle instead of being injected into the active turn.
Trigger deliveries are stored in the workspace until their linked agent turn Trigger deliveries are stored in the workspace until their linked agent turn
finishes successfully. If the gateway exits after claiming a delivery but before finishes successfully. If the gateway exits after claiming a delivery but before
the turn completes, the next gateway start requeues that delivery. This is an the turn completes, the next gateway start requeues that delivery. This is an
at-least-once local queue: a delivery may run more than once if the process at-least-once local queue: a delivery may run more than once if the process
exits at the wrong time, so external scripts should make repeated trigger exits at the wrong time, so external scripts should make repeated trigger
messages safe. messages safe. If the delivery reaches the agent and the agent turn fails, the
delivery is marked failed in Automations instead of retrying forever.
For longer or generated content, omit the message argument and pipe stdin: For longer or generated content, omit the message argument and pipe stdin:

View File

@ -133,7 +133,8 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
``` ```
Keep `nanobot gateway` running so the message can be delivered to the linked Keep `nanobot gateway` running so the message can be delivered to the linked
chat/session. chat/session. The message is recorded as an automation turn in that session,
not as a normal chat message typed by the user.
The command writes to a workspace-local durable queue. If `nanobot gateway` is The command writes to a workspace-local durable queue. If `nanobot gateway` is
not running yet, the message waits in that workspace. If the target session is not running yet, the message waits in that workspace. If the target session is
@ -141,8 +142,9 @@ already running a turn, the trigger waits for that session to become idle. If th
gateway exits after claiming a delivery but before the linked turn completes, gateway exits after claiming a delivery but before the linked turn completes,
the next gateway start requeues that delivery. The queue is at-least-once, not the next gateway start requeues that delivery. The queue is at-least-once, not
exactly-once, so the same message can be delivered again after an interrupted exactly-once, so the same message can be delivered again after an interrupted
process. Run one gateway consumer per workspace; this local queue is not a process. If the agent receives the delivery and the turn fails, the delivery is
distributed multi-consumer queue. marked failed instead of retried indefinitely. Run one gateway consumer per
workspace; this local queue is not a distributed multi-consumer queue.
Use stdin when another local process generates the message: Use stdin when another local process generates the message:

View File

@ -150,8 +150,10 @@ nanobot to respond in that session. Webhook servers, third-party auth, and
event-to-message formatting stay outside nanobot. Trigger deliveries are stored event-to-message formatting stay outside nanobot. Trigger deliveries are stored
in the workspace until the linked agent turn finishes successfully. If the in the workspace until the linked agent turn finishes successfully. If the
target session is busy, the trigger waits until that session is idle instead of target session is busy, the trigger waits until that session is idle instead of
being injected into the active turn. Delivery is at-least-once, so external being injected into the active turn. The message is recorded as an automation
systems should tolerate repeated trigger messages. turn in that session. Delivery is at-least-once, so external systems should
tolerate repeated trigger messages; a delivery that reaches the agent but fails
is marked failed rather than retried forever.
## Where to Go Next ## Where to Go Next

View File

@ -137,7 +137,9 @@ restarts and are requeued if the process exits before the linked turn completes.
If the linked session is already running a turn, the local trigger waits until If the linked session is already running a turn, the local trigger waits until
that session is idle instead of being injected into the active turn. This is an that session is idle instead of being injected into the active turn. This is an
at-least-once local queue, so repeated delivery is possible after an interrupted at-least-once local queue, so repeated delivery is possible after an interrupted
process. process. A delivered trigger is recorded as an automation turn in the linked
session; if the agent receives it but the turn fails, Automations marks the run
failed instead of retrying indefinitely.
For recurring background checks that should stay quiet unless there is something For recurring background checks that should stay quiet unless there is something
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md` useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`

View File

@ -0,0 +1,145 @@
"""Shared coordination for session-bound automation turns."""
from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage
class AutomationTurnError(RuntimeError):
"""Raised when an automation turn reaches the agent and finishes with an error."""
async def publish_next_deferred_turn(
*,
deferred_queues: dict[str, list[InboundMessage]],
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
session_key: str,
) -> bool:
"""Publish the next deferred automation turn for a session."""
queue = deferred_queues.get(session_key)
if not queue:
return False
msg = queue.pop(0)
if not queue:
deferred_queues.pop(session_key, None)
await publish_inbound(msg)
return True
class AutomationTurnCoordinator:
"""Manage automation turns without mixing them into live injections."""
def __init__(
self,
*,
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
turn_id: Callable[[InboundMessage], str | None],
pending_id: Callable[[InboundMessage], str | None],
should_defer_turn: Callable[[InboundMessage, str, Iterable[str]], bool],
missing_id_error: str,
duplicate_id_error: Callable[[str], str],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self._turn_id = turn_id
self._pending_id = pending_id
self._should_defer_turn = should_defer_turn
self._missing_id_error = missing_id_error
self._duplicate_id_error = duplicate_id_error
self.deferred_queues = deferred_queues if deferred_queues is not None else {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_turn_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit an automation turn and wait for its session response."""
turn_id = self._turn_id(msg)
if not turn_id:
raise ValueError(self._missing_id_error)
if turn_id in self._waiters:
raise RuntimeError(self._duplicate_id_error(turn_id))
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[turn_id] = future
self._pending_messages_by_turn_id[turn_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
try:
return await future
except asyncio.CancelledError:
raise
except Exception as exc:
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
finally:
self._waiters.pop(turn_id, None)
self._pending_messages_by_turn_id.pop(turn_id, None)
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer an automation turn when its target session is already active."""
if not self._should_defer_turn(msg, session_key, active_session_keys):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.deferred_queues.setdefault(session_key, []).append(pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
turn_id = self._turn_id(msg)
if not turn_id:
return
future = self._waiters.get(turn_id)
if future is None or future.done():
return
if error is not None:
future.set_exception(error)
else:
future.set_result(response)
def pending_ids_for_session(self, session_key: str) -> set[str]:
"""Return automation IDs that are waiting for or running in *session_key*."""
pending_ids: set[str] = set()
for msg in self.deferred_queues.get(session_key, []):
pending_id = self._pending_id(msg)
if pending_id:
pending_ids.add(pending_id)
for msg in self._pending_messages_by_turn_id.values():
if msg.session_key != session_key:
continue
pending_id = self._pending_id(msg)
if pending_id:
pending_ids.add(pending_id)
return pending_ids
async def publish_next_deferred(self, session_key: str) -> bool:
return await publish_next_deferred_turn(
deferred_queues=self.deferred_queues,
publish_inbound=self._publish_inbound,
session_key=session_key,
)

View File

@ -2,11 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.agent.automation_turns import AutomationTurnCoordinator
from nanobot.bus.events import InboundMessage
from nanobot.cron.session_turns import ( from nanobot.cron.session_turns import (
cron_run_id, cron_run_id,
cron_trigger, cron_trigger,
@ -14,7 +13,7 @@ from nanobot.cron.session_turns import (
) )
class CronTurnCoordinator: class CronTurnCoordinator(AutomationTurnCoordinator):
"""Manage scheduled cron turns without mixing them into live injections.""" """Manage scheduled cron turns without mixing them into live injections."""
def __init__( def __init__(
@ -23,116 +22,31 @@ class CronTurnCoordinator:
publish_inbound: Callable[[InboundMessage], Awaitable[None]], publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]], dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool], is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None: ) -> None:
self._publish_inbound = publish_inbound super().__init__(
self._dispatch = dispatch publish_inbound=publish_inbound,
self._is_running = is_running dispatch=dispatch,
self.deferred_queues: dict[str, list[InboundMessage]] = {} is_running=is_running,
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} turn_id=lambda msg: cron_run_id(msg.metadata),
self._pending_messages_by_run_id: dict[str, InboundMessage] = {} pending_id=_cron_job_id,
should_defer_turn=_should_defer_cron_turn,
async def submit(self, msg: InboundMessage) -> OutboundMessage | None: missing_id_error="cron turn metadata must include a run_id",
"""Submit a scheduled cron turn and wait for its session response.""" duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
run_id = cron_run_id(msg.metadata) deferred_queues=deferred_queues,
if not run_id:
raise ValueError("cron turn metadata must include a run_id")
if run_id in self._waiters:
raise RuntimeError(f"cron run {run_id!r} is already pending")
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[run_id] = future
self._pending_messages_by_run_id[run_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
return await future
finally:
self._waiters.pop(run_id, None)
self._pending_messages_by_run_id.pop(run_id, None)
def should_defer(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return (
defer_cron_until_session_idle(msg.metadata)
and session_key in active_session_keys
) )
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer a cron turn when its target session is already active."""
if not self.should_defer(
msg,
session_key=session_key,
active_session_keys=active_session_keys,
):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.defer(session_key, pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
run_id = cron_run_id(msg.metadata)
if not run_id:
return
future = self._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(self, session_key: str, msg: InboundMessage) -> None:
self.deferred_queues.setdefault(session_key, []).append(msg)
def pending_job_ids_for_session(self, session_key: str) -> set[str]: def pending_job_ids_for_session(self, session_key: str) -> set[str]:
"""Return cron jobs that are waiting for or running in *session_key*.""" """Return cron jobs that are waiting for or running in *session_key*."""
job_ids: set[str] = set() return self.pending_ids_for_session(session_key)
for msg in self.deferred_queues.get(session_key, []):
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
for msg in self._pending_messages_by_run_id.values():
if msg.session_key != session_key:
continue
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
return job_ids
async def publish_next_deferred(self, session_key: str) -> bool:
queue = self.deferred_queues.get(session_key) def _should_defer_cron_turn(
if not queue: msg: InboundMessage,
return False session_key: str,
msg = queue.pop(0) active_session_keys: Iterable[str],
if not queue: ) -> bool:
self.deferred_queues.pop(session_key, None) return defer_cron_until_session_idle(msg.metadata) and session_key in active_session_keys
await self._publish_inbound(msg)
return True
def _cron_job_id(msg: InboundMessage) -> str | None: def _cron_job_id(msg: InboundMessage) -> str | None:

View File

@ -18,6 +18,7 @@ from loguru import logger
from nanobot.agent import context as agent_context from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.automation_turns import publish_next_deferred_turn
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.cron_turns import CronTurnCoordinator from nanobot.agent.cron_turns import CronTurnCoordinator
from nanobot.agent.hook import AgentHook, CompositeHook from nanobot.agent.hook import AgentHook, CompositeHook
@ -225,6 +226,7 @@ class AgentLoop:
runtime_events: RuntimeEventBus | None = None, runtime_events: RuntimeEventBus | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto", restart_mode: str = "auto",
local_trigger_store: Any | None = None,
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@ -272,6 +274,7 @@ class AgentLoop:
): ):
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service self.cron_service = cron_service
self.local_trigger_store = local_trigger_store
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.workspace_scopes = WorkspaceScopeResolver( self.workspace_scopes = WorkspaceScopeResolver(
default_workspace=workspace, default_workspace=workspace,
@ -316,15 +319,22 @@ class AgentLoop:
# When a session has an active task, new messages for that session # When a session has an active task, new messages for that session
# are routed here instead of creating a new task. # are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue] = {} self._pending_queues: dict[str, asyncio.Queue] = {}
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator( self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound, publish_inbound=self.bus.publish_inbound,
dispatch=self._dispatch, dispatch=self._dispatch,
is_running=lambda: self._running, is_running=lambda: self._running,
deferred_queues=self._deferred_automation_turns,
) )
self._local_trigger_turns = LocalTriggerTurnCoordinator( self._local_trigger_turns = LocalTriggerTurnCoordinator(
publish_inbound=self.bus.publish_inbound, publish_inbound=self.bus.publish_inbound,
dispatch=self._dispatch, dispatch=self._dispatch,
is_running=lambda: self._running, is_running=lambda: self._running,
deferred_queues=self._deferred_automation_turns,
)
self._automation_turn_coordinators = (
("cron", self._cron_turns),
("local trigger", self._local_trigger_turns),
) )
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3. # NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3")) _max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
@ -605,9 +615,11 @@ class AgentLoop:
return self._local_trigger_turns.pending_trigger_ids_for_session(session_key) return self._local_trigger_turns.pending_trigger_ids_for_session(session_key)
async def _publish_next_deferred_automation_turn(self, session_key: str) -> None: async def _publish_next_deferred_automation_turn(self, session_key: str) -> None:
if await self._cron_turns.publish_next_deferred(session_key): await publish_next_deferred_turn(
return deferred_queues=self._deferred_automation_turns,
await self._local_trigger_turns.publish_next_deferred(session_key) publish_inbound=self.bus.publish_inbound,
session_key=session_key,
)
def _persist_user_message_early( def _persist_user_message_early(
self, self,
@ -938,25 +950,21 @@ class AgentLoop:
self.commands.dispatch_priority, self.commands.dispatch_priority,
) )
continue continue
if self._cron_turns.defer_if_active( deferred = False
msg, for label, coordinator in self._automation_turn_coordinators:
session_key=effective_key, if coordinator.defer_if_active(
active_session_keys=self._pending_queues.keys(), msg,
): session_key=effective_key,
logger.info( active_session_keys=self._pending_queues.keys(),
"Deferred cron turn for active session {}", ):
effective_key, logger.info(
) "Deferred {} turn for active session {}",
continue label,
if self._local_trigger_turns.defer_if_active( effective_key,
msg, )
session_key=effective_key, deferred = True
active_session_keys=self._pending_queues.keys(), break
): if deferred:
logger.info(
"Deferred local trigger turn for active session {}",
effective_key,
)
continue continue
# If this session already has an active pending queue (i.e. a task # If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn # is processing this session), route the message there for mid-turn
@ -1079,17 +1087,11 @@ class AgentLoop:
session_key=session_key, session_key=session_key,
metadata=msg.metadata, metadata=msg.metadata,
) )
self._cron_turns.complete(msg, response=response) for _, coordinator in self._automation_turn_coordinators:
self._local_trigger_turns.complete(msg, response=response) coordinator.complete(msg, response=response)
except asyncio.CancelledError: except asyncio.CancelledError:
self._cron_turns.complete( for _, coordinator in self._automation_turn_coordinators:
msg, coordinator.complete(msg, error=asyncio.CancelledError())
error=asyncio.CancelledError(),
)
self._local_trigger_turns.complete(
msg,
error=asyncio.CancelledError(),
)
logger.info("Task cancelled for session {}", session_key) logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so # Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant # the user does not lose tool results and assistant
@ -1128,8 +1130,8 @@ class AgentLoop:
session_key=session_key, session_key=session_key,
metadata=msg.metadata, metadata=msg.metadata,
) )
self._cron_turns.complete(msg, error=exc) for _, coordinator in self._automation_turn_coordinators:
self._local_trigger_turns.complete(msg, error=exc) coordinator.complete(msg, error=exc)
finally: finally:
# Drain any messages still in the pending queue and re-publish # Drain any messages still in the pending queue and re-publish
# them to the bus so they are processed as fresh inbound messages # them to the bus so they are processed as fresh inbound messages
@ -1481,7 +1483,7 @@ class AgentLoop:
# message. Mark messages with _command so get_history can filter # message. Mark messages with _command so get_history can filter
# them out of LLM context. /new is excluded because it # them out of LLM context. /new is excluded because it
# intentionally clears the session. # intentionally clears the session.
if raw.lower() != "/new": if cmd_ctx.raw.lower() != "/new":
ctx.user_persisted_early = self._persist_user_message_early( ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session, _command=True ctx.msg, ctx.session, _command=True
) )

View File

@ -948,14 +948,13 @@ def _run_gateway(
runtime_events=runtime_events, runtime_events=runtime_events,
provider_signature=provider_snapshot.signature, provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)], hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
) )
WebuiTurnCoordinator( WebuiTurnCoordinator(
bus=bus, bus=bus,
sessions=session_manager, sessions=session_manager,
schedule_background=lambda coro: agent._schedule_background(coro), schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events) ).subscribe(runtime_events)
agent.local_trigger_store = trigger_store
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel from nanobot.session.keys import session_key_for_channel
@ -1302,7 +1301,6 @@ def _run_gateway(
asyncio.create_task( asyncio.create_task(
run_local_trigger_queue( run_local_trigger_queue(
store=trigger_store, store=trigger_store,
bus=bus,
submit_turn=getattr(agent, "submit_local_trigger_turn", None), submit_turn=getattr(agent, "submit_local_trigger_turn", None),
), ),
name="nanobot-local-triggers", name="nanobot-local-triggers",

View File

@ -753,11 +753,18 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
if store is None: if store is None:
store = LocalTriggerStore(workspace) store = LocalTriggerStore(workspace)
from nanobot.session.keys import UNIFIED_SESSION_KEY
session_key = (
ctx.msg.session_key
if ctx.key == UNIFIED_SESSION_KEY
else ctx.key
)
trigger = store.create( trigger = store.create(
name=name, name=name,
channel=ctx.msg.channel, channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id, chat_id=ctx.msg.chat_id,
session_key=ctx.key, session_key=session_key,
sender_id="trigger", sender_id="trigger",
origin_metadata=dict(ctx.msg.metadata or {}), origin_metadata=dict(ctx.msg.metadata or {}),
) )

View File

@ -9,8 +9,8 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.agent.automation_turns import AutomationTurnError
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
from nanobot.triggers.local_store import LocalTriggerStore from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery
@ -20,14 +20,13 @@ from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN
async def run_local_trigger_queue( async def run_local_trigger_queue(
*, *,
store: LocalTriggerStore, store: LocalTriggerStore,
bus: MessageBus | None = None,
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None = None, submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None = None,
poll_interval_s: float = 0.5, poll_interval_s: float = 0.5,
batch_size: int = 20, batch_size: int = 20,
) -> None: ) -> None:
"""Poll local trigger deliveries and publish them as normal inbound messages.""" """Poll local trigger deliveries and submit them as session turns."""
if bus is None and submit_turn is None: if submit_turn is None:
raise ValueError("run_local_trigger_queue requires bus or submit_turn") raise ValueError("run_local_trigger_queue requires submit_turn")
logger.info("Local trigger queue started") logger.info("Local trigger queue started")
recovered = store.recover_processing_deliveries() recovered = store.recover_processing_deliveries()
if recovered: if recovered:
@ -46,7 +45,6 @@ async def run_local_trigger_queue(
await _deliver_delivery( await _deliver_delivery(
store, store,
delivery, delivery,
bus=bus,
submit_turn=submit_turn, submit_turn=submit_turn,
) )
store.complete_delivery(delivery) store.complete_delivery(delivery)
@ -67,6 +65,21 @@ async def run_local_trigger_queue(
delivery.trigger_id, delivery.trigger_id,
exc, exc,
) )
except AutomationTurnError as exc:
error = str(exc) or exc.__class__.__name__
store.record_delivery(
delivery.trigger_id,
status="error",
error=error,
run_at_ms=delivery.created_at_ms,
)
store.complete_delivery(delivery)
logger.warning(
"Trigger: delivery {} for {} reached the agent but failed: {}",
delivery.id,
delivery.trigger_id,
error,
)
except Exception as exc: except Exception as exc:
error = str(exc) or exc.__class__.__name__ error = str(exc) or exc.__class__.__name__
retried = store.retry_delivery(delivery, error) retried = store.retry_delivery(delivery, error)
@ -92,8 +105,7 @@ async def _deliver_delivery(
store: LocalTriggerStore, store: LocalTriggerStore,
delivery: TriggerDelivery, delivery: TriggerDelivery,
*, *,
bus: MessageBus | None, submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]],
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None,
) -> None: ) -> None:
trigger = store.get(delivery.trigger_id) trigger = store.get(delivery.trigger_id)
if trigger is None: if trigger is None:
@ -109,12 +121,7 @@ async def _deliver_delivery(
metadata=_delivery_metadata(trigger, delivery), metadata=_delivery_metadata(trigger, delivery),
session_key_override=trigger.session_key, session_key_override=trigger.session_key,
) )
if submit_turn is not None: await submit_turn(msg)
await submit_turn(msg)
else:
if bus is None:
raise RuntimeError("bus unavailable for local trigger delivery")
await bus.publish_inbound(msg)
store.record_delivery( store.record_delivery(
trigger.id, trigger.id,
status="ok", status="ok",
@ -129,6 +136,7 @@ def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict
"trigger_name": trigger.name, "trigger_name": trigger.name,
"delivery_id": delivery.id, "delivery_id": delivery.id,
"created_at_ms": delivery.created_at_ms, "created_at_ms": delivery.created_at_ms,
"persist_content": _history_content(trigger, delivery),
} }
if trigger.channel == "websocket": if trigger.channel == "websocket":
metadata.pop(WEBUI_TURN_METADATA_KEY, None) metadata.pop(WEBUI_TURN_METADATA_KEY, None)
@ -138,3 +146,8 @@ def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict
source["label"] = trigger.name source["label"] = trigger.name
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
return metadata return metadata
def _history_content(trigger: LocalTrigger, delivery: TriggerDelivery) -> str:
label = trigger.name.strip() if trigger.name else trigger.id
return f"Local trigger received: {label}\n\n{delivery.content}"

View File

@ -14,6 +14,9 @@ LOCAL_TRIGGER_META = "_local_trigger"
def _local_trigger_history_text(trigger: Mapping[str, Any]) -> str: def _local_trigger_history_text(trigger: Mapping[str, Any]) -> str:
persist_content = trigger.get("persist_content")
if isinstance(persist_content, str) and persist_content.strip():
return persist_content
name = trigger.get("trigger_name") name = trigger.get("trigger_name")
trigger_id = trigger.get("trigger_id") trigger_id = trigger.get("trigger_id")
label = name if isinstance(name, str) and name.strip() else trigger_id label = name if isinstance(name, str) and name.strip() else trigger_id

View File

@ -2,15 +2,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.agent.automation_turns import AutomationTurnCoordinator
from nanobot.bus.events import InboundMessage
from nanobot.triggers.local_session_turns import local_trigger, local_trigger_delivery_id from nanobot.triggers.local_session_turns import local_trigger, local_trigger_delivery_id
class LocalTriggerTurnCoordinator: class LocalTriggerTurnCoordinator(AutomationTurnCoordinator):
"""Manage local trigger turns without mixing them into live injections.""" """Manage local trigger turns without mixing them into live injections."""
def __init__( def __init__(
@ -19,113 +18,33 @@ class LocalTriggerTurnCoordinator:
publish_inbound: Callable[[InboundMessage], Awaitable[None]], publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]], dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool], is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None: ) -> None:
self._publish_inbound = publish_inbound super().__init__(
self._dispatch = dispatch publish_inbound=publish_inbound,
self._is_running = is_running dispatch=dispatch,
self.deferred_queues: dict[str, list[InboundMessage]] = {} is_running=is_running,
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} turn_id=lambda msg: local_trigger_delivery_id(msg.metadata),
self._pending_messages_by_delivery_id: dict[str, InboundMessage] = {} pending_id=_local_trigger_id,
should_defer_turn=_should_defer_local_trigger_turn,
async def submit(self, msg: InboundMessage) -> OutboundMessage | None: missing_id_error="local trigger turn metadata must include a delivery_id",
"""Submit a local trigger turn and wait for its session response.""" duplicate_id_error=lambda delivery_id: (
delivery_id = local_trigger_delivery_id(msg.metadata) f"local trigger delivery {delivery_id!r} is already pending"
if not delivery_id: ),
raise ValueError("local trigger turn metadata must include a delivery_id") deferred_queues=deferred_queues,
if delivery_id in self._waiters: )
raise RuntimeError(f"local trigger delivery {delivery_id!r} is already pending")
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[delivery_id] = future
self._pending_messages_by_delivery_id[delivery_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
return await future
finally:
self._waiters.pop(delivery_id, None)
self._pending_messages_by_delivery_id.pop(delivery_id, None)
def should_defer(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return local_trigger(msg.metadata) is not None and session_key in active_session_keys
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer a local trigger turn when its target session is already active."""
if not self.should_defer(
msg,
session_key=session_key,
active_session_keys=active_session_keys,
):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.defer(session_key, pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
delivery_id = local_trigger_delivery_id(msg.metadata)
if not delivery_id:
return
future = self._waiters.get(delivery_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(self, session_key: str, msg: InboundMessage) -> None:
self.deferred_queues.setdefault(session_key, []).append(msg)
def pending_trigger_ids_for_session(self, session_key: str) -> set[str]: def pending_trigger_ids_for_session(self, session_key: str) -> set[str]:
"""Return local triggers waiting for or running in *session_key*.""" """Return local triggers waiting for or running in *session_key*."""
trigger_ids: set[str] = set() return self.pending_ids_for_session(session_key)
for msg in self.deferred_queues.get(session_key, []):
trigger_id = _local_trigger_id(msg)
if trigger_id:
trigger_ids.add(trigger_id)
for msg in self._pending_messages_by_delivery_id.values():
if msg.session_key != session_key:
continue
trigger_id = _local_trigger_id(msg)
if trigger_id:
trigger_ids.add(trigger_id)
return trigger_ids
async def publish_next_deferred(self, session_key: str) -> bool:
queue = self.deferred_queues.get(session_key) def _should_defer_local_trigger_turn(
if not queue: msg: InboundMessage,
return False session_key: str,
msg = queue.pop(0) active_session_keys: Iterable[str],
if not queue: ) -> bool:
self.deferred_queues.pop(session_key, None) return local_trigger(msg.metadata) is not None and session_key in active_session_keys
await self._publish_inbound(msg)
return True
def _local_trigger_id(msg: InboundMessage) -> str | None: def _local_trigger_id(msg: InboundMessage) -> str | None:

View File

@ -134,6 +134,7 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
"trigger_name": "PR review", "trigger_name": "PR review",
"delivery_id": "tdel_456", "delivery_id": "tdel_456",
"created_at_ms": 1_700_000_000_000, "created_at_ms": 1_700_000_000_000,
"persist_content": "Local trigger received: PR review\n\nReview PR #4502",
} }
}, },
), ),
@ -142,8 +143,7 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
assert persisted is True assert persisted is True
message = session.messages[-1] message = session.messages[-1]
assert message["content"] == "Local trigger received: PR review" assert message["content"] == "Local trigger received: PR review\n\nReview PR #4502"
assert "Review PR #4502" not in message["content"]
assert message[AUTOMATION_HISTORY_META] == { assert message[AUTOMATION_HISTORY_META] == {
"kind": "local_trigger", "kind": "local_trigger",
"trigger_id": "trg_123", "trigger_id": "trg_123",
@ -156,6 +156,25 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
assert message["trigger_delivery_id"] == "tdel_456" assert message["trigger_delivery_id"] == "tdel_456"
@pytest.mark.asyncio
async def test_new_with_bot_suffix_does_not_persist_command(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
response = await loop._process_message(
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-1",
content="/new@nanobot_bot",
)
)
assert response is not None
assert response.content == "New session started."
session = loop.sessions.get_or_create("websocket:chat-1")
assert session.messages == []
def test_clean_generated_title_strips_reasoning_tags() -> None: def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish" assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == "" assert clean_generated_title("Title: <think> The user said hello") == ""

View File

@ -2044,6 +2044,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod @classmethod
def from_config(cls, config, bus=None, **extra): def from_config(cls, config, bus=None, **extra):
seen["agent_from_config_kwargs"] = extra
return cls(**extra) return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
@ -2093,8 +2094,11 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
cli_commands._run_gateway(config, health_server_enabled=False) cli_commands._run_gateway(config, health_server_enabled=False)
agent = seen["agent"] agent = seen["agent"]
agent_kwargs = seen["agent_from_config_kwargs"]
kwargs = seen["local_trigger_queue_kwargs"] kwargs = seen["local_trigger_queue_kwargs"]
assert kwargs["bus"] is bus assert "local_trigger_store" in agent_kwargs
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
assert "bus" not in kwargs
assert kwargs["submit_turn"] is agent.submit_local_trigger_turn assert kwargs["submit_turn"] is agent.submit_local_trigger_turn

View File

@ -8,6 +8,7 @@ import pytest
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import build_help_text, register_builtin_commands from nanobot.command.builtin import build_help_text, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter from nanobot.command.router import CommandContext, CommandRouter
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.triggers.local_store import LocalTriggerStore from nanobot.triggers.local_store import LocalTriggerStore
@ -45,6 +46,37 @@ async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Pat
assert f"nanobot trigger {trigger.id} \"message\"" in response.content assert f"nanobot trigger {trigger.id} \"message\"" in response.content
@pytest.mark.asyncio
async def test_trigger_command_binds_inbound_session_when_unified_session_is_active(
tmp_path: Path,
) -> None:
router = CommandRouter()
register_builtin_commands(router)
store = LocalTriggerStore(tmp_path)
loop = SimpleNamespace(workspace=tmp_path, local_trigger_store=store)
msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-1",
content="/trigger PR review",
session_key_override="websocket:chat-1:thread-a",
)
ctx = CommandContext(
msg=msg,
session=None,
key=UNIFIED_SESSION_KEY,
raw="/trigger PR review",
loop=loop,
)
response = await router.dispatch(ctx)
assert response is not None
trigger = store.list_for_session("websocket:chat-1:thread-a")[0]
assert trigger.session_key == "websocket:chat-1:thread-a"
assert store.list_for_session(UNIFIED_SESSION_KEY) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None: async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None:
router = CommandRouter() router = CommandRouter()

View File

@ -6,6 +6,7 @@ from pathlib import Path
import pytest import pytest
from nanobot.agent.automation_turns import AutomationTurnError
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.triggers.local_runner import run_local_trigger_queue from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore, TriggerDisabledError from nanobot.triggers.local_store import LocalTriggerStore, TriggerDisabledError
@ -77,7 +78,7 @@ def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None: async def test_local_trigger_queue_submits_bound_inbound_message(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path) store = LocalTriggerStore(tmp_path)
trigger = store.create( trigger = store.create(
name="PR review", name="PR review",
@ -87,18 +88,18 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
origin_metadata={"webui": True, WEBUI_TURN_METADATA_KEY: "old-turn"}, origin_metadata={"webui": True, WEBUI_TURN_METADATA_KEY: "old-turn"},
) )
store.enqueue(trigger.id, "Review PR #4502") store.enqueue(trigger.id, "Review PR #4502")
published: list[InboundMessage] = [] submitted: list[InboundMessage] = []
class _Bus: async def _submit_turn(msg: InboundMessage):
async def publish_inbound(self, msg: InboundMessage) -> None: submitted.append(msg)
published.append(msg) return None
task = asyncio.create_task( task = asyncio.create_task(
run_local_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01) run_local_trigger_queue(store=store, submit_turn=_submit_turn, poll_interval_s=0.01)
) )
try: try:
for _ in range(100): for _ in range(100):
if published: if submitted:
break break
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
finally: finally:
@ -106,8 +107,8 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await task await task
assert len(published) == 1 assert len(submitted) == 1
msg = published[0] msg = submitted[0]
assert msg.channel == "websocket" assert msg.channel == "websocket"
assert msg.chat_id == "chat-1" assert msg.chat_id == "chat-1"
assert msg.sender_id == "trigger" assert msg.sender_id == "trigger"
@ -120,6 +121,10 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
"label": "PR review", "label": "PR review",
} }
assert msg.metadata["_local_trigger"]["trigger_id"] == trigger.id assert msg.metadata["_local_trigger"]["trigger_id"] == trigger.id
assert (
msg.metadata["_local_trigger"]["persist_content"]
== "Local trigger received: PR review\n\nReview PR #4502"
)
stored = store.get(trigger.id) stored = store.get(trigger.id)
assert stored is not None assert stored is not None
@ -227,6 +232,52 @@ async def test_local_trigger_queue_requeues_when_submitted_turn_is_interrupted(
await task await task
@pytest.mark.asyncio
async def test_local_trigger_queue_does_not_retry_completed_agent_failure(
tmp_path: Path,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="CI review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
store.enqueue(trigger.id, "Review failed CI")
started = asyncio.Event()
async def _submit_turn(_msg: InboundMessage):
started.set()
raise AutomationTurnError("model failed")
task = asyncio.create_task(
run_local_trigger_queue(
store=store,
submit_turn=_submit_turn,
poll_interval_s=0.01,
)
)
try:
await asyncio.wait_for(started.wait(), timeout=1)
for _ in range(100):
stored = store.get(trigger.id)
if stored and stored.last_status == "error":
break
await asyncio.sleep(0.01)
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status == "error"
assert stored.last_error == "model failed"
assert store.claim_deliveries() == []
assert not list(store.processing_dir.glob("*.json"))
assert not list(store.failed_dir.glob("*.json"))
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_local_trigger_queue_recovers_processing_delivery_on_start( async def test_local_trigger_queue_recovers_processing_delivery_on_start(
tmp_path: Path, tmp_path: Path,
@ -240,19 +291,19 @@ async def test_local_trigger_queue_recovers_processing_delivery_on_start(
) )
store.enqueue(trigger.id, "Review PR #4591") store.enqueue(trigger.id, "Review PR #4591")
assert len(store.claim_deliveries()) == 1 assert len(store.claim_deliveries()) == 1
published: list[InboundMessage] = [] submitted: list[InboundMessage] = []
class _Bus: async def _submit_turn(msg: InboundMessage):
async def publish_inbound(self, msg: InboundMessage) -> None: submitted.append(msg)
published.append(msg) return None
restarted = LocalTriggerStore(tmp_path) restarted = LocalTriggerStore(tmp_path)
task = asyncio.create_task( task = asyncio.create_task(
run_local_trigger_queue(store=restarted, bus=_Bus(), poll_interval_s=0.01) run_local_trigger_queue(store=restarted, submit_turn=_submit_turn, poll_interval_s=0.01)
) )
try: try:
for _ in range(100): for _ in range(100):
if published: if submitted:
break break
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
finally: finally:
@ -260,7 +311,7 @@ async def test_local_trigger_queue_recovers_processing_delivery_on_start(
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await task await task
assert len(published) == 1 assert len(submitted) == 1
assert published[0].content == "Review PR #4591" assert submitted[0].content == "Review PR #4591"
assert published[0].metadata["_local_trigger"]["trigger_id"] == trigger.id assert submitted[0].metadata["_local_trigger"]["trigger_id"] == trigger.id
assert restarted.claim_deliveries() == [] assert restarted.claim_deliveries() == []