refactor(trigger): share automation turn delivery

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent afef27dd6c
commit acb0e853ff
16 changed files with 413 additions and 299 deletions
+6 -5
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
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
delivered. The trigger message starts an automation turn; it is not shown in
the chat as a normal user message. If that session is already running a turn,
the trigger waits until the session is idle instead of being injected into the
active turn.
delivered. The trigger message starts an automation turn recorded in that
session with the message you passed to the CLI; it is not treated as a normal
user message. If that session is already running a turn, the trigger waits
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
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
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
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:
+5 -3
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
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
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,
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
process. Run one gateway consumer per workspace; this local queue is not a
distributed multi-consumer queue.
process. If the agent receives the delivery and the turn fails, the delivery is
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:
+4 -2
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
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
being injected into the active turn. Delivery is at-least-once, so external
systems should tolerate repeated trigger messages.
being injected into the active turn. The message is recorded as an automation
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
+3 -1
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
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
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
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
+145
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,
)
+22 -108
View File
@@ -2,11 +2,10 @@
from __future__ import annotations
import asyncio
import dataclasses
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 (
cron_run_id,
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."""
def __init__(
@@ -23,116 +22,31 @@ class CronTurnCoordinator:
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self.deferred_queues: dict[str, list[InboundMessage]] = {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_run_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit a scheduled cron turn and wait for its session response."""
run_id = cron_run_id(msg.metadata)
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
super().__init__(
publish_inbound=publish_inbound,
dispatch=dispatch,
is_running=is_running,
turn_id=lambda msg: cron_run_id(msg.metadata),
pending_id=_cron_job_id,
should_defer_turn=_should_defer_cron_turn,
missing_id_error="cron turn metadata must include a run_id",
duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
deferred_queues=deferred_queues,
)
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]:
"""Return cron jobs that are waiting for or running in *session_key*."""
job_ids: set[str] = set()
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
return self.pending_ids_for_session(session_key)
async def publish_next_deferred(self, session_key: str) -> bool:
queue = self.deferred_queues.get(session_key)
if not queue:
return False
msg = queue.pop(0)
if not queue:
self.deferred_queues.pop(session_key, None)
await self._publish_inbound(msg)
return True
def _should_defer_cron_turn(
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 _cron_job_id(msg: InboundMessage) -> str | None:
+37 -35
View File
@@ -18,6 +18,7 @@ from loguru import logger
from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers
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.cron_turns import CronTurnCoordinator
from nanobot.agent.hook import AgentHook, CompositeHook
@@ -225,6 +226,7 @@ class AgentLoop:
runtime_events: RuntimeEventBus | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto",
local_trigger_store: Any | None = None,
):
from nanobot.config.schema import ToolsConfig
@@ -272,6 +274,7 @@ class AgentLoop:
):
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service
self.local_trigger_store = local_trigger_store
self.restrict_to_workspace = restrict_to_workspace
self.workspace_scopes = WorkspaceScopeResolver(
default_workspace=workspace,
@@ -316,15 +319,22 @@ 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] = {}
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound,
dispatch=self._dispatch,
is_running=lambda: self._running,
deferred_queues=self._deferred_automation_turns,
)
self._local_trigger_turns = LocalTriggerTurnCoordinator(
publish_inbound=self.bus.publish_inbound,
dispatch=self._dispatch,
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.
_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)
async def _publish_next_deferred_automation_turn(self, session_key: str) -> None:
if await self._cron_turns.publish_next_deferred(session_key):
return
await self._local_trigger_turns.publish_next_deferred(session_key)
await publish_next_deferred_turn(
deferred_queues=self._deferred_automation_turns,
publish_inbound=self.bus.publish_inbound,
session_key=session_key,
)
def _persist_user_message_early(
self,
@@ -938,25 +950,21 @@ class AgentLoop:
self.commands.dispatch_priority,
)
continue
if self._cron_turns.defer_if_active(
msg,
session_key=effective_key,
active_session_keys=self._pending_queues.keys(),
):
logger.info(
"Deferred cron turn for active session {}",
effective_key,
)
continue
if self._local_trigger_turns.defer_if_active(
msg,
session_key=effective_key,
active_session_keys=self._pending_queues.keys(),
):
logger.info(
"Deferred local trigger turn for active session {}",
effective_key,
)
deferred = False
for label, coordinator in self._automation_turn_coordinators:
if coordinator.defer_if_active(
msg,
session_key=effective_key,
active_session_keys=self._pending_queues.keys(),
):
logger.info(
"Deferred {} turn for active session {}",
label,
effective_key,
)
deferred = True
break
if deferred:
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
@@ -1079,17 +1087,11 @@ class AgentLoop:
session_key=session_key,
metadata=msg.metadata,
)
self._cron_turns.complete(msg, response=response)
self._local_trigger_turns.complete(msg, response=response)
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(msg, response=response)
except asyncio.CancelledError:
self._cron_turns.complete(
msg,
error=asyncio.CancelledError(),
)
self._local_trigger_turns.complete(
msg,
error=asyncio.CancelledError(),
)
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(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
@@ -1128,8 +1130,8 @@ class AgentLoop:
session_key=session_key,
metadata=msg.metadata,
)
self._cron_turns.complete(msg, error=exc)
self._local_trigger_turns.complete(msg, error=exc)
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(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
@@ -1481,7 +1483,7 @@ class AgentLoop:
# message. Mark messages with _command so get_history can filter
# them out of LLM context. /new is excluded because it
# intentionally clears the session.
if raw.lower() != "/new":
if cmd_ctx.raw.lower() != "/new":
ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session, _command=True
)
+1 -3
View File
@@ -948,14 +948,13 @@ def _run_gateway(
runtime_events=runtime_events,
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
)
WebuiTurnCoordinator(
bus=bus,
sessions=session_manager,
schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events)
agent.local_trigger_store = trigger_store
from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel
@@ -1302,7 +1301,6 @@ def _run_gateway(
asyncio.create_task(
run_local_trigger_queue(
store=trigger_store,
bus=bus,
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
),
name="nanobot-local-triggers",
+8 -1
View File
@@ -753,11 +753,18 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
if store is None:
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(
name=name,
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
session_key=ctx.key,
session_key=session_key,
sender_id="trigger",
origin_metadata=dict(ctx.msg.metadata or {}),
)
+27 -14
View File
@@ -9,8 +9,8 @@ from typing import Any
from loguru import logger
from nanobot.agent.automation_turns import AutomationTurnError
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_store import LocalTriggerStore
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(
*,
store: LocalTriggerStore,
bus: MessageBus | None = None,
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None = None,
poll_interval_s: float = 0.5,
batch_size: int = 20,
) -> None:
"""Poll local trigger deliveries and publish them as normal inbound messages."""
if bus is None and submit_turn is None:
raise ValueError("run_local_trigger_queue requires bus or submit_turn")
"""Poll local trigger deliveries and submit them as session turns."""
if submit_turn is None:
raise ValueError("run_local_trigger_queue requires submit_turn")
logger.info("Local trigger queue started")
recovered = store.recover_processing_deliveries()
if recovered:
@@ -46,7 +45,6 @@ async def run_local_trigger_queue(
await _deliver_delivery(
store,
delivery,
bus=bus,
submit_turn=submit_turn,
)
store.complete_delivery(delivery)
@@ -67,6 +65,21 @@ async def run_local_trigger_queue(
delivery.trigger_id,
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:
error = str(exc) or exc.__class__.__name__
retried = store.retry_delivery(delivery, error)
@@ -92,8 +105,7 @@ async def _deliver_delivery(
store: LocalTriggerStore,
delivery: TriggerDelivery,
*,
bus: MessageBus | None,
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None,
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]],
) -> None:
trigger = store.get(delivery.trigger_id)
if trigger is None:
@@ -109,12 +121,7 @@ async def _deliver_delivery(
metadata=_delivery_metadata(trigger, delivery),
session_key_override=trigger.session_key,
)
if submit_turn is not None:
await submit_turn(msg)
else:
if bus is None:
raise RuntimeError("bus unavailable for local trigger delivery")
await bus.publish_inbound(msg)
await submit_turn(msg)
store.record_delivery(
trigger.id,
status="ok",
@@ -129,6 +136,7 @@ def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict
"trigger_name": trigger.name,
"delivery_id": delivery.id,
"created_at_ms": delivery.created_at_ms,
"persist_content": _history_content(trigger, delivery),
}
if trigger.channel == "websocket":
metadata.pop(WEBUI_TURN_METADATA_KEY, None)
@@ -138,3 +146,8 @@ def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict
source["label"] = trigger.name
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
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}"
+3
View File
@@ -14,6 +14,9 @@ LOCAL_TRIGGER_META = "_local_trigger"
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")
trigger_id = trigger.get("trigger_id")
label = name if isinstance(name, str) and name.strip() else trigger_id
+25 -106
View File
@@ -2,15 +2,14 @@
from __future__ import annotations
import asyncio
import dataclasses
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
class LocalTriggerTurnCoordinator:
class LocalTriggerTurnCoordinator(AutomationTurnCoordinator):
"""Manage local trigger turns without mixing them into live injections."""
def __init__(
@@ -19,113 +18,33 @@ class LocalTriggerTurnCoordinator:
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self.deferred_queues: dict[str, list[InboundMessage]] = {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_delivery_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit a local trigger turn and wait for its session response."""
delivery_id = local_trigger_delivery_id(msg.metadata)
if not delivery_id:
raise ValueError("local trigger turn metadata must include a delivery_id")
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)
super().__init__(
publish_inbound=publish_inbound,
dispatch=dispatch,
is_running=is_running,
turn_id=lambda msg: local_trigger_delivery_id(msg.metadata),
pending_id=_local_trigger_id,
should_defer_turn=_should_defer_local_trigger_turn,
missing_id_error="local trigger turn metadata must include a delivery_id",
duplicate_id_error=lambda delivery_id: (
f"local trigger delivery {delivery_id!r} is already pending"
),
deferred_queues=deferred_queues,
)
def pending_trigger_ids_for_session(self, session_key: str) -> set[str]:
"""Return local triggers waiting for or running in *session_key*."""
trigger_ids: set[str] = set()
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
return self.pending_ids_for_session(session_key)
async def publish_next_deferred(self, session_key: str) -> bool:
queue = self.deferred_queues.get(session_key)
if not queue:
return False
msg = queue.pop(0)
if not queue:
self.deferred_queues.pop(session_key, None)
await self._publish_inbound(msg)
return True
def _should_defer_local_trigger_turn(
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 _local_trigger_id(msg: InboundMessage) -> str | None:
+21 -2
View File
@@ -134,6 +134,7 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
"trigger_name": "PR review",
"delivery_id": "tdel_456",
"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
message = session.messages[-1]
assert message["content"] == "Local trigger received: PR review"
assert "Review PR #4502" not in message["content"]
assert message["content"] == "Local trigger received: PR review\n\nReview PR #4502"
assert message[AUTOMATION_HISTORY_META] == {
"kind": "local_trigger",
"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"
@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:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == ""
+5 -1
View File
@@ -2044,6 +2044,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
seen["agent_from_config_kwargs"] = extra
return cls(**extra)
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)
agent = seen["agent"]
agent_kwargs = seen["agent_from_config_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
+32
View File
@@ -8,6 +8,7 @@ import pytest
from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import build_help_text, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.session.keys import UNIFIED_SESSION_KEY
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
@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
async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None:
router = CommandRouter()
+69 -18
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from nanobot.agent.automation_turns import AutomationTurnError
from nanobot.bus.events import InboundMessage
from nanobot.triggers.local_runner import run_local_trigger_queue
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
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)
trigger = store.create(
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"},
)
store.enqueue(trigger.id, "Review PR #4502")
published: list[InboundMessage] = []
submitted: list[InboundMessage] = []
class _Bus:
async def publish_inbound(self, msg: InboundMessage) -> None:
published.append(msg)
async def _submit_turn(msg: InboundMessage):
submitted.append(msg)
return None
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:
for _ in range(100):
if published:
if submitted:
break
await asyncio.sleep(0.01)
finally:
@@ -106,8 +107,8 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
with suppress(asyncio.CancelledError):
await task
assert len(published) == 1
msg = published[0]
assert len(submitted) == 1
msg = submitted[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
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",
}
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)
assert stored is not None
@@ -227,6 +232,52 @@ async def test_local_trigger_queue_requeues_when_submitted_turn_is_interrupted(
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
async def test_local_trigger_queue_recovers_processing_delivery_on_start(
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")
assert len(store.claim_deliveries()) == 1
published: list[InboundMessage] = []
submitted: list[InboundMessage] = []
class _Bus:
async def publish_inbound(self, msg: InboundMessage) -> None:
published.append(msg)
async def _submit_turn(msg: InboundMessage):
submitted.append(msg)
return None
restarted = LocalTriggerStore(tmp_path)
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:
for _ in range(100):
if published:
if submitted:
break
await asyncio.sleep(0.01)
finally:
@@ -260,7 +311,7 @@ async def test_local_trigger_queue_recovers_processing_delivery_on_start(
with suppress(asyncio.CancelledError):
await task
assert len(published) == 1
assert published[0].content == "Review PR #4591"
assert published[0].metadata["_local_trigger"]["trigger_id"] == trigger.id
assert len(submitted) == 1
assert submitted[0].content == "Review PR #4591"
assert submitted[0].metadata["_local_trigger"]["trigger_id"] == trigger.id
assert restarted.claim_deliveries() == []