Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ Main files:
- feeds tool results back into the model;
- stops when a final answer is produced or runtime limits are hit.

MCP connections are application-owned infrastructure. Composition roots create
an `MCPProvider`, share its `ToolRegistry` with `AgentLoop`, await `connect()`
before use, and guarantee `aclose()` during shutdown; the loop does not manage
that lifecycle. `AgentLoop.from_config()` therefore requires a caller-owned
`ToolRegistry`; callers using MCP share it with their application-owned
`MCPProvider`.

Keep this split in mind when debugging. If a problem is about channel routing, session keys, workspace selection, or outbound delivery, start in `agent/loop.py`. If it is about provider calls, tool calls, streaming, or iteration limits, start in `agent/runner.py`.

## Providers
Expand Down
20 changes: 1 addition & 19 deletions nanobot/agent/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,29 +42,11 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
)


async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)


def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
return mcp_tools.runtime_status(state)


async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state)


async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
await state.discard_session(msg.session_key)
return True
for handler in (
image_generation_tools.handle_runtime_control,
mcp_tools.handle_runtime_control,
):
if await handler(state, msg, tools):
return True
return False
return await image_generation_tools.handle_runtime_control(state, msg, tools)


class ContextBuilder:
Expand Down
51 changes: 18 additions & 33 deletions nanobot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,9 @@
)

if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
from nanobot.config.schema import (
ChannelsConfig,
Config,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
Expand Down Expand Up @@ -271,7 +269,7 @@ def __init__(
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict[str, MCPServerConfig] | None = None,
tool_registry: ToolRegistry | None = None,
channels_config: ChannelsConfig | None = None,
timezone: str | None = None,
session_ttl_minutes: int = 0,
Expand Down Expand Up @@ -379,7 +377,7 @@ def __init__(
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
self.tools = ToolRegistry()
self.tools = tool_registry if tool_registry is not None else ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
self._file_state_store = FileStateStore()
Expand All @@ -399,15 +397,11 @@ def __init__(
)
self._unified_session = unified_session
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
self._discarding_sessions: set[str] = set()
self._background_tasks: set[asyncio.Task[Any]] = set()
self._close_mcp_lock = asyncio.Lock()
self._close_lock = asyncio.Lock()
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
Expand Down Expand Up @@ -464,10 +458,15 @@ def from_config(
cls,
config: Config,
bus: MessageBus | None = None,
*,
tool_registry: ToolRegistry,
**extra: Any,
) -> AgentLoop:
"""Create an AgentLoop from config with the common parameter set.

The tool registry is caller-owned so application composition can share
it with infrastructure such as an ``MCPProvider``.

Extra keyword arguments are forwarded to ``AgentLoop.__init__``,
allowing callers to override or extend the standard config-derived
parameters (e.g. ``cron_service``, ``session_manager``).
Expand All @@ -486,8 +485,6 @@ def from_config(
config,
provider_snapshot_loader,
)
from nanobot.agent.plugins import agent_plugin_mcp_servers

return cls(
bus=bus,
provider=provider,
Expand All @@ -502,7 +499,6 @@ def from_config(
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
Expand All @@ -517,6 +513,7 @@ def from_config(
restart_mode=config.gateway.restart_mode,
provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader,
tool_registry=tool_registry,
**extra,
)

Expand Down Expand Up @@ -643,14 +640,6 @@ def _register_default_tools(

logger.info("Registered {} tools: {}", len(registered), registered)

async def _connect_mcp(self) -> None:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)

def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return connection state learned from real MCP runtime attempts."""
return agent_context.mcp_runtime_status(self)

def register_runtime_context_provider(
self,
provider: RuntimeContextProvider,
Expand Down Expand Up @@ -1162,7 +1151,6 @@ async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
try:
await self._connect_mcp()
logger.info("Agent loop started")

while self._running:
Expand Down Expand Up @@ -1253,8 +1241,7 @@ async def run(self) -> None:
active_tasks.add(task)
task.add_done_callback(active_tasks.discard)
finally:
# MCP stdio transports use AnyIO cancel scopes; close them from the task that opened them.
await self.close_mcp()
await self.aclose()

async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message: per-session serial, cross-session concurrent."""
Expand Down Expand Up @@ -1372,24 +1359,24 @@ async def _dispatch(self, msg: InboundMessage) -> None:
await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key)

async def close_mcp(self) -> None:
"""Stop active work, then close exec, subagent, and MCP resources.
async def aclose(self) -> None:
"""Stop active work, then close resources owned by the agent loop.

Resource teardown must still run if cancellation interrupts task draining.
Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup
phase in ``finally`` prevents a timed-out background task from leaving
subprocess transports alive after the event loop closes.
"""
# The agent loop closes itself from ``run()`` while gateway shutdown also
# The loop closes itself from ``run()`` while application shutdown also
# performs a guaranteed final close. Serialize those owners so they cannot
# tear down the same subprocess transports concurrently.
close_lock = getattr(self, "_close_mcp_lock", None)
# tear down the same resources concurrently.
close_lock = getattr(self, "_close_lock", None)
if close_lock is None:
close_lock = self._close_mcp_lock = asyncio.Lock()
close_lock = self._close_lock = asyncio.Lock()
async with close_lock:
await self._close_mcp_unlocked()
await self._aclose_unlocked()

async def _close_mcp_unlocked(self) -> None:
async def _aclose_unlocked(self) -> None:
errors: list[BaseException] = []
active_task_groups = getattr(self, "_active_tasks", {})
active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks})
Expand All @@ -1412,7 +1399,6 @@ async def _close_mcp_unlocked(self) -> None:
cleanup_steps = (
self.subagents.close,
self._exec_session_manager.close_all,
lambda: agent_context.close_mcp(self),
)
for cleanup in cleanup_steps:
try:
Expand Down Expand Up @@ -2301,7 +2287,6 @@ async def process_direct(
"""Process an external message directly and return the outbound payload."""
if channel == "system":
raise ValueError("channel 'system' is reserved for internal messages")
await self._connect_mcp()
metadata: dict[str, Any] = {}
if not persist_user_message:
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
Expand Down
Loading
Loading