Files
EvoScientist-Multi/EvoScientist/channels/standalone.py
T
dinos 8b1451cdda refactor(runtime): centralize async bridges under an owned runtime (#376)
* 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>
2026-07-27 14:17:57 +01:00

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))