8b1451cdda
* feat(runtime): add application-scoped async runtime * refactor(cli): use owned runtime for session stats * refactor(onboard): use the owned async runtime * docs(runtime): record async bridge ownership * refactor(middleware): keep sync fallback synchronous * refactor(mcp): load tools on an owned runtime * refactor(cli): share owned runtime across entry points * refactor(channels): make inbound sync bridge explicit * refactor(stream): run Rich streaming on owned runtime * chore(runtime): remove nest-asyncio dependency * refactor(asyncio): require active loops in async code * docs(runtime): document final event loop ownership * fix(stream): cancel stalled owned streams * fix(cli): recover cleanly from stream cancellation * fix(runtime): drain executor work before shutdown * fix(runtime): terminate cancelled shell process trees * fix(models): let fallback bypass selector failures * fix(cli): reset interrupt handling between turns * docs: rm implementation spec * fix(serve): cancel active turns during shutdown * fix(runtime): protect settlement from waiter cancellation * fix(backends): reject empty shell commands * fix(runtime): terminate descendants after shell exit * fix(mcp): keep standalone discovery off channel loop * fix(cli): own and settle interactive prompt cancellation * fix(serve): keep channel sends off runtime loop * fix(stream): scope cancel context to iterator steps * refactor(serve): require the owned async runtime * fix(channels): keep interactive sends off runtime loop * fix(selector): surface fallback without log spam * test(runtime): normalize Windows shell marker * fix(cli): serialize interactive session turns * fix(shell): bound output drain after termination * fix(ui): do not retry owned runtime failures * fix(shell): allow signal-safe registry reentry * fix(shell): avoid terminating reused process ids * fix(channels): preserve streaming send order * fix(cli): report runtime shutdown timeouts cleanly * fix(mcp): guide async callers to async loader * docs(runtime): clarify reserved async bridge APIs * fix(runtime): bound code interpreter cleanup * test(shell): use active Python for drain regression --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
196 lines
5.9 KiB
Python
196 lines
5.9 KiB
Python
"""Shared standalone runner for channel servers.
|
|
|
|
Provides the channel-agnostic agent loop that any channel can use to
|
|
run headless — consuming inbound messages from the bus, streaming
|
|
agent events, and dispatching outbound replies.
|
|
|
|
Usage from a channel's ``main()``::
|
|
|
|
from EvoScientist.channels.standalone import run_standalone
|
|
|
|
channel = SomeChannel(config)
|
|
bus = MessageBus()
|
|
run_standalone(channel, bus, use_agent=True, send_thinking=True)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import signal
|
|
|
|
from .base import Channel
|
|
from .bus import MessageBus
|
|
from .bus.events import OutboundMessage
|
|
from .consumer import InboundConsumer
|
|
from .debug import emit_debug_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _create_standalone_agent():
|
|
"""Construct the synchronous agent without blocking the channel loop."""
|
|
from ..EvoScientist import create_cli_agent
|
|
|
|
return await asyncio.to_thread(create_cli_agent)
|
|
|
|
|
|
def _channel_trace_enabled(channel: Channel) -> bool:
|
|
"""Check if debug tracing is enabled on the channel."""
|
|
try:
|
|
return channel.is_debug_trace_enabled()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def _deliver_outbound(channel: Channel, msg: OutboundMessage) -> None:
|
|
"""Deliver an outbound message, including any media attachments."""
|
|
if msg.content:
|
|
sent = await channel.send(msg)
|
|
if not sent:
|
|
raise RuntimeError("send() returned False")
|
|
for media_path in msg.media:
|
|
media_ok = await channel.send_media(
|
|
recipient=msg.chat_id,
|
|
file_path=media_path,
|
|
metadata=msg.metadata,
|
|
)
|
|
if not media_ok:
|
|
raise RuntimeError(f"send_media() returned False for {media_path}")
|
|
|
|
|
|
async def standalone_outbound_dispatcher(
|
|
bus: MessageBus,
|
|
channel: Channel,
|
|
) -> None:
|
|
"""Consume outbound messages from the bus and send via channel."""
|
|
while True:
|
|
try:
|
|
msg: OutboundMessage = await asyncio.wait_for(
|
|
bus.consume_outbound(),
|
|
timeout=1.0,
|
|
)
|
|
except TimeoutError:
|
|
continue
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
try:
|
|
await _deliver_outbound(channel, msg)
|
|
except Exception as e:
|
|
emit_debug_event(
|
|
logger,
|
|
"standalone_dispatch_error",
|
|
channel=channel.name,
|
|
enabled=_channel_trace_enabled(channel),
|
|
recipient=msg.recipient,
|
|
error=str(e),
|
|
)
|
|
logger.error(f"Error sending outbound: {e}")
|
|
|
|
|
|
async def _async_main(
|
|
channel: Channel,
|
|
bus: MessageBus,
|
|
use_agent: bool,
|
|
send_thinking: bool,
|
|
) -> None:
|
|
"""Async entry point — gather channel, dispatcher and optional consumer."""
|
|
from .channel_manager import ChannelManager
|
|
|
|
channel.set_bus(bus)
|
|
if send_thinking:
|
|
channel.send_thinking = True
|
|
|
|
# Create a lightweight manager for the consumer to use
|
|
manager = ChannelManager(bus)
|
|
manager._channels[channel.name] = channel
|
|
|
|
await manager.start_health()
|
|
|
|
tasks = [channel.run()]
|
|
|
|
dispatcher = standalone_outbound_dispatcher(bus, channel)
|
|
tasks.append(dispatcher)
|
|
|
|
consumer: InboundConsumer | None = None
|
|
if use_agent:
|
|
logger.info("Loading EvoScientist agent...")
|
|
from ..gateway import create_runtime_gateways
|
|
|
|
# Agent construction performs synchronous MCP discovery through the
|
|
# owned-runtime bridge. Keep it off this already-running channel loop
|
|
# (and avoid blocking channel health/startup work while it loads).
|
|
agent = await _create_standalone_agent()
|
|
runtime_gateways = create_runtime_gateways()
|
|
logger.info("Agent loaded")
|
|
|
|
consumer = InboundConsumer(
|
|
bus=bus,
|
|
manager=manager,
|
|
agent=agent,
|
|
thread_id="",
|
|
graph_gateway=runtime_gateways.graph_gateway,
|
|
send_thinking=send_thinking,
|
|
)
|
|
manager.register_health_provider("consumer", lambda: consumer.metrics)
|
|
tasks.append(consumer.run())
|
|
if send_thinking:
|
|
logger.info("Thinking messages enabled")
|
|
|
|
async def _graceful_shutdown() -> None:
|
|
"""Graceful shutdown: drain consumer, flush outbound, stop channel."""
|
|
logger.info("Graceful shutdown initiated...")
|
|
if consumer is not None:
|
|
await consumer.stop()
|
|
# Drain outbound queue before stopping the channel
|
|
drained = 0
|
|
while True:
|
|
try:
|
|
msg = bus.outbound.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
try:
|
|
await asyncio.wait_for(_deliver_outbound(channel, msg), timeout=5.0)
|
|
if msg.content or msg.media:
|
|
drained += 1
|
|
except Exception:
|
|
pass
|
|
if drained:
|
|
logger.info(f"Outbound drain: {drained} sent")
|
|
channel._running = False
|
|
await channel.stop()
|
|
await manager.stop_health()
|
|
|
|
loop = asyncio.get_running_loop()
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
loop.add_signal_handler(
|
|
sig,
|
|
lambda s=sig: asyncio.create_task(_graceful_shutdown()),
|
|
)
|
|
|
|
await asyncio.gather(*tasks)
|
|
|
|
|
|
def run_standalone(
|
|
channel: Channel,
|
|
bus: MessageBus,
|
|
*,
|
|
use_agent: bool = False,
|
|
send_thinking: bool = False,
|
|
) -> None:
|
|
"""Synchronous entry point that spins up the standalone runner.
|
|
|
|
Parameters
|
|
----------
|
|
channel:
|
|
A fully-configured :class:`Channel` instance.
|
|
bus:
|
|
The :class:`MessageBus` shared with *channel*.
|
|
use_agent:
|
|
When ``True``, load the EvoScientist agent and process inbound
|
|
messages through it.
|
|
send_thinking:
|
|
When ``True`` **and** *use_agent* is set, forward intermediate
|
|
thinking messages to the channel.
|
|
"""
|
|
asyncio.run(_async_main(channel, bus, use_agent, send_thinking))
|