refactor: move MCP lifecycle out of AgentLoop (#5343)
This commit is contained in:
+38
-23
@@ -13,6 +13,8 @@ from rich.console import Console
|
||||
from nanobot import __logo__
|
||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.mcp import MCPProvider
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.outbound_events import (
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
@@ -84,6 +86,8 @@ def agent(
|
||||
# Create cron service with workspace-scoped store
|
||||
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
tools = ToolRegistry()
|
||||
mcp_provider = MCPProvider.from_config(runtime_config, tools)
|
||||
|
||||
_set_nanobot_logs(logs)
|
||||
|
||||
@@ -95,6 +99,7 @@ def agent(
|
||||
cron_service=cron,
|
||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
tool_registry=tools,
|
||||
)
|
||||
except ValueError as exc:
|
||||
_print_agent_start_error(exc)
|
||||
@@ -106,6 +111,12 @@ def agent(
|
||||
render_markdown=False,
|
||||
)
|
||||
|
||||
async def _close_runtime() -> None:
|
||||
try:
|
||||
await agent_loop.aclose()
|
||||
finally:
|
||||
await mcp_provider.aclose()
|
||||
|
||||
# Shared reference for progress callbacks
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
|
||||
@@ -149,30 +160,33 @@ def agent(
|
||||
if message:
|
||||
# Single message mode — direct call, no bus needed
|
||||
async def run_once() -> None:
|
||||
renderer = StreamRenderer(
|
||||
render_markdown=markdown,
|
||||
bot_name=runtime_config.agents.defaults.bot_name,
|
||||
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||
)
|
||||
response = await agent_loop.process_direct(
|
||||
message,
|
||||
session_id,
|
||||
on_progress=_make_progress(renderer),
|
||||
on_stream=renderer.on_delta,
|
||||
on_stream_end=renderer.on_end,
|
||||
)
|
||||
if not renderer.streamed:
|
||||
await renderer.close()
|
||||
print_kwargs: dict[str, Any] = {}
|
||||
if renderer.header_printed:
|
||||
print_kwargs["show_header"] = False
|
||||
cli_terminal._print_agent_response(
|
||||
response.content if response else "",
|
||||
try:
|
||||
await mcp_provider.connect()
|
||||
renderer = StreamRenderer(
|
||||
render_markdown=markdown,
|
||||
metadata=response.metadata if response else None,
|
||||
**print_kwargs,
|
||||
bot_name=runtime_config.agents.defaults.bot_name,
|
||||
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||
)
|
||||
await agent_loop.close_mcp()
|
||||
response = await agent_loop.process_direct(
|
||||
message,
|
||||
session_id,
|
||||
on_progress=_make_progress(renderer),
|
||||
on_stream=renderer.on_delta,
|
||||
on_stream_end=renderer.on_end,
|
||||
)
|
||||
if not renderer.streamed:
|
||||
await renderer.close()
|
||||
print_kwargs: dict[str, Any] = {}
|
||||
if renderer.header_printed:
|
||||
print_kwargs["show_header"] = False
|
||||
cli_terminal._print_agent_response(
|
||||
response.content if response else "",
|
||||
render_markdown=markdown,
|
||||
metadata=response.metadata if response else None,
|
||||
**print_kwargs,
|
||||
)
|
||||
finally:
|
||||
await _close_runtime()
|
||||
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
@@ -209,6 +223,7 @@ def agent(
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||
|
||||
async def run_interactive() -> None:
|
||||
await mcp_provider.connect()
|
||||
bus_task = asyncio.create_task(agent_loop.run())
|
||||
turn_done = asyncio.Event()
|
||||
turn_done.set()
|
||||
@@ -347,6 +362,6 @@ def agent(
|
||||
agent_loop.stop()
|
||||
outbound_task.cancel()
|
||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||
await agent_loop.close_mcp()
|
||||
await _close_runtime()
|
||||
|
||||
asyncio.run(run_interactive())
|
||||
|
||||
+11
-2
@@ -49,6 +49,8 @@ from nanobot import __logo__, __version__ # noqa: E402
|
||||
from nanobot import optional_features as feature_support # noqa: E402
|
||||
from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402
|
||||
from nanobot.agent.loop import AgentLoop # noqa: E402
|
||||
from nanobot.agent.tools.mcp import MCPProvider # noqa: E402
|
||||
from nanobot.agent.tools.registry import ToolRegistry # noqa: E402
|
||||
from nanobot.cli import terminal as cli_terminal # noqa: E402
|
||||
from nanobot.cli.agent import agent # noqa: E402
|
||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||
@@ -351,12 +353,15 @@ def serve(
|
||||
sync_workspace_templates(runtime_config.workspace_path)
|
||||
bus = MessageBus()
|
||||
session_manager = SessionManager(runtime_config.workspace_path)
|
||||
tools = ToolRegistry()
|
||||
mcp_provider = MCPProvider.from_config(runtime_config, tools)
|
||||
try:
|
||||
agent_loop = AgentLoop.from_config(
|
||||
runtime_config, bus,
|
||||
session_manager=session_manager,
|
||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
tool_registry=tools,
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
@@ -378,13 +383,17 @@ def serve(
|
||||
api_app = create_app(
|
||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
||||
api_key=api_key,
|
||||
prepare_agent=mcp_provider.connect,
|
||||
)
|
||||
|
||||
async def on_startup(_app: Any) -> None:
|
||||
await agent_loop._connect_mcp()
|
||||
await mcp_provider.connect()
|
||||
|
||||
async def on_cleanup(_app: Any) -> None:
|
||||
await agent_loop.close_mcp()
|
||||
try:
|
||||
await agent_loop.aclose()
|
||||
finally:
|
||||
await mcp_provider.aclose()
|
||||
|
||||
api_app.on_startup.append(on_startup)
|
||||
api_app.on_cleanup.append(on_cleanup)
|
||||
|
||||
@@ -14,6 +14,8 @@ from rich.console import Console
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.mcp import MCPProvider
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.cli import terminal as cli_terminal
|
||||
from nanobot.cli.runtime_config import _migrate_cron_store
|
||||
from nanobot.cli.webui_support import (
|
||||
@@ -233,6 +235,7 @@ def _print_gateway_health_endpoint(host: str, port: int) -> None:
|
||||
|
||||
async def _close_gateway_runtime(
|
||||
agent: AgentLoop,
|
||||
mcp_provider: MCPProvider,
|
||||
channels: Any,
|
||||
tasks: list[asyncio.Task[Any]],
|
||||
runtime_tasks: asyncio.Future[list[Any]] | None,
|
||||
@@ -240,18 +243,13 @@ async def _close_gateway_runtime(
|
||||
task_wait_timeout: float = 15.0,
|
||||
close_timeout: float = 15.0,
|
||||
) -> None:
|
||||
"""Cancel runtime tasks, then deterministically close agent resources.
|
||||
"""Cancel runtime tasks, then deterministically close application resources.
|
||||
|
||||
Order matters: runtime tasks (including the agent loop and any in-flight
|
||||
turn) are cancelled and awaited -- bounded -- before exec sessions,
|
||||
subagents, and MCP servers are torn down, so no active turn is using a
|
||||
shared resource when it closes. The final close is bounded and idempotent:
|
||||
the agent loop's own finally also calls ``close_mcp()``, so this runs again
|
||||
as a no-op when that path already completed, and as the guaranteed final
|
||||
close when it was skipped or cut short (which previously left asyncio
|
||||
subprocess transports alive past ``loop.close()``, producing
|
||||
"RuntimeError: Event loop is closed" noise and potentially orphaned
|
||||
processes at interpreter exit).
|
||||
turn) are cancelled and awaited -- bounded -- before the loop-owned resources
|
||||
and the application-owned MCP provider are torn down. The final close is
|
||||
bounded and idempotent, so it also covers a cancelled or incomplete loop
|
||||
cleanup without leaving subprocess transports alive past ``loop.close()``.
|
||||
"""
|
||||
# Some SDKs swallow task cancellation while attempting to reconnect.
|
||||
# Close channel transports before waiting for their runners to exit.
|
||||
@@ -272,10 +270,14 @@ async def _close_gateway_runtime(
|
||||
task.cancel()
|
||||
if runtime_tasks is not None and not runtime_tasks.done():
|
||||
runtime_tasks.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
|
||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc)
|
||||
for label, close in (
|
||||
("agent", agent.aclose),
|
||||
("MCP provider", mcp_provider.aclose),
|
||||
):
|
||||
try:
|
||||
await asyncio.wait_for(close(), timeout=close_timeout)
|
||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||
logger.warning("Gateway shutdown: {} cleanup incomplete: {}", label, exc)
|
||||
# Retrieving an already-finished gather prevents noisy unhandled exceptions,
|
||||
# but never wait for it here: its children were bounded individually above.
|
||||
if runtime_tasks is not None and runtime_tasks.done():
|
||||
@@ -414,6 +416,9 @@ def _run_gateway(
|
||||
route_policy=WebuiTurnRoutePolicy(session_manager),
|
||||
)
|
||||
|
||||
tools = ToolRegistry()
|
||||
mcp_provider = MCPProvider.from_config(config, tools)
|
||||
|
||||
# Create agent with cron service
|
||||
agent = AgentLoop.from_config(
|
||||
config, bus,
|
||||
@@ -431,6 +436,7 @@ def _run_gateway(
|
||||
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||
local_trigger_store=trigger_store,
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
tool_registry=tools,
|
||||
)
|
||||
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||
@@ -512,6 +518,7 @@ def _run_gateway(
|
||||
prompt, last_cursor = result
|
||||
key = dream_session_key()
|
||||
dream_runtime = agent.dream_runtime()
|
||||
await mcp_provider.connect()
|
||||
resp = await agent.process_direct(
|
||||
prompt,
|
||||
session_key=key,
|
||||
@@ -589,6 +596,7 @@ def _run_gateway(
|
||||
if isinstance(message_tool, MessageTool):
|
||||
suppress_token = message_tool.set_suppress_delivery(True)
|
||||
try:
|
||||
await mcp_provider.connect()
|
||||
resp = await agent.process_direct(
|
||||
prompt,
|
||||
session_key="heartbeat",
|
||||
@@ -668,7 +676,8 @@ def _run_gateway(
|
||||
webui_static_dist=webui_static_dist,
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
webui_mcp_runtime_status=agent.mcp_runtime_status,
|
||||
webui_mcp_runtime_status=mcp_provider.runtime_status,
|
||||
webui_mcp_reload=mcp_provider.reload,
|
||||
webui_skill_state_action=_webui_skill_state_action,
|
||||
config_path=Path(config_path),
|
||||
)
|
||||
@@ -844,6 +853,13 @@ def _run_gateway(
|
||||
await cron.start()
|
||||
# Re-read once on first admission to close the watcher subscription window.
|
||||
agent.runtime_resolver.invalidate()
|
||||
async def _run_agent() -> None:
|
||||
try:
|
||||
await mcp_provider.connect()
|
||||
await agent.run()
|
||||
finally:
|
||||
await mcp_provider.aclose()
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
watch_config_file(
|
||||
@@ -852,7 +868,7 @@ def _run_gateway(
|
||||
),
|
||||
name="nanobot-config-watcher",
|
||||
),
|
||||
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
||||
asyncio.create_task(_run_agent(), name="nanobot-agent-loop"),
|
||||
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
|
||||
asyncio.create_task(
|
||||
run_local_trigger_queue(
|
||||
@@ -910,7 +926,13 @@ def _run_gateway(
|
||||
agent.stop()
|
||||
# Cancel runtime tasks first, then deterministically close
|
||||
# exec/MCP resources while the event loop is still alive.
|
||||
await _close_gateway_runtime(agent, channels, tasks, runtime_tasks)
|
||||
await _close_gateway_runtime(
|
||||
agent,
|
||||
mcp_provider,
|
||||
channels,
|
||||
tasks,
|
||||
runtime_tasks,
|
||||
)
|
||||
# Flush all cached sessions to durable storage before exit.
|
||||
# This prevents data loss on filesystems with write-back
|
||||
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
||||
|
||||
Reference in New Issue
Block a user