diff --git a/condor/acp/pydantic_ai_client.py b/condor/acp/pydantic_ai_client.py index 6e646ed14..482402615 100644 --- a/condor/acp/pydantic_ai_client.py +++ b/condor/acp/pydantic_ai_client.py @@ -16,7 +16,7 @@ import os import time import uuid -from typing import Any, AsyncIterator, Iterator +from typing import Any, AsyncIterator, Awaitable, Callable, Iterator from urllib.parse import urlparse from .client import ( @@ -33,6 +33,10 @@ log = logging.getLogger(__name__) +# pydantic-ai 2.x prints an ASCII banner on stderr once per process. Every +# pydantic-ai import in this module is deferred, so this runs ahead of them all. +os.environ.setdefault("PYDANTIC_AI_NO_BANNER", "1") + # Model prefix → pydantic-ai model string mapping # Users set agent_key like "ollama:llama3.1:70b" or "openai:gpt-4o" @@ -65,9 +69,13 @@ # How often the MCP lifecycle task checks its stdio transports for a dead # server between shutdown checks (CORR-332). A killed subprocess never raises -# out of ``run_mcp_servers()``; it only closes the session's streams. +# out of the agent's context; it only closes the session's streams. MCP_TRANSPORT_POLL_SECONDS = 2.0 +# How long one liveness ping may take before the server is assumed to be busy +# rather than gone. A dead transport answers at once, with an error. +MCP_PING_TIMEOUT_SECONDS = 5.0 + def _get_server_semaphore(base_url: str) -> asyncio.Semaphore: if base_url not in _SERVER_SEMAPHORES: @@ -188,7 +196,7 @@ async def healthcheck_local_backend( def _make_openai_compat_model(model_id: str, provider: Any) -> Any: - """Build an OpenAIModel that never sends an assistant ``content: null``. + """Build an OpenAIChatModel that never sends an assistant ``content: null``. Ollama's OpenAI-compatible ``/v1/chat/completions`` endpoint rejects any message whose ``content`` is null with ``invalid message content type: @@ -203,9 +211,9 @@ def _make_openai_compat_model(model_id: str, provider: Any) -> Any: """ global _NULL_SAFE_MODEL_CLS if _NULL_SAFE_MODEL_CLS is None: - from pydantic_ai.models.openai import OpenAIModel + from pydantic_ai.models.openai import OpenAIChatModel - class _NullContentSafeOpenAIModel(OpenAIModel): + class _NullContentSafeOpenAIChatModel(OpenAIChatModel): async def _map_messages(self, *args: Any, **kwargs: Any) -> Any: mapped = await super()._map_messages(*args, **kwargs) for msg in mapped: @@ -217,7 +225,7 @@ async def _map_messages(self, *args: Any, **kwargs: Any) -> Any: msg["content"] = "" return mapped - _NULL_SAFE_MODEL_CLS = _NullContentSafeOpenAIModel + _NULL_SAFE_MODEL_CLS = _NullContentSafeOpenAIChatModel return _NULL_SAFE_MODEL_CLS(model_id, provider=provider) @@ -359,20 +367,42 @@ async def call_tool( return _GATED_TOOLSET_CLS -def _dead_transport(servers: list[Any]) -> str | None: - """Label of the first MCP server whose session streams are closed, if any. +def _server_label(server: Any) -> str: + """The command an MCP toolset spawned, for a log line a human can act on.""" + transport = getattr(getattr(server, "client", None), "transport", None) + return getattr(transport, "command", None) or repr(server) + + +async def _dead_transport(servers: list[Any]) -> str | None: + """Label of the first MCP server whose transport is closed, if any. - When an MCP subprocess dies, the stdio reader hits EOF and the MCP - session's receive loop closes both of its streams. Nothing raises until - the next request (``anyio.ClosedResourceError``), so a closed stream is - the only signal there is (CORR-332). ``_closed`` is anyio's memory-stream - flag; a server that has not opened its streams reads as healthy. + When an MCP subprocess dies nothing raises until the next request, and the + toolset goes on reporting itself running and connected — so the only honest + signal is to send one (CORR-332). The request is a ``ping``, and what counts + is *who* answers it: a dead transport fails it locally with + ``CONNECTION_CLOSED``, while a live server answers — with a result, or with + a JSON-RPC error of its own (an MCP SDK 2.x server has no ``ping`` method + and says so). Any answer from the far side is proof of life. + + A *timeout* is read as healthy on purpose: a server busy inside a slow tool + must not be torn down. A server that has not opened its client reads as + healthy too. """ + from mcp.types import CONNECTION_CLOSED + for server in servers: - for attr in ("_read_stream", "_write_stream"): - stream = getattr(server, attr, None) - if stream is not None and getattr(stream, "_closed", False): - return getattr(server, "command", None) or repr(server) + client = getattr(server, "client", None) + if client is None: + continue + try: + await asyncio.wait_for(client.ping(), MCP_PING_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + continue # busy, not dead + except Exception as exc: + code = getattr(exc, "code", None) + if code is not None and code != CONNECTION_CLOSED: + continue # the server answered, if only to refuse + return _server_label(server) return None @@ -381,7 +411,7 @@ class PydanticAIClient: Mirrors ACPClient's interface: start() → prompt_stream() → stop(). MCP servers are launched as stdio subprocesses, same as ACP does, - but tools are consumed via pydantic-ai's MCPServerStdio integration. + but tools are consumed via pydantic-ai's MCPToolset integration. Model resolution: - "ollama:llama3.1" → uses ollama provider (localhost:11434) @@ -689,12 +719,13 @@ async def _fetch_ollama_native_model(self, base_url: str) -> str | None: async def start(self) -> None: """Initialize MCP servers and create the pydantic-ai agent.""" from pydantic_ai import Agent - from pydantic_ai.mcp import MCPServerStdio + from pydantic_ai.capabilities import PrepareTools + from pydantic_ai.mcp import MCPToolset, StdioTransport toolsets = [] for srv_config in self.mcp_server_configs: command = srv_config["command"] - # StdioServerParameters requires list[str]; YAML/config may yield ints + # StdioTransport requires list[str]; YAML/config may yield ints # (e.g. numeric hummingbot passwords) that only surface with pydantic-ai # backends (lmstudio:/ollama:/openrouter:), not ACP agents. args = [str(a) for a in srv_config.get("args", [])] @@ -709,11 +740,12 @@ async def start(self) -> None: if isinstance(env_entry, dict): env[str(env_entry["name"])] = str(env_entry["value"]) - mcp_server = MCPServerStdio( - command, - args=args, - env=env, - timeout=30, + mcp_server = MCPToolset( + # keep_alive=False: with the default the subprocess outlives + # the agent's context, and every stopped session would leak one + # Python process per server. + StdioTransport(command, args, env=env, keep_alive=False), + init_timeout=30, # pydantic-ai drops a server's ``instructions`` by default; the # ACP host forwards them, so ask for them here too or the condor # server's routing rules never reach a pydantic-ai model. @@ -724,12 +756,14 @@ async def start(self) -> None: self._mcp_servers.append(mcp_server) model = await self._build_model() - prepare = self._prepare_tools if self.allowed_tools else None + capabilities = ( + [PrepareTools(self._prepare_tools)] if self.allowed_tools else None + ) self._agent = Agent( model, instructions=self.system_prompt or None, toolsets=self._gate_toolsets(toolsets), - prepare_tools=prepare, + capabilities=capabilities, ) # Resolve the global semaphore for this server's base URL so all client @@ -848,7 +882,7 @@ async def _run_mcp_lifecycle(self) -> None: """ servers = list(self._mcp_servers) try: - async with self._agent.run_mcp_servers(): + async with self._agent: self._ready_event.set() while not self._shutdown_event.is_set(): with contextlib.suppress(asyncio.TimeoutError): @@ -856,7 +890,7 @@ async def _run_mcp_lifecycle(self) -> None: self._shutdown_event.wait(), timeout=MCP_TRANSPORT_POLL_SECONDS, ) - dead = _dead_transport(servers) + dead = await _dead_transport(servers) if dead is not None and not self._shutdown_event.is_set(): raise ConnectionError(f"MCP server transport closed: {dead}") except asyncio.CancelledError as exc: @@ -884,14 +918,14 @@ async def _run_mcp_lifecycle(self) -> None: self._lifecycle_error = exc self._teardown_after_lifecycle_failure() - def _mark_dead_if_transport_closed(self, exc: BaseException) -> None: + async def _mark_dead_if_transport_closed(self, exc: BaseException) -> None: """A turn failed: if an MCP transport is gone, the client is dead. The prompt usually meets a killed subprocess before the lifecycle task's next poll does, so don't leave ``alive`` True in between. The lifecycle task still exits on its next poll and closes the servers. """ - dead = _dead_transport(self._mcp_servers) + dead = await _dead_transport(list(self._mcp_servers)) if dead is None or self._lifecycle_error is not None: return log.error( @@ -1027,7 +1061,7 @@ async def prompt_stream( Uses pydantic-ai's streaming run with MCP tools. Tool calls go through the permission callback for risk checking. MCP servers are already running (started in start()), so we - call iter() directly without run_mcp_servers(). + call iter() directly without entering the agent again. ``images`` become ``BinaryContent`` parts ahead of the text, the same order the ACP path uses. Forwarded optimistically: unlike ACP there is @@ -1035,9 +1069,69 @@ async def prompt_stream( hardcoded list of model ids is a maintenance burden that would go stale faster than the providers ship. A model that cannot see says so in its own error, which reaches the user as the turn's error (FEAT-098). + + The run itself lives in a task this client owns (:meth:`_run_turn`); + this generator only relays its events. A consumer may stop reading at + any ``yield`` (a WS drop, a page reload, a cancelled prompt task), and + pydantic-ai's run cannot be unwound from a generator that is being + closed — its cancel scopes must exit in the task that entered them, + the same constraint ``_run_mcp_lifecycle`` exists for. Cancelling the + owned task instead ends the turn in order, so the request slot comes + back when the reader leaves rather than when this generator is + collected (CORR-714). """ assert self._agent is not None, "Client not started" + # None marks the end of the turn; the task puts it however it ends. + events: asyncio.Queue[ACPEvent | None] = asyncio.Queue() + turn = asyncio.create_task(self._run_turn(text, images, events)) + try: + while (event := await events.get()) is not None: + yield event + # The reader is back for more: let the turn take its next step. + events.task_done() + finally: + turn.cancel() + try: + await turn + except asyncio.CancelledError: + # The turn's cancellation is ours to absorb -- only re-raise + # when it is *this* task being cancelled. + if not turn.cancelled(): + raise + + async def _run_turn( + self, + text: str, + images: list | None, + events: asyncio.Queue[ACPEvent | None], + ) -> None: + """Run one turn start to finish, handing its events to ``events``. + + Each event waits for the reader to come back for the next one, so the + run advances in step with its consumer exactly as it did when + ``prompt_stream`` was the run: ``abort_prompt`` still stops it at the + step the reader has reached. ``PromptDone`` is the exception — nothing + follows it, and waiting there would keep the slot for a reader that + (rightly) stops at it. + """ + + async def emit(event: ACPEvent) -> None: + events.put_nowait(event) + if not isinstance(event, PromptDone): + await events.join() + + try: + await self._run_turn_steps(text, images, emit) + finally: + events.put_nowait(None) + + async def _run_turn_steps( + self, + text: str, + images: list | None, + emit: Callable[[ACPEvent], Awaitable[None]], + ) -> None: # Serialize requests: local inference servers (LM Studio, Ollama) process # one request at a time. Without this, concurrent ticks race to connect # and the losing ticks ConnectTimeout against a busy server. Cloud @@ -1071,22 +1165,22 @@ async def prompt_stream( break if isinstance(node, End): - # Final result -- extract text from the result - if hasattr(node, "data") and node.data: - result_data = node.data - if hasattr(result_data, "data"): - yield TextChunk(text=str(result_data.data)) + # Nothing to project: the final text already went + # out as the last CallToolsNode's TextPart. break if isinstance(node, ModelRequestNode): elapsed = time.monotonic() - start_time - yield Heartbeat(elapsed_seconds=elapsed) + await emit(Heartbeat(elapsed_seconds=elapsed)) for event in self._tool_return_events(node, blocked_ids): - yield event + await emit(event) elif isinstance(node, CallToolsNode): - async for event in self._response_events(node, blocked_ids): - yield event + async with contextlib.aclosing( + self._response_events(node, blocked_ids) + ) as response_events: + async for event in response_events: + await emit(event) # Accumulate messages so the next prompt_stream() call sees # this turn's context via message_history. An aborted run @@ -1098,15 +1192,17 @@ async def prompt_stream( elif aborted: self._message_history.extend(run.new_messages()) - yield PromptDone(stop_reason="cancelled" if aborted else "end_turn") + await emit( + PromptDone(stop_reason="cancelled" if aborted else "end_turn") + ) except asyncio.TimeoutError: - yield PromptDone(stop_reason="timeout") + await emit(PromptDone(stop_reason="timeout")) except Exception as e: log.exception("PydanticAI prompt error: %s", e) - self._mark_dead_if_transport_closed(e) - yield TextChunk(text=self._format_error(e)) - yield PromptDone(stop_reason="error") + await self._mark_dead_if_transport_closed(e) + await emit(TextChunk(text=self._format_error(e))) + await emit(PromptDone(stop_reason="error")) @contextlib.asynccontextmanager async def _usage_counted(self, run: Any) -> AsyncIterator[None]: @@ -1115,9 +1211,9 @@ async def _usage_counted(self, run: Any) -> AsyncIterator[None]: On the way out of the run, not beside the history accumulation: a finished run, a stopped one and one that raised all spent their requests, and so does one whose consumer walked away mid-answer (a WS - drop, a page reload), which closes this generator at a ``yield`` and - never reaches the lines after the node loop. The run is still open - here, so ``PromptDone`` — yielded after this exits — already sees the + drop, a page reload), which cancels the turn at an ``emit`` and never + reaches the lines after the node loop. The run is still open + here, so ``PromptDone`` — emitted after this exits — already sees the turn's tokens. """ try: @@ -1141,7 +1237,7 @@ def _fold_usage(self, run: Any) -> None: try: from pydantic_ai.messages import ModelResponse - ru = run.usage() + ru = run.usage new_messages = ( run.result.new_messages() if run.result is not None diff --git a/condor/runtime/context.py b/condor/runtime/context.py index 7176f2201..a243029ba 100644 --- a/condor/runtime/context.py +++ b/condor/runtime/context.py @@ -132,7 +132,7 @@ def _chat_mcp_tools() -> tuple[str, ...]: Names come from ``mcp_servers.*.profiles`` for the same reason :func:`toolsets.seat_tools` reads them there: those are leaf string modules, the definition site of each ring, and importing a ``server.py`` to ask would - parse argv and build a ``FastMCP`` singleton as a side effect. The import is + parse argv and build a ``MCPServer`` singleton as a side effect. The import is function-local to keep that dependency off ``context``'s import path. The tick seat keeps its own, deliberately narrower list in diff --git a/condor/runtime/toolsets.py b/condor/runtime/toolsets.py index 594a13475..5bdd3f2d6 100644 --- a/condor/runtime/toolsets.py +++ b/condor/runtime/toolsets.py @@ -128,7 +128,7 @@ def seat_tools(agent_slug: str | None, tick: bool = False) -> list[dict[str, Any Names come from ``mcp_servers.*.profiles`` rather than from the servers themselves on purpose: importing either ``server.py`` parses argv and builds - a ``FastMCP`` singleton, neither of which a web request has any business + a ``MCPServer`` singleton, neither of which a web request has any business doing. ``server.py`` resolves the same names against its own functions at import, so a table that drifts fails there, loudly, and not here. diff --git a/condor/web/routes/agents.py b/condor/web/routes/agents.py index 02a19eeb5..e596bd05a 100644 --- a/condor/web/routes/agents.py +++ b/condor/web/routes/agents.py @@ -1936,7 +1936,7 @@ def _tool_cards(slug: str, allowlist: list[str]) -> list[ToolCard]: ``condor.runtime.toolsets.seat_tools`` is the single place that knows what a seat mounts, so the panel asks it rather than re-deriving the rings here. It reads the two ``profiles.py`` leaf modules — never ``server.py``, whose - import parses argv and builds a ``FastMCP`` singleton. + import parses argv and builds a ``MCPServer`` singleton. """ from condor.runtime.toolsets import canonical_tool_name, seat_tools diff --git a/mcp_servers/_profiles.py b/mcp_servers/_profiles.py index 8dcc07b5e..e230f4f96 100644 --- a/mcp_servers/_profiles.py +++ b/mcp_servers/_profiles.py @@ -11,20 +11,24 @@ once instead of drifting between two copies. A leaf module on purpose. It must import neither ``server.py`` (importing one -parses argv and builds a ``FastMCP`` singleton as a side effect) nor anything +parses argv and builds a ``MCPServer`` singleton as a side effect) nor anything that builds one — ``mcp_servers/hummingbot_api/__init__.py`` lazy-loads ``main`` precisely so that the tables stay reachable without waking a server. Hence the -``FastMCP`` annotation below is a type-checking import only. +``MCPServer`` annotation below is a type-checking import only, and the one SDK +import this module needs at runtime is deferred to registration, by which point +the caller is a server. """ from __future__ import annotations import argparse +import functools +import inspect from collections.abc import Callable, Iterable, Mapping from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover - annotation only, never imported at runtime - from mcp.server.fastmcp import FastMCP + from mcp.server.mcpserver import MCPServer def parse_profile_flags(default_profile: str) -> tuple[str, tuple[str, ...]]: @@ -101,8 +105,29 @@ def resolve_profiles( } +def _surface_errors(fn): + """Keep a tool's exception message in the result the model reads. + + MCP SDK >= 2.1 reports any non-SDK exception as a bare + ``Error executing tool ``. Agents act on the message, so re-raise + as the SDK's ToolError, which is passed through verbatim. + """ + from mcp.server.mcpserver.exceptions import ToolError + + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + try: + return await fn(*args, **kwargs) + except ToolError: + raise + except Exception as exc: + raise ToolError(str(exc)) from exc + + return wrapper + + def register_tools( - server: FastMCP, + server: MCPServer, tool_profiles: Mapping[str, tuple], profile: str, muted: Iterable[str] = (), @@ -119,6 +144,11 @@ def register_tools( and a name this profile never mounts is ignored rather than refused: seats mount different rings, so "off here, never mounted there" is an ordinary difference between seats and not a mistake to report. + + Every tool must be a coroutine function. The SDK runs a sync ``def`` on a + worker thread with no running loop, and ``_surface_errors`` awaits what it + wraps — so a sync tool is refused here, at startup, rather than discovered + on its first call. """ try: tools = tool_profiles[profile] @@ -129,6 +159,8 @@ def register_tools( ) from None switched_off = set(muted) for fn in tools: + if not inspect.iscoroutinefunction(fn): + raise TypeError(f"MCP tool {fn.__name__!r} must be an async function") if fn.__name__ in switched_off: continue - server.tool()(fn) + server.tool()(_surface_errors(fn)) diff --git a/mcp_servers/condor/profiles.py b/mcp_servers/condor/profiles.py index 8eedde938..259efbbcd 100644 --- a/mcp_servers/condor/profiles.py +++ b/mcp_servers/condor/profiles.py @@ -2,7 +2,7 @@ A leaf module on purpose: strings and nothing else. ``server.py`` cannot be imported to ask what it registers — importing it parses argv and builds a -``FastMCP`` singleton as an import side effect — so the web process, which has +``MCPServer`` singleton as an import side effect — so the web process, which has to render a switch per tool, reads the tables from here instead. ``server.py`` resolves every name below in its own module namespace at import diff --git a/mcp_servers/condor/server.py b/mcp_servers/condor/server.py index ceae4c3fa..01ddd39d9 100644 --- a/mcp_servers/condor/server.py +++ b/mcp_servers/condor/server.py @@ -7,7 +7,7 @@ from collections.abc import Iterable from typing import Any, Literal -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from condor.telemetry import taps as telemetry_taps from mcp_servers._profiles import make_resolver @@ -350,7 +350,7 @@ def _build_instructions() -> str: return "\n\n".join(sections) -mcp = FastMCP("condor", instructions=_build_instructions()) +mcp = MCPServer("condor", instructions=_build_instructions()) @handle_errors("delegate task") @@ -1333,7 +1333,7 @@ async def trading_agent_journal_write( # The rings themselves — which tool sits in which one, and why — moved to # ``profiles.py`` as plain name strings (FEAT-091), because the web process has # to read them to draw a switch per tool and cannot import *this* module to ask: -# importing it parses argv and builds the ``FastMCP`` singleton. Here the names +# importing it parses argv and builds the ``MCPServer`` singleton. Here the names # are resolved back into functions, at import, which is what keeps the table and # the functions provably in step. @@ -1349,7 +1349,7 @@ async def trading_agent_journal_write( def register_tools( - server: FastMCP, + server: MCPServer, profile: str = DEFAULT_TOOL_PROFILE, muted: Iterable[str] = (), ) -> None: diff --git a/mcp_servers/hummingbot_api/__init__.py b/mcp_servers/hummingbot_api/__init__.py index df4c4cc92..a192be6ca 100644 --- a/mcp_servers/hummingbot_api/__init__.py +++ b/mcp_servers/hummingbot_api/__init__.py @@ -16,7 +16,7 @@ def __getattr__(name: str): It used to be a plain ``from .server import main`` up here, which made importing *anything* in this package wake the server — and ``server.py`` - parses argv and builds a ``FastMCP`` singleton at import. ``profiles.py`` + parses argv and builds a ``MCPServer`` singleton at import. ``profiles.py`` (FEAT-091) exists so the web process can read the tool tables without any of that, and a package-level import of the server would have quietly undone it, since importing a submodule imports its package first. diff --git a/mcp_servers/hummingbot_api/profiles.py b/mcp_servers/hummingbot_api/profiles.py index b729f3043..63a1aeac2 100644 --- a/mcp_servers/hummingbot_api/profiles.py +++ b/mcp_servers/hummingbot_api/profiles.py @@ -2,7 +2,7 @@ A leaf module on purpose: strings and nothing else. ``server.py`` cannot be imported to ask what it registers — importing it parses argv and builds a -``FastMCP`` singleton as an import side effect — so the web process, which has +``MCPServer`` singleton as an import side effect — so the web process, which has to render a switch per tool, reads the tables from here instead. ``server.py`` resolves every name below in its own module namespace at import diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index 740f48984..6a12c7fe9 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -9,7 +9,7 @@ from collections.abc import Iterable from typing import Any, Literal -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp_servers._profiles import make_resolver from mcp_servers._profiles import register_tools as _register_tools @@ -66,8 +66,8 @@ ) logger = logging.getLogger("hummingbot-mcp") -# Initialize FastMCP server -mcp = FastMCP("hummingbot-mcp") +# Initialize MCPServer server +mcp = MCPServer("hummingbot-mcp") # Server Management Tools @@ -2203,7 +2203,7 @@ async def explore_geckoterminal( # The rings themselves — which tool sits in which one, and why — moved to # ``profiles.py`` as plain name strings (FEAT-091), because the web process has # to read them to draw a switch per tool and cannot import *this* module to ask: -# importing it parses argv and builds the ``FastMCP`` singleton. Here the names +# importing it parses argv and builds the ``MCPServer`` singleton. Here the names # are resolved back into functions, at import, which is what keeps the table and # the functions provably in step. @@ -2219,7 +2219,7 @@ async def explore_geckoterminal( def register_tools( - server: FastMCP, + server: MCPServer, profile: str = DEFAULT_TOOL_PROFILE, muted: Iterable[str] = (), ) -> None: @@ -2281,7 +2281,7 @@ async def _run(): "💡 Use 'configure_server' tool to view or update the API server connection" ) - # Run the server with FastMCP + # Run the server with MCPServer # Connection to API will happen lazily on first tool use try: await mcp.run_stdio_async() diff --git a/pyproject.toml b/pyproject.toml index e3577e49a..3c5bdf994 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,14 +20,14 @@ dependencies = [ # the API container runs (2.2.6), so the two environments stay aligned. "pandas-ta>=0.4.71b", "geckoterminal-py", - "mcp", + "mcp>=2.2,<3", "fastapi", "uvicorn[standard]", "python-jose[cryptography]", "faster-whisper", "matplotlib>=3.10.8", "scipy>=1.17.1", - "pydantic-ai[mcp]", + "pydantic-ai[groq]>=2.52,<3", "beautifulsoup4>=4.14.3", "markdown>=3.10.2", "bleach>=6.4.0", diff --git a/tests/fixtures/mcp_tool_surface_v1.json b/tests/fixtures/mcp_tool_surface_v1.json new file mode 100644 index 000000000..d76597a17 --- /dev/null +++ b/tests/fixtures/mcp_tool_surface_v1.json @@ -0,0 +1,4786 @@ +{ + "condor": { + "control_agent": { + "description": "Run and steer live agent instances (start / stop / pause / resume).\n\n An *instance* is one running session of an agent's loop, identified by\n ``agent_id``. Start one from a loop \u2014 or from a bare agent slug, since\n every agent is loopable. See `manage_agents` for the identities and\n `manage_loops` for the playbooks; read what a running instance did with\n `trading_agent_journal_read`.\n\n STOP vs SHUTDOWN: \"stop\" halts the loop and KEEPS its open positions.\n \"shutdown\" is the emergency exit \u2014 it winds down this session's positions and\n executors per the agent's shutdown.md policy (closes perp, keeps spot by\n default). Reach for \"stop\" unless the user wants to be out of the market.\n\n Actions:\n - \"list\": All running instances with status.\n - \"start\": Start a session (requires loop_id, optional config overrides).\n loop_id may be a BARE AGENT SLUG \u2014 the agent then runs its only\n loop, or a default playbook created from its identity on first start.\n - \"stop\": Stop a running instance, keeping positions (requires agent_id).\n - \"shutdown\": Stop AND wind down positions/executors (requires agent_id).\n - \"pause\": Pause a running instance (requires agent_id).\n - \"resume\": Resume a paused instance (requires agent_id).\n - \"get_state\" / \"set_state\": Read/write the instance's own scratch state\n (requires agent_id; set_state also requires key). State is for cursors and\n counters a loop would otherwise re-derive every tick \u2014 anything worth\n *remembering* belongs in `manage_memory`. The namespace is derived from\n agent_id, so an instance only ever sees its own.\n\n Args:\n action: The lifecycle action to take on the loop.\n agent_id: The running instance (everything except list and start).\n loop_id: Loop key \"agent_slug.loop_slug\", or a bare agent slug\n (start only).\n config: Overrides on the loop's defaults, for \"start\" only: agent_key,\n model_base_url (for LM Studio/vLLM), execution_mode, frequency_sec,\n tick_timeout_sec (wall-clock budget for one tick's agent session;\n 0 = runtime default of 600s), total_amount_quote, trading_context,\n risk_limits, server_name, max_ticks.\n key: State key. get_state reads one key, or the whole state if omitted;\n set_state requires it.\n value: JSON-serializable value to store (set_state).\n expires_in: TTL in seconds for the stored value (set_state); None = no expiry.\n clear: Delete ``key`` instead of writing it (set_state).\n\n Returns:\n Action-specific result dict.\n ", + "inputSchema": { + "properties": { + "action": { + "enum": [ + "list", + "start", + "stop", + "pause", + "resume", + "shutdown", + "get_state", + "set_state", + "list_agents", + "start_agent", + "stop_agent", + "pause_agent", + "resume_agent", + "shutdown_agent" + ], + "title": "Action", + "type": "string" + }, + "agent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agent Id" + }, + "clear": { + "default": false, + "title": "Clear", + "type": "boolean" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "expires_in": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires In" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "loop_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loop Id" + }, + "value": { + "default": null, + "title": "Value" + } + }, + "required": [ + "action" + ], + "title": "control_agentArguments", + "type": "object" + }, + "name": "control_agent", + "outputSchema": null + }, + "delegate": { + "description": "Hand work to another agent \u2014 detached, or blocking for an answer.\n\n DELEGATE is the ONLY way to reach another agent, and it has two shapes:\n\n - **\"start\"** hands a goal-oriented task to a DETACHED agent that works\n autonomously until done, then notifies the user \u2014 while you stay free to do\n other things. Use it for \"go build/scan/produce X and ping me when\n finished\" (e.g. \"create a routine that scans SOL pools\"). Add\n ``on_complete=\"resume\"`` when you want the answer yourself, then end your\n turn: you are woken with it.\n - **\"ask\"** BLOCKS and returns the agent's answer as text. Use it when you\n need another domain's answer to carry on with your own reasoning right now\n (\"what range should this LP use?\") and the answer is not itself the\n deliverable the user is waiting on.\n\n Prefer \"start\" whenever there is a user watching: they get a task id, a\n progress list and a transcript instead of a frozen turn. Prefer \"ask\" when\n there is not \u2014 a tick or a background worker runs for nobody's conversation,\n so \"resume\" has nothing to wake and \"notify\" would send the answer to the\n user instead of to you.\n\n Either way the agent runs unrestricted with full auto-approve, so reach for\n another agent only when you trust it with the task.\n\n The user tracks a delegation on whichever surface they are on \u2014 the\n /delegations command in Telegram, the Tasks list in the chat's context dock\n on the dashboard \u2014 and is pinged automatically when it finishes. Never invent\n a status command (there is no \"/task\", and /delegations exists only in\n Telegram); \"start\" returns a next_steps hint already worded for this session's\n surface, so relay that rather than guessing.\n\n Actions:\n - \"start\": Begin a delegation (requires agent, task). Returns immediately with\n {\"task_id\", \"status\": \"running\", \"next_steps\"} \u2014 does NOT wait for completion.\n - \"ask\": Ask an agent and WAIT for its answer (requires agent, task). Returns\n {\"agent\", \"answer\"}. Refused if you are yourself answering an ask (depth\n stops at one) or if you name your own slug.\n - \"list\": List in-flight/finished delegations (task_id, agent, status).\n - \"get\": Get a delegation's status + result/error (requires task_id).\n - \"stop\": Cancel a running delegation (requires task_id).\n\n An agent may pass its OWN slug to start a background copy of itself (FEAT-041)\n \u2014 same identity, playbooks and memory, running unattended on a long job while\n the interactive session stays responsive. That copy cannot spawn a further copy\n of itself; the recursion stops at depth one.\n\n Args:\n action: What to do with the agent or the delegation.\n agent: Agent slug to reach (for start/ask). For \"start\" your own slug is\n allowed and means \"a background session of me\"; for \"ask\" it is\n refused, because asking yourself is a round trip through your own\n brain.\n task: The one-off task or question, in plain language (for start/ask).\n task_id: Delegation id returned by start (for get/stop).\n on_complete: What this conversation gets when the task ends (for start).\n \"notify\" (default) pings the user with the result AND writes the\n outcome into THIS conversation \u2014 recorded in the transcript and shown\n in the chat as it lands, so an already-open session sees it without a\n reload. What it does not do is give you a turn: the result is there\n for the human to read, not handed back to you. So never tell the user\n the result will not reach the chat. \"resume\" additionally hands the\n result back to you in a new turn of THIS conversation, so use it when\n you intend to do something with the result yourself (\"research X,\n then draft the summary\"). With \"resume\" you must end your turn after\n starting the task: you will be woken with the answer.\n timeout_sec: Wall-clock budget for the whole background task, in seconds\n (for start). Omit it (0) for the default of 900s. This knob only\n goes UP: 900s is also the floor, and a smaller number is raised back\n to it, so do not try to give a task less. Raise it when you KNOW the\n job is long \u2014 several routines to build, a research sweep, a\n multi-step backtest \u2014 because a task that outlives its budget is\n cut off mid-run and loses whatever it had not finished. The ceiling\n is 1800s: an agent session has its own ~31-minute hard stop, so\n asking for more only delays the same cut-off. For work bigger than\n that, split it across delegations.\n context: Extra context for the agent \u2014 relevant numbers, the user's\n intent (for ask).\n\n Returns:\n Action-specific result dict.\n ", + "inputSchema": { + "properties": { + "action": { + "enum": [ + "start", + "ask", + "list", + "get", + "stop" + ], + "title": "Action", + "type": "string" + }, + "agent": { + "default": "", + "title": "Agent", + "type": "string" + }, + "context": { + "default": "", + "title": "Context", + "type": "string" + }, + "on_complete": { + "default": "notify", + "enum": [ + "notify", + "resume" + ], + "title": "On Complete", + "type": "string" + }, + "task": { + "default": "", + "title": "Task", + "type": "string" + }, + "task_id": { + "default": "", + "title": "Task Id", + "type": "string" + }, + "timeout_sec": { + "default": 0, + "title": "Timeout Sec", + "type": "integer" + } + }, + "required": [ + "action" + ], + "title": "delegateArguments", + "type": "object" + }, + "name": "delegate", + "outputSchema": null + }, + "get_available_models": { + "description": "List every model/provider currently usable for a trading agent's ``agent_key``.\n\n Use this when helping the user pick a model for an agent they are building\n (agent_builder skill) \u2014 recommend from what is ACTUALLY configured, not a\n hardcoded default. Read-only; never returns key values.\n\n Args:\n openrouter_query: Optional substring to filter the OpenRouter catalog by\n slug or name (e.g. \"claude\", \"deepseek\", \"free\"). Empty = no filter.\n openrouter_limit: Max OpenRouter models to return (default 20, cheapest\n input price first). ``total_matching`` reports how many matched.\n\n Returns a dict with:\n - acp_clis: subscription/CLI bridges (claude-code, gemini, copilot, \u2026),\n each with ``agent_key``, ``available`` \u2014 the CLI is installed and\n launchable \u2014 and ``logged_in``: true a credential for its interactive\n login (Claude/Google/GitHub/OpenAI) was found on this machine, false none\n was found where that CLI keeps one, null no marker exists to read\n (Copilot) or the bridge isn't installed. ``available`` alone does NOT\n mean signed in, and ``logged_in`` is a heuristic (a credential file can\n still hold an expired token), so treat a bridge with ``logged_in`` false\n or null as a candidate to CONFIRM with the user, and prefer a credential\n you can verify (a set ``cloud_keys`` provider, or a loaded local model)\n when recommending unprompted.\n - cloud_keys: {provider: bool} \u2014 whether OPENROUTER/OPENAI/ANTHROPIC/GROQ/\n GOOGLE keys are set in the environment.\n - custom_endpoints: the user's own saved OpenAI-compatible endpoints, each\n with ``name``, ``base_url``, ``reachable`` and the chat ``models`` it\n serves (``agent_key`` set to ``custom@:``). The\n strongest signal here \u2014 the user added these deliberately and they are\n re-validated on every call \u2014 so prefer a reachable one when it fits.\n - local: {ollama, lmstudio} each with ``base_url``, ``reachable``, and the\n ``models`` currently loaded on that server (empty if not running).\n - openrouter: tool-capable catalog (``models`` with ``agent_key`` set to\n ``openrouter:``, plus in/out $/Mtok and context). The catalog is\n public so this is ALWAYS present \u2014 no key needed to recommend. Check\n ``key_present``: false means these are options that need OPENROUTER_API_KEY\n added (Settings) before they can run; recommend a runnable option first.\n\n Guidance for choosing: correctness-critical or high-capital agents \u2192 a strong\n model (a capable OpenRouter model when its key is set, or a subscription ACP\n bridge the user confirms is signed in); simple\n report/watch loops or privacy/offline needs \u2192 a loaded local model or a cheap\n OpenRouter one. An agent's ``tools`` allowlist binds on every model, ACP\n bridges included: a tool it leaves out is never mounted.\n ", + "inputSchema": { + "properties": { + "openrouter_limit": { + "default": 20, + "title": "Openrouter Limit", + "type": "integer" + }, + "openrouter_query": { + "default": "", + "title": "Openrouter Query", + "type": "string" + } + }, + "title": "get_available_modelsArguments", + "type": "object" + }, + "name": "get_available_models", + "outputSchema": null + }, + "manage_agent_controllers": { + "description": "The Hummingbot controllers you OWN: source + sample configs in your folder,\n pushed to the active server on demand. Read the `controller_sources` skill\n before your first sync.\n\n Layout: controllers//.py, optional controllers//CONTROLLER.md\n (description, `type:` when it cannot be inferred), and\n controllers//sample_configs/