db1abce8d8
* chore: add pytest-asyncio in auto mode * test: migrate channel and stream tests to native async Convert run_async() wrapper tests to plain 'async def test_*' under pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a coroutine awaited at every call site. * test: migrate command and model/middleware tests to native async Convert run_async() wrappers (import, alias, and fixture forms) to plain 'async def test_*'. Multi-call tests merge onto one loop as sequential awaits; none asserted on loop identity. * test: migrate TUI, notifier, gateway, and session tests to native async TUI/notifier/gateway files convert run_async wrappers to plain async tests. test_sessions.py's unittest.TestCase classes move to unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async methods on plain TestCase; converting blindly would have made ~70 tests silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget in test_tui_widgets.py drops its TestCase base for the same reason. * test: replace direct asyncio.run() calls with native async tests Convert tests that called asyncio.run() (directly or via a local _run helper) to plain 'async def test_*'; delete the local helpers. * test: drop undeclared anyio markers and delete run_async helper The @pytest.mark.anyio tests relied on anyio being a transitive dep of httpx; auto-mode pytest-asyncio collects them natively. run_async() and its fixture are unreferenced after the migration, so remove them — pytest-asyncio's per-test loop teardown covers the pending-task cancellation the helper existed for (verified: full suite runs with no 'Event loop is closed' errors or destroyed-task warnings). * test: add autouse fixture for watcher cleanup * refactor: remove redundant hasattr calls * refactor: extract shared HITL/ask_user interaction grammar Extract prompt/question formatting, the reply grammar (approval letters, ask_user choice letters + the 'Other' sub-flow, stop-commands), the ApprovalPolicy (config auto-approve rule + session registry + session-key derivation), per-flow timeout constants, and the bilingual feedback strings into channels/interaction.py. Both drivers now point at the shared functions: this reverses cli/channel.py's imports of consumer privates and closes the /stop drift at the parsing layer (serve-mode ask_user now checks stop-commands before parsing an answer, matching the CLI path). * refactor: add interaction engine + registry; port InboundConsumer Introduce InteractionIO (transport adapter Protocol), PendingReplyRegistry (one asyncio-based reply router per process), and the engine coroutines resolve_ask_user / resolve_approval in channels/interaction.py. Port InboundConsumer onto them: a _ConsumerIO adapter over bus.publish_outbound + the registry, one ApprovalPolicy replacing the config/session auto-approve checks, and a single reply-interception point (registry.try_resolve) replacing the parallel ask_user/HITL pending dicts. _resolve_ask_user and the approval section of _stream_with_hitl are now thin engine calls. Behavior unification (serve mode): an unrecognized HITL reply now declines with the shared 'Unrecognized reply' notice instead of rejecting-and-refeeding as a fresh turn, and /stop mid-approval cancels cleanly — both via the shared parser. * refactor: port CLI channel bridge onto the interaction engine Replace the ~250-line parallel bodies of channel_ask_user_prompt / channel_hitl_prompt with thin bridges that run resolve_ask_user / resolve_approval on the bus loop via run_coroutine_threadsafe(...).result() (outer = engine per-flow timeout + slack, so the engine's own timeout fires first). The 15s send timeout moves into the _BridgeIO adapter. Delete the _pending_hitl / _hitl_lock / _hitl_auto_approve module globals and the _register_hitl_wait / _try_set_hitl_reply / _pop_hitl_reply helpers, absorbed by one bus-loop PendingReplyRegistry + one ApprovalPolicy. The bus consumer feeds the registry via try_resolve ahead of normal enqueue. * refactor: restore serve-mode refeed for unrecognized HITL replies Gate-review fix: the engine no longer decides transport policy for unparseable approval replies. resolve_approval now returns an ApprovalOutcome carrying unrecognized_reply (raw text) when parsing fails, sending no feedback itself; recognized reject keeps the sharedi rejection message. Consumer driver (serve mode) restores the pre-engine semantics: an unrecognized reply rejects the pending action, confirms with the rejection message, and the text is re-dispatched as a NEW agent turn — _stream_with_hitl returns the captured text and _handle_message starts the refeed turn only after the current one has released the chat lock (old fall-through ordering). CLI bridge keeps its old no-refeed path byte-for-byte: 'Unrecognized reply. Action rejected.' and decline. Tests: serve refeed pinned end-to-end (prompt → unrecognized text → rejection feedback → text reaches the stream path as a new turn), CLI no-refeed pinned (notice sent, nothing enqueued), engine test updated to assert the outcome struct with no engine-side feedback. * refactor: polish the interaction engine surface - English feedback strings (Approved / Rejected / auto-approving) - drop the consumer's backwards-compatible re-exports and both modules' private timeout aliases; callers use the canonical interaction names - replace byte-for-byte prompt goldens with structural format tests and assert feedback via the shared constants instead of string literals - strip audit/design shorthand (R1/R2/G3, stage numbers) from comments * fix: propagate pending reply task cancellation * fix: preserve reply context when refeeding HITL replies * fix: honor HITL session grants without bus loop * fix: bound bridge waits by send latency * fix: handle empty ask_user replies explicitly * chore: remove stale interaction helpers * fix: harden interaction engine reply edge cases Review follow-ups on the interaction engine: - normalize ask_user choices before .get(): the tool args come from model JSON and only presence is validated, so plain-string choices must render and parse instead of crashing the turn - treat only None as an approval timeout, so an empty/media-only reply flows through the unrecognized path and serve mode refeeds it with its preserved context - intercept prompt replies before _get_thread_id so a consumed reply cannot create an orphan graph thread or touch the sender-session LRU - close engine coroutines the bridge failed to schedule (no bus loop / scheduling error) to avoid never-awaited warnings - clear pending-response and channel-request state in the bridge test fixture --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
691 lines
26 KiB
Python
691 lines
26 KiB
Python
"""Unified inbound message consumer.
|
|
|
|
Provides :class:`InboundConsumer` — a single class that consumes
|
|
inbound messages from the :class:`MessageBus`, runs them through
|
|
the agent, and publishes outbound responses. This replaces the
|
|
inline consumer loops that were duplicated in ``cli.py`` and
|
|
``standalone.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections import OrderedDict
|
|
from collections.abc import AsyncIterator, Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any, TypeVar
|
|
|
|
from ..gateway import GraphGateway, GraphRunInput, GraphTarget, RunRequest
|
|
from .base import Channel
|
|
from .bus import MessageBus
|
|
from .bus.events import InboundMessage, OutboundMessage
|
|
from .capabilities import ChannelCapabilities
|
|
from .interaction import (
|
|
ASK_USER_TIMEOUT,
|
|
HITL_APPROVAL_TIMEOUT,
|
|
REJECTED_FEEDBACK,
|
|
ApprovalPolicy,
|
|
InteractionIO,
|
|
PendingReplyRegistry,
|
|
resolve_approval,
|
|
resolve_ask_user,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T")
|
|
|
|
_MAX_CHAT_LOCKS = 10_000
|
|
_MAX_SESSIONS = 10_000
|
|
_MAX_HITL_ROUNDS = 50
|
|
|
|
|
|
@dataclass
|
|
class ConsumerMetrics:
|
|
"""Cumulative processing counters for the consumer."""
|
|
|
|
total_processed: int = 0
|
|
total_successes: int = 0
|
|
total_failures: int = 0
|
|
total_timeouts: int = 0
|
|
|
|
|
|
async def _timeout_aiter(
|
|
agen: AsyncIterator[T],
|
|
idle_timeout: float,
|
|
) -> AsyncIterator[T]:
|
|
"""Wrap an async iterator with a per-yield idle timeout.
|
|
|
|
If ``__anext__()`` does not produce a value within *idle_timeout*
|
|
seconds, :class:`asyncio.TimeoutError` is raised. Continuous
|
|
yielding resets the timer each time, so only a truly stalled
|
|
generator will trigger the timeout.
|
|
"""
|
|
ait = agen.__aiter__()
|
|
try:
|
|
while True:
|
|
try:
|
|
item = await asyncio.wait_for(ait.__anext__(), timeout=idle_timeout)
|
|
except StopAsyncIteration:
|
|
return
|
|
yield item
|
|
finally:
|
|
if hasattr(ait, "aclose"):
|
|
await ait.aclose()
|
|
|
|
|
|
def _format_todo_list(todos: list[dict]) -> str:
|
|
"""Format todo items as a numbered list."""
|
|
lines = ["\U0001f4cb Todo List\n"] # 📋
|
|
for i, item in enumerate(todos, 1):
|
|
content = item.get("content", "")
|
|
lines.append(f"{i}. {content}")
|
|
lines.append(f"\n\U0001f680 {len(todos)} tasks") # 🚀
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _join_subagent_text(buffers: dict[str, tuple[str, list[str]]]) -> str:
|
|
"""Join sub-agent text buffers into a single fallback string.
|
|
|
|
*buffers* maps ``instance_id`` → ``(display_name, chunks)``.
|
|
|
|
When only one instance produced text, return its content directly.
|
|
When multiple instances share the same display name, number them
|
|
(e.g. ``[research-agent #1]``, ``[research-agent #2]``).
|
|
"""
|
|
if not buffers:
|
|
return ""
|
|
if len(buffers) == 1:
|
|
_display_name, chunks = next(iter(buffers.values()))
|
|
return "".join(chunks)
|
|
|
|
# Group by display_name to detect same-name instances
|
|
name_groups: dict[str, list[list[str]]] = {}
|
|
for _instance_id, (display_name, chunks) in buffers.items():
|
|
name_groups.setdefault(display_name, []).append(chunks)
|
|
|
|
sections: list[str] = []
|
|
for display_name, chunk_lists in name_groups.items():
|
|
if len(chunk_lists) == 1:
|
|
sections.append(f"[{display_name}]: {''.join(chunk_lists[0])}")
|
|
else:
|
|
for i, chs in enumerate(chunk_lists, 1):
|
|
sections.append(f"[{display_name} #{i}]: {''.join(chs)}")
|
|
return "\n\n".join(sections)
|
|
|
|
|
|
class _ConsumerIO(InteractionIO):
|
|
""":class:`InteractionIO` over the consumer's bus + reply registry.
|
|
|
|
Publishes prompts through ``bus.publish_outbound`` and blocks for
|
|
replies on the consumer's shared :class:`PendingReplyRegistry` — both
|
|
on the consumer's own event loop, so the engine runs natively async
|
|
here with no thread hand-off.
|
|
"""
|
|
|
|
def __init__(
|
|
self, consumer: InboundConsumer, msg: InboundMessage, session_key: str
|
|
) -> None:
|
|
self._consumer = consumer
|
|
self._msg = msg
|
|
self._session_key = session_key
|
|
self._last_reply_message: InboundMessage | None = None
|
|
channel = consumer._get_channel(msg.channel)
|
|
self.capabilities = (
|
|
channel.capabilities if channel is not None else ChannelCapabilities()
|
|
)
|
|
self.base_metadata = msg.metadata
|
|
|
|
async def send(self, content: str, *, metadata: dict | None = None) -> bool:
|
|
await self._consumer.bus.publish_outbound(
|
|
OutboundMessage(
|
|
channel=self._msg.channel,
|
|
chat_id=self._msg.chat_id,
|
|
content=content,
|
|
metadata=metadata if metadata is not None else self._msg.metadata,
|
|
)
|
|
)
|
|
return True
|
|
|
|
async def wait_reply(self, *, timeout: float) -> str | None:
|
|
reply = await self._consumer._reply_registry.wait_event(
|
|
self._session_key, timeout
|
|
)
|
|
if reply is None:
|
|
self._last_reply_message = None
|
|
return None
|
|
self._last_reply_message = (
|
|
reply.context if isinstance(reply.context, InboundMessage) else None
|
|
)
|
|
return reply.content
|
|
|
|
def take_reply_context(self) -> InboundMessage | None:
|
|
"""Consume the last inbound reply context captured by ``wait_reply``."""
|
|
msg = self._last_reply_message
|
|
self._last_reply_message = None
|
|
return msg
|
|
|
|
|
|
class InboundConsumer:
|
|
"""Consume inbound messages from the bus, process via agent, publish outbound.
|
|
|
|
Parameters
|
|
----------
|
|
bus:
|
|
The MessageBus to consume from / publish to.
|
|
manager:
|
|
The ChannelManager (used to look up channel instances).
|
|
agent:
|
|
The local agent object used by local graph gateway targets.
|
|
thread_id:
|
|
Default thread ID for agent conversations.
|
|
graph_gateway:
|
|
Gateway used for thread creation and graph streaming.
|
|
send_thinking:
|
|
Whether to forward thinking messages to the channel.
|
|
on_message_received:
|
|
Optional callback ``(msg: InboundMessage) -> None`` invoked when
|
|
a message is consumed (e.g. for CLI Rich display).
|
|
on_streaming_event:
|
|
Optional callback ``(event: dict) -> None`` invoked for each
|
|
streaming event from the agent.
|
|
on_message_sent:
|
|
Optional callback ``(msg: OutboundMessage) -> None`` invoked when
|
|
the outbound message is published.
|
|
inference_timeout:
|
|
Per-yield idle timeout in seconds for the agent stream. If the
|
|
agent produces no event for this long, the inference is aborted.
|
|
max_concurrent:
|
|
Number of worker coroutines (= max parallel inferences).
|
|
max_pending:
|
|
Maximum depth of the internal work queue. When full, the
|
|
consumer loop blocks (back-pressure).
|
|
drain_timeout:
|
|
Seconds to wait for in-flight workers to finish during ``stop()``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
bus: MessageBus,
|
|
manager: Any,
|
|
agent: Any,
|
|
thread_id: str,
|
|
*,
|
|
graph_gateway: GraphGateway,
|
|
send_thinking: bool = False,
|
|
on_message_received: Callable[[InboundMessage], None] | None = None,
|
|
on_streaming_event: Callable[[dict], None] | None = None,
|
|
on_message_sent: Callable[[OutboundMessage], None] | None = None,
|
|
inference_timeout: float = 300.0,
|
|
max_concurrent: int = 5,
|
|
max_pending: int = 50,
|
|
drain_timeout: float = 30.0,
|
|
):
|
|
self.bus = bus
|
|
self.manager = manager
|
|
self.agent = agent
|
|
self.thread_id = thread_id
|
|
self.graph_gateway = graph_gateway
|
|
self.send_thinking = send_thinking
|
|
self._on_message_received = on_message_received
|
|
self._on_streaming_event = on_streaming_event
|
|
self._on_message_sent = on_message_sent
|
|
self._sessions: OrderedDict[str, str] = (
|
|
OrderedDict()
|
|
) # sender_id -> thread_id (LRU)
|
|
|
|
# Per-chat locks: same chat is processed serially (bounded)
|
|
self._chat_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
# Inference timeout
|
|
self._inference_timeout = inference_timeout
|
|
|
|
# Worker pool
|
|
self._max_concurrent = max_concurrent
|
|
self._work_queue: asyncio.Queue[InboundMessage | None] = asyncio.Queue(
|
|
maxsize=max_pending,
|
|
)
|
|
self._workers: list[asyncio.Task] = []
|
|
self._stopping = False
|
|
self._drain_timeout = drain_timeout
|
|
|
|
# Metrics
|
|
self._metrics = ConsumerMetrics()
|
|
|
|
# Interaction engine state: one reply registry (routes the next
|
|
# message from a chat into a waiting prompt) and one approval
|
|
# policy (config rule + session "Approve all" grants), shared by
|
|
# the ask_user and HITL flows via ``channels.interaction``.
|
|
self._reply_registry = PendingReplyRegistry()
|
|
self._approval_policy = ApprovalPolicy()
|
|
|
|
async def _get_thread_id(self, sender_id: str) -> str:
|
|
"""Get or create a thread ID for the given sender.
|
|
|
|
Uses LRU ordering: recently accessed senders are moved to the
|
|
end, so eviction always removes the least-recently-active sender.
|
|
"""
|
|
if sender_id in self._sessions:
|
|
self._sessions.move_to_end(sender_id)
|
|
return self._sessions[sender_id]
|
|
|
|
if len(self._sessions) >= _MAX_SESSIONS:
|
|
# Evict the least-recently-used entry
|
|
self._sessions.popitem(last=False)
|
|
if self.thread_id:
|
|
self._sessions[sender_id] = f"{self.thread_id}:{sender_id}"
|
|
else:
|
|
self._sessions[sender_id] = await self.graph_gateway.create_thread(
|
|
GraphTarget(local_graph=self.agent)
|
|
)
|
|
return self._sessions[sender_id]
|
|
|
|
def _get_channel(self, channel_name: str) -> Channel | None:
|
|
"""Look up the channel by name from the manager."""
|
|
return self.manager.get_channel(channel_name)
|
|
|
|
# ── lifecycle ──
|
|
|
|
async def run(self) -> None:
|
|
"""Main consumer loop — runs until ``stop()`` or cancellation.
|
|
|
|
Spawns *max_concurrent* worker coroutines that pull from an
|
|
internal bounded queue. The loop reads from the bus and feeds
|
|
the queue; when the queue is full the loop blocks (back-pressure).
|
|
"""
|
|
self._stopping = False
|
|
self._workers = [
|
|
asyncio.create_task(self._worker(i)) for i in range(self._max_concurrent)
|
|
]
|
|
try:
|
|
while not self._stopping:
|
|
try:
|
|
msg = await asyncio.wait_for(
|
|
self.bus.consume_inbound(),
|
|
timeout=1.0,
|
|
)
|
|
except TimeoutError:
|
|
continue
|
|
except asyncio.CancelledError:
|
|
break
|
|
if self._stopping:
|
|
break
|
|
await self._work_queue.put(msg) # blocks when full (back-pressure)
|
|
finally:
|
|
if not self._stopping:
|
|
await self.stop()
|
|
|
|
async def stop(self) -> None:
|
|
"""Gracefully drain in-flight work and shut down workers."""
|
|
self._stopping = True
|
|
logger.info("Consumer stopping: draining in-flight messages...")
|
|
pending_count = self._work_queue.qsize()
|
|
|
|
# Send a None sentinel per worker so each exits its loop
|
|
for _ in self._workers:
|
|
try:
|
|
self._work_queue.put_nowait(None)
|
|
except asyncio.QueueFull:
|
|
pass
|
|
|
|
# Wait for workers to finish, then force-cancel stragglers
|
|
if self._workers:
|
|
done, still_running = await asyncio.wait(
|
|
self._workers,
|
|
timeout=self._drain_timeout,
|
|
)
|
|
for task in still_running:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
logger.info(
|
|
f"Consumer drain: {len(done)} finished, "
|
|
f"{len(still_running)} force-cancelled, "
|
|
f"{pending_count} were pending"
|
|
)
|
|
self._workers.clear()
|
|
|
|
# ── workers ──
|
|
|
|
async def _worker(self, worker_id: int) -> None:
|
|
"""Pull messages from the work queue and process them."""
|
|
while True:
|
|
msg = await self._work_queue.get()
|
|
if msg is None:
|
|
break # shutdown sentinel
|
|
try:
|
|
await self._handle_message(msg)
|
|
except Exception:
|
|
logger.exception(f"Worker {worker_id} unhandled error")
|
|
finally:
|
|
self._work_queue.task_done()
|
|
|
|
async def _handle_message(self, msg: InboundMessage) -> None:
|
|
"""Process a single inbound message."""
|
|
if self._on_message_received:
|
|
try:
|
|
self._on_message_received(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
session_key = msg.session_key # "channel:chat_id"
|
|
|
|
# Lazily create per-chat lock; evict stale locks when too many
|
|
if session_key not in self._chat_locks:
|
|
self._chat_locks[session_key] = asyncio.Lock()
|
|
if len(self._chat_locks) > _MAX_CHAT_LOCKS:
|
|
self._evict_chat_locks()
|
|
|
|
self._metrics.total_processed += 1
|
|
|
|
# Reply interception: if a prompt (ask_user question or HITL
|
|
# approval) is waiting on this chat, hand it this message instead
|
|
# of starting a fresh agent turn. The engine parses it (stop /
|
|
# cancel / choice / approval grammar), so the registry only routes
|
|
# text plus the original inbound context — one path for both flows.
|
|
if self._reply_registry.try_resolve(session_key, msg.content, context=msg):
|
|
return
|
|
|
|
# Resolved only for real agent turns — a consumed prompt reply must
|
|
# not create a graph thread or touch the sender-session LRU.
|
|
channel = self._get_channel(msg.channel)
|
|
thread_id = await self._get_thread_id(msg.sender_id)
|
|
|
|
async with self._chat_locks[session_key]:
|
|
refeed = await self._stream_with_hitl(msg, channel, thread_id, session_key)
|
|
|
|
# An unrecognized reply to a pending approval rejects the action and
|
|
# then becomes a new agent turn. The lock was released above, so the
|
|
# previous turn has fully unwound before the refeed turn acquires it.
|
|
# Loops in case the refeed turn hits another approval that is again
|
|
# answered with unparseable text.
|
|
while refeed is not None:
|
|
channel = self._get_channel(refeed.channel)
|
|
thread_id = await self._get_thread_id(refeed.sender_id)
|
|
session_key = refeed.session_key
|
|
if session_key not in self._chat_locks:
|
|
self._chat_locks[session_key] = asyncio.Lock()
|
|
if len(self._chat_locks) > _MAX_CHAT_LOCKS:
|
|
self._evict_chat_locks()
|
|
async with self._chat_locks[session_key]:
|
|
refeed = await self._stream_with_hitl(
|
|
refeed, channel, thread_id, session_key
|
|
)
|
|
|
|
async def _stream_with_hitl(
|
|
self,
|
|
msg: InboundMessage,
|
|
channel: Channel | None,
|
|
thread_id: str,
|
|
session_key: str,
|
|
) -> InboundMessage | None:
|
|
"""Stream agent events with HITL interrupt handling.
|
|
|
|
Returns ``None`` normally. When a pending approval is answered
|
|
with unrecognized text, returns the intercepted inbound reply so the
|
|
caller can refeed it as a new agent turn after this one unwinds.
|
|
"""
|
|
from langgraph.types import Command
|
|
|
|
stream_input: GraphRunInput = msg.content
|
|
|
|
try:
|
|
if channel:
|
|
await channel.start_typing(msg.chat_id)
|
|
|
|
_last_sent_thinking: str | None = None
|
|
|
|
for _hitl_round in range(_MAX_HITL_ROUNDS):
|
|
final_content = ""
|
|
thinking_buffer: list[str] = []
|
|
todo_sent = False
|
|
subagent_text_buffers: dict[str, tuple[str, list[str]]] = {}
|
|
thinking_sent = False
|
|
interrupt_data: dict | None = None
|
|
|
|
async def _flush_thinking_buffer(
|
|
buffer: list[str] = thinking_buffer,
|
|
) -> bool:
|
|
"""Send the current thinking buffer, dedup by content."""
|
|
nonlocal thinking_sent, _last_sent_thinking
|
|
if not channel or thinking_sent or not buffer:
|
|
return False
|
|
|
|
full_thinking = "".join(buffer).rstrip()
|
|
buffer.clear()
|
|
if not full_thinking or full_thinking == _last_sent_thinking:
|
|
return False
|
|
|
|
await channel.send_thinking_message(
|
|
msg.sender_id,
|
|
full_thinking,
|
|
msg.metadata,
|
|
)
|
|
thinking_sent = True
|
|
_last_sent_thinking = full_thinking
|
|
return True
|
|
|
|
async for event in _timeout_aiter(
|
|
self.graph_gateway.stream_events(
|
|
RunRequest(
|
|
message=stream_input,
|
|
thread_id=thread_id,
|
|
media=msg.media or None
|
|
if isinstance(stream_input, str)
|
|
else None,
|
|
target=GraphTarget(local_graph=self.agent),
|
|
)
|
|
),
|
|
self._inference_timeout,
|
|
):
|
|
event_type = event.get("type")
|
|
|
|
if self._on_streaming_event:
|
|
try:
|
|
self._on_streaming_event(event)
|
|
except Exception:
|
|
pass
|
|
|
|
if event_type == "thinking":
|
|
thinking_text = event.get("content", "")
|
|
if thinking_text:
|
|
thinking_buffer.append(thinking_text)
|
|
|
|
elif event_type == "tool_call":
|
|
if event.get("name") == "write_todos" and not todo_sent:
|
|
todos = event.get("args", {}).get("todos", [])
|
|
if todos and channel:
|
|
await _flush_thinking_buffer()
|
|
await channel.send_todo_message(
|
|
msg.sender_id,
|
|
_format_todo_list(todos),
|
|
msg.metadata,
|
|
)
|
|
todo_sent = True
|
|
|
|
elif event_type == "text":
|
|
final_content += event.get("content", "")
|
|
|
|
elif event_type == "subagent_text":
|
|
sa_name = event.get("subagent", "unknown")
|
|
instance_id = event.get("instance_id")
|
|
if not instance_id:
|
|
continue
|
|
if instance_id not in subagent_text_buffers:
|
|
subagent_text_buffers[instance_id] = (sa_name, [])
|
|
subagent_text_buffers[instance_id][1].append(
|
|
event.get("content", "")
|
|
)
|
|
|
|
elif event_type == "done":
|
|
final_content = event.get("content", "") or final_content
|
|
|
|
elif event_type == "interrupt":
|
|
interrupt_data = event
|
|
break # exit async for to handle interrupt
|
|
|
|
elif event_type == "ask_user":
|
|
interrupt_data = event
|
|
break # exit async for to handle ask_user
|
|
|
|
# Flush thinking
|
|
await _flush_thinking_buffer()
|
|
|
|
# No interrupt — normal completion
|
|
if interrupt_data is None:
|
|
outbound = OutboundMessage(
|
|
channel=msg.channel,
|
|
chat_id=msg.chat_id,
|
|
content=final_content
|
|
or _join_subagent_text(subagent_text_buffers)
|
|
or "No response",
|
|
reply_to=msg.message_id or None,
|
|
metadata=msg.metadata,
|
|
)
|
|
await self.bus.publish_outbound(outbound)
|
|
self._metrics.total_successes += 1
|
|
if self._on_message_sent:
|
|
try:
|
|
self._on_message_sent(outbound)
|
|
except Exception:
|
|
pass
|
|
return # done
|
|
|
|
# ask_user: send questions to channel user, collect answers
|
|
if interrupt_data.get("type") == "ask_user":
|
|
result = await self._resolve_ask_user(
|
|
msg,
|
|
interrupt_data,
|
|
session_key,
|
|
)
|
|
|
|
stream_input = Command(resume=result)
|
|
continue
|
|
|
|
# HITL: resolve the interrupt through the shared engine.
|
|
# ``resolve_approval`` handles session/config auto-approve,
|
|
# the approval prompt (with capability-driven buttons), the
|
|
# reply wait, parsing (incl. /stop), and feedback strings.
|
|
action_reqs = interrupt_data.get("action_requests", [])
|
|
io = _ConsumerIO(self, msg, session_key)
|
|
outcome = await resolve_approval(
|
|
action_reqs,
|
|
io,
|
|
self._approval_policy,
|
|
session_key,
|
|
timeout=HITL_APPROVAL_TIMEOUT,
|
|
)
|
|
if outcome.unrecognized_reply is not None:
|
|
# Serve-mode policy: an unrecognized reply rejects the
|
|
# pending action, confirms with reject feedback, and is
|
|
# then processed as a new agent turn. The refeed is
|
|
# returned to ``_handle_message`` so chat-lock ordering
|
|
# stays serialized.
|
|
await io.send(REJECTED_FEEDBACK)
|
|
# In this flow, the final wait_reply call is exactly the
|
|
# unrecognized approval reply. ask_user does not read this.
|
|
refeed_msg = io.take_reply_context()
|
|
if refeed_msg is None:
|
|
logger.warning(
|
|
"Unrecognized approval reply had no inbound context; "
|
|
"dropping refeed"
|
|
)
|
|
return refeed_msg
|
|
if outcome.decisions is None:
|
|
return None # reject / timeout / stop — end the turn
|
|
|
|
stream_input = Command(resume={"decisions": outcome.decisions})
|
|
# continue to next HITL round
|
|
|
|
except TimeoutError:
|
|
self._metrics.total_timeouts += 1
|
|
logger.error(
|
|
f"Inference timeout ({self._inference_timeout}s idle) "
|
|
f"for {msg.sender_id} in {session_key}"
|
|
)
|
|
await self.bus.publish_outbound(
|
|
OutboundMessage(
|
|
channel=msg.channel,
|
|
chat_id=msg.chat_id,
|
|
content="Sorry, the response timed out. Please try again.",
|
|
metadata=msg.metadata,
|
|
)
|
|
)
|
|
|
|
except Exception as e:
|
|
self._metrics.total_failures += 1
|
|
logger.error(f"Agent error: {e}")
|
|
await self.bus.publish_outbound(
|
|
OutboundMessage(
|
|
channel=msg.channel,
|
|
chat_id=msg.chat_id,
|
|
content="Sorry, something went wrong. Please try again later.",
|
|
metadata=msg.metadata,
|
|
)
|
|
)
|
|
finally:
|
|
if channel:
|
|
await channel.stop_typing(msg.chat_id)
|
|
|
|
# ── observability ──
|
|
|
|
@property
|
|
def pending_count(self) -> int:
|
|
"""Number of messages waiting in the work queue."""
|
|
return self._work_queue.qsize()
|
|
|
|
@property
|
|
def active_workers(self) -> int:
|
|
"""Number of worker tasks that are still alive."""
|
|
return sum(1 for w in self._workers if not w.done())
|
|
|
|
@property
|
|
def metrics(self) -> dict[str, int]:
|
|
"""Cumulative processing counters."""
|
|
m = self._metrics
|
|
return {
|
|
"total_processed": m.total_processed,
|
|
"total_successes": m.total_successes,
|
|
"total_failures": m.total_failures,
|
|
"total_timeouts": m.total_timeouts,
|
|
"pending": self.pending_count,
|
|
"active_workers": self.active_workers,
|
|
"chat_locks": len(self._chat_locks),
|
|
"sessions": len(self._sessions),
|
|
}
|
|
|
|
# ── ask_user helpers ──
|
|
|
|
async def _resolve_ask_user(
|
|
self,
|
|
msg: InboundMessage,
|
|
event_data: dict,
|
|
session_key: str,
|
|
) -> dict:
|
|
"""Handle an ask_user interrupt via the shared engine.
|
|
|
|
Delegates the whole question/answer flow (prompt formatting, choice
|
|
+ "Other" grammar, ``/stop`` handling) to
|
|
:func:`channels.interaction.resolve_ask_user` over a
|
|
:class:`_ConsumerIO` adapter, so serve mode and the CLI bridge
|
|
cannot drift.
|
|
|
|
Returns a dict suitable for ``Command(resume=...)``:
|
|
``{"answers": [...], "status": "answered"}`` or
|
|
``{"status": "cancelled"}``.
|
|
"""
|
|
questions = event_data.get("questions", [])
|
|
io = _ConsumerIO(self, msg, session_key)
|
|
return await resolve_ask_user(questions, io, timeout=ASK_USER_TIMEOUT)
|
|
|
|
# ── internal ──
|
|
|
|
def _evict_chat_locks(self) -> None:
|
|
"""Remove chat locks that are not currently held."""
|
|
stale = [k for k, lock in self._chat_locks.items() if not lock.locked()]
|
|
for k in stale[: max(1, len(stale) // 2)]:
|
|
del self._chat_locks[k]
|