refactor: move MCP lifecycle out of AgentLoop (#5343)

This commit is contained in:
chengyongru
2026-08-12 17:51:04 +08:00
committed by GitHub
parent 686dd0603e
commit 19997d20bb
39 changed files with 1192 additions and 846 deletions
+38 -23
View File
@@ -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
View File
@@ -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)
+39 -17
View File
@@ -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.).