mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
refactor(trigger): share automation turn delivery
This commit is contained in:
parent
afef27dd6c
commit
acb0e853ff
@ -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:
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
145
nanobot/agent/automation_turns.py
Normal file
145
nanobot/agent/automation_turns.py
Normal 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,
|
||||||
|
)
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 {}),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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") == ""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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() == []
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user