diff --git a/REFERENCE.md b/REFERENCE.md index 09216ef..801ef66 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -139,3 +139,27 @@ writes: derives that title from the transcript via the LLM Gateway once the stream ends, renaming the files to match (the timestamp stem is kept if the title is empty). The two are mutually exclusive. + +## Live agent tools (MCP) + +`assembly live` answers each spoken turn with a tool-using agent, so it can reach +external tools mid-conversation. Out of the box it loads its built-in URL fetch, +the AssemblyAI docs, and a curated, no-auth MCP toolset: `time` and `fetch` +(`uvx`), `memory` and `filesystem` (`npx`, the latter rooted at the working +directory), and an NWS-backed `weather` server. + +Firecrawl web search also loads when a `FIRECRAWL_API_KEY` is set; without it the +session prints a one-line notice and runs without web search (every other default +tool needs no key). + +`--mcp-config FILE` adds your own servers on top of the defaults, from a standard +`mcpServers` JSON file — the same +`{"mcpServers": {"name": {"command": "…", "args": […]}}}` shape Claude Desktop and +Claude Code use. Repeat the flag to merge several files; a later file (or a config +entry sharing a default's name) wins on a clash. Remote servers use `{"url": "…"}` +instead of `command`/`args`. + +Each server is launched independently and best-effort: one that won't start (a +missing `npx`/`uvx`, an offline host) drops only its own tools, so a single broken +tool never sinks the session. MCP tools are a live-run feature and are not +reflected in `--show-code` output. diff --git a/aai_cli/agent_cascade/brain.py b/aai_cli/agent_cascade/brain.py index 5f8b2b3..7f9b8d2 100644 --- a/aai_cli/agent_cascade/brain.py +++ b/aai_cli/agent_cascade/brain.py @@ -23,7 +23,7 @@ from aai_cli.agent_cascade.config import CascadeConfig from aai_cli.code_agent.agent import CompiledAgent from aai_cli.code_agent.fetch_tool import FETCH_TOOL_NAME -from aai_cli.code_agent.web_search import WEB_SEARCH_TOOL_NAME +from aai_cli.code_agent.firecrawl_search import WEB_SEARCH_TOOL_NAME from aai_cli.core import debuglog if TYPE_CHECKING: @@ -73,8 +73,8 @@ def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]: """The spoken-capability phrases backed by an actually-present tool. Derived from the resolved tool names so the prompt never advertises a capability the - agent can't perform: web search is present only with a ``TAVILY_API_KEY``, and the docs - tools are best-effort (absent when the docs host is unreachable). + agent can't perform: web search is present only with a ``FIRECRAWL_API_KEY``, and the + docs tools are best-effort (absent when the docs host is unreachable). """ names = {tool.name for tool in tools} capabilities: list[str] = [] @@ -87,15 +87,35 @@ def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]: return capabilities -def build_system_prompt(persona: str, *, tools: Sequence[BaseTool]) -> str: +def _extra_capability(extra_tools: Sequence[BaseTool]) -> str | None: + """The spoken-capability phrase for user-configured MCP tools, listing them by name. + + The deepagents graph already shows the model each tool's schema, so this only has to + name the tools so the guidance doesn't claim "no external tools" when MCP tools are + bound — and so the model knows to reach for them. + """ + names = sorted(tool.name for tool in extra_tools) + if not names: + return None + return f"use your connected tools ({', '.join(names)})" + + +def build_system_prompt( + persona: str, *, tools: Sequence[BaseTool], extra_tools: Sequence[BaseTool] = () +) -> str: """The live agent's system prompt: the user's persona plus tool guidance. - The guidance is tailored to ``tools`` so the model is only told about capabilities it - actually has — advertising a missing tool (web search without a ``TAVILY_API_KEY``) made - the agent announce an action it then couldn't take, leaving the turn hanging with no - answer. With no tools at all the model is told to answer from its own knowledge. + The guidance is tailored to the bound tools so the model is only told about + capabilities it actually has — advertising a missing tool (web search without a + ``FIRECRAWL_API_KEY``) made the agent announce an action it then couldn't take, leaving + the turn hanging with no answer. ``tools`` are the built-in legs (web search, URL + fetch, AssemblyAI docs); ``extra_tools`` are user-configured MCP tools, advertised + generically by name. With no tools at all the model answers from its own knowledge. """ capabilities = _tool_capabilities(tools) + extra = _extra_capability(extra_tools) + if extra is not None: + capabilities.append(extra) if not capabilities: return f"{persona}\n\n{_NO_TOOLS_GUIDANCE}" guidance = ( @@ -113,12 +133,12 @@ def build_live_tools() -> list[BaseTool]: All three are reused from the coding agent's tool modules. Unlike there they are *not* approval-gated — a spoken turn can't wait for a keyboard confirmation, so the live agent only gets read-only tools and runs them automatically. Web search is - present only when ``TAVILY_API_KEY`` is set; the docs MCP is best-effort (an empty + present only when ``FIRECRAWL_API_KEY`` is set; the docs MCP is best-effort (an empty list when the host is unreachable), so neither blocks a session. """ from aai_cli.code_agent.docs_mcp import load_docs_tools from aai_cli.code_agent.fetch_tool import build_fetch_tool - from aai_cli.code_agent.web_search import build_web_search_tool + from aai_cli.code_agent.firecrawl_search import build_web_search_tool tools: list[BaseTool] = [build_fetch_tool()] search = build_web_search_tool() @@ -129,27 +149,36 @@ def build_live_tools() -> list[BaseTool]: def build_graph( - api_key: str, config: CascadeConfig, *, tools: Sequence[BaseTool] | None = None + api_key: str, + config: CascadeConfig, + *, + tools: Sequence[BaseTool] | None = None, + mcp_tools: Sequence[BaseTool] | None = None, ) -> CompiledAgent: """Compile the deepagents graph for one live session over the gateway model. Reuses the coding agent's gateway-bound ``ChatOpenAI`` (so the live agent can only ever reach AssemblyAI), threading the cascade's ``--max-tokens``/``--llm-config`` - through it. ``tools`` defaults to :func:`build_live_tools`; tests pass an explicit - (possibly empty) list to skip the network-touching docs probe. + through it. ``tools`` defaults to :func:`build_live_tools`; ``mcp_tools`` defaults to + the tools of the servers in ``config.mcp_servers``. The two are kept apart so the + system prompt advertises the built-in legs and the MCP tools differently, but the + model is bound to both. Tests pass explicit (possibly empty) lists to skip the + network-touching docs/MCP probes. """ from deepagents import create_deep_agent + from aai_cli.agent_cascade.mcp_tools import load_mcp_tools from aai_cli.code_agent.model import build_model model = build_model( api_key, model=config.model, max_tokens=config.max_tokens, extra=config.llm_extra ) - resolved = build_live_tools() if tools is None else list(tools) + builtin = build_live_tools() if tools is None else list(tools) + extra = load_mcp_tools(config.mcp_servers) if mcp_tools is None else list(mcp_tools) return create_deep_agent( model=model, - tools=resolved, - system_prompt=build_system_prompt(config.system_prompt, tools=resolved), + tools=builtin + extra, + system_prompt=build_system_prompt(config.system_prompt, tools=builtin, extra_tools=extra), ) diff --git a/aai_cli/agent_cascade/config.py b/aai_cli/agent_cascade/config.py index ed5481e..7589b95 100644 --- a/aai_cli/agent_cascade/config.py +++ b/aai_cli/agent_cascade/config.py @@ -43,6 +43,12 @@ class CascadeConfig: llm_extra: Mapping[str, object] = field(default_factory=dict[str, object]) # Extra streaming-TTS query params (the --tts-config escape hatch). tts_extra: Mapping[str, str] = field(default_factory=dict[str, str]) + # MCP servers (name -> launch spec) whose tools the deepagents brain can call. Empty + # here by default; the live command populates it with the curated default set plus any + # --mcp-config files. + mcp_servers: Mapping[str, Mapping[str, object]] = field( + default_factory=dict[str, Mapping[str, object]] + ) # Whether STT formats finalized turns. The reply trigger waits for the formatted # turn when on; with it off, an unformatted end-of-turn is the cue instead. format_turns: bool = True diff --git a/aai_cli/agent_cascade/mcp_tools.py b/aai_cli/agent_cascade/mcp_tools.py new file mode 100644 index 0000000..1086f94 --- /dev/null +++ b/aai_cli/agent_cascade/mcp_tools.py @@ -0,0 +1,146 @@ +"""Load tools from user-configured MCP servers for the `assembly live` agent. + +The live voice agent's brain is a deepagents graph, so any Model Context Protocol +server's tools can be threaded into it through ``langchain-mcp-adapters`` — the same +adapter `docs_mcp.py` uses for the hosted AssemblyAI docs. This lets a spoken +conversation reach real tools (clock, weather, memory, a notes folder, …), bringing +`assembly live` toward Gemini-Live / ChatGPT-voice parity. + +Two entry points feed the brain: + +- :func:`default_servers` returns a curated, zero/low-auth set (time, fetch, memory, + filesystem, weather) that every live session loads out of the box. +- :func:`parse_mcp_config` reads one or more standard ``mcpServers`` JSON files — the + exact shape Claude Desktop / Claude Code use — so an existing config drops in + unchanged and can extend or override the defaults. + +Launching a server is **best-effort per server**: a missing ``npx``/``uvx`` or an +offline run skips that one server (the others still load) rather than aborting the +session — a single broken tool can't sink a live demo. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from aai_cli.core import jsonshape +from aai_cli.core.errors import UsageError + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + from langchain_mcp_adapters.sessions import Connection + +# One MCP server's launch spec, as it appears under "mcpServers" in a standard config: +# stdio servers carry {command, args, env}; remote servers carry {url}. +ServerSpec = Mapping[str, object] +# A loader maps (server name, adapter connection dict) -> the server's tools. Injected in +# tests so the per-server orchestration runs without subprocesses or sockets. +Loader = Callable[[str, "Connection"], "list[BaseTool]"] + + +def default_servers(filesystem_root: Path) -> dict[str, ServerSpec]: + """The curated server set every live session loads: zero/low-auth, fast, speakable. + + Every entry is a published reference server runnable with no API key: + ``time``/``fetch`` over ``uvx`` (PyPI), ``memory``/``filesystem`` over ``npx`` (npm), + and an NWS-backed ``weather`` server. ``filesystem`` is rooted at ``filesystem_root`` + (the working directory) so "summarize my notes file" stays scoped to one folder. + """ + return { + "time": {"command": "uvx", "args": ["mcp-server-time"]}, + "fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}, + "memory": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-memory"]}, + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", str(filesystem_root)], + }, + "weather": {"command": "npx", "args": ["-y", "@h1deya/mcp-server-weather"]}, + } + + +def parse_mcp_config(paths: Sequence[Path]) -> dict[str, ServerSpec]: + """Merge the ``mcpServers`` maps from one or more standard MCP config JSON files. + + Each file must be ``{"mcpServers": {name: spec, …}}`` (the Claude Desktop / Claude + Code shape). Later files win on a name clash. A malformed file, a missing + ``mcpServers`` key, or a spec with neither ``command`` nor ``url`` is a usage error, + surfaced before any audio device opens. + """ + servers: dict[str, ServerSpec] = {} + for path in paths: + try: + data = jsonshape.as_mapping(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError) as exc: + raise UsageError(f"Could not read MCP config {str(path)!r}: {exc}") from exc + entries = jsonshape.as_mapping(data.get("mcpServers")) if data is not None else None + if entries is None: + raise UsageError( + f"MCP config {str(path)!r} has no 'mcpServers' object.", + suggestion='Expected {"mcpServers": {"name": {"command": "…"}}}.', + ) + for name, spec in entries.items(): + servers[name] = _validate_spec(name, spec) + return servers + + +def _validate_spec(name: str, spec: object) -> dict[str, object]: + """Return the spec as a mapping, or reject one naming neither a ``command`` nor ``url``.""" + mapping = jsonshape.as_mapping(spec) + if mapping is None or ("command" not in mapping and "url" not in mapping): + raise UsageError( + f"MCP server {name!r} needs a 'command' or 'url'.", + suggestion='e.g. {"command": "uvx", "args": ["mcp-server-time"]}.', + ) + return mapping + + +def _to_connection(spec: ServerSpec) -> Connection: + """Translate a standard ``mcpServers`` spec into a langchain-mcp-adapters connection. + + A ``url`` spec becomes a ``streamable_http`` transport; otherwise it's a ``stdio`` + transport launched from ``command``/``args`` (passing ``env`` through when present). + """ + if "url" in spec: + return {"transport": "streamable_http", "url": str(spec["url"])} + args = [str(arg) for arg in jsonshape.object_list(spec.get("args"))] + env_map = jsonshape.as_mapping(spec.get("env")) + env = {str(k): str(v) for k, v in env_map.items()} if env_map is not None else None + return {"transport": "stdio", "command": str(spec["command"]), "args": args, "env": env} + + +def _load_server(name: str, conn: Connection) -> list[BaseTool]: + """Connect to one MCP server and return its tools (drives the async adapter).""" + from langchain_mcp_adapters.client import MultiServerMCPClient + + async def _fetch() -> list[BaseTool]: + client = MultiServerMCPClient({name: conn}) + return await client.get_tools() + + return asyncio.run(_fetch()) + + +def _safe_load(loader: Loader, name: str, spec: ServerSpec) -> list[BaseTool]: + """One server's tools, or ``[]`` if it won't start — so a failure is never fatal.""" + try: + return loader(name, _to_connection(spec)) + except Exception: + return [] + + +def load_mcp_tools( + servers: Mapping[str, ServerSpec], *, loader: Loader = _load_server +) -> list[BaseTool]: + """Load the tools from every configured MCP server, skipping any that fail to start. + + Each server is launched independently so one unreachable server (npx not installed, + an offline host) drops only its own tools — the rest still load. ``loader`` is the + only network/subprocess seam, injected in tests. + """ + tools: list[BaseTool] = [] + for name, spec in servers.items(): + tools.extend(_safe_load(loader, name, spec)) + return tools diff --git a/aai_cli/code_agent/firecrawl_search.py b/aai_cli/code_agent/firecrawl_search.py new file mode 100644 index 0000000..7a97134 --- /dev/null +++ b/aai_cli/code_agent/firecrawl_search.py @@ -0,0 +1,37 @@ +"""Optional Firecrawl web search for the live voice agent. + +Firecrawl grounds the agent with live web search, enabled when a ``FIRECRAWL_API_KEY`` +is present in the environment. Search is read-only, so it is *not* gated behind the +approval flow. With no key set we simply omit the tool (the agent still has its URL +fetch and the AssemblyAI docs MCP), rather than erroring. + +This mirrors ``web_search.py`` (Tavily) but reuses Firecrawl's official LangChain +integration; the live agent prefers it as its default search tool. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aai_cli.core import env + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +# Firecrawl's SDK reads this from the environment; we gate on its presence so we never +# hand the agent a search tool that will fail on first use for lack of a key. +FIRECRAWL_API_KEY_ENV = "FIRECRAWL_API_KEY" + +# The name ``FirecrawlSearch`` registers itself under. The prompt builder detects +# web-search availability by this name, so a test pins it against the tool. +WEB_SEARCH_TOOL_NAME = "firecrawl_search" + + +def build_web_search_tool() -> BaseTool | None: + """The Firecrawl web-search tool, or ``None`` when no ``FIRECRAWL_API_KEY`` is set.""" + if not env.get(FIRECRAWL_API_KEY_ENV): + return None + + from langchain_firecrawl import FirecrawlSearch + + return FirecrawlSearch() diff --git a/aai_cli/commands/agent_cascade/__init__.py b/aai_cli/commands/agent_cascade/__init__.py index b17e85e..97fcb8f 100644 --- a/aai_cli/commands/agent_cascade/__init__.py +++ b/aai_cli/commands/agent_cascade/__init__.py @@ -25,6 +25,7 @@ _PANEL_STT = "Speech-to-text" _PANEL_LLM = "Language model" _PANEL_TTS = "Text-to-speech" +_PANEL_TOOLS = "Tools" app = typer.Typer() @@ -56,6 +57,10 @@ def _emit_voice_list(_state: AppState, json_mode: bool) -> None: "Give the agent a persona", 'assembly --sandbox live --system-prompt "You are a terse pirate."', ), + ( + "Add your own MCP servers on top of the defaults", + "assembly --sandbox live --mcp-config ~/.config/mcp/servers.json", + ), ("See available voices", "assembly --sandbox live --list-voices"), ( "Print equivalent Python instead of running", @@ -154,6 +159,14 @@ def live( dir_okay=False, ), greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting"), + mcp_config: list[Path] | None = typer.Option( + None, + "--mcp-config", + help='Extra MCP servers config JSON ({"mcpServers": {…}}) on top of the defaults (repeatable)', + exists=True, + dir_okay=False, + rich_help_panel=_PANEL_TOOLS, + ), device: int | None = typer.Option(None, "--device", help="Microphone device index"), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit"), json_out: bool = options.json_option("Emit newline-delimited JSON events"), @@ -187,8 +200,12 @@ def live( This only runs a conversation in the terminal — it writes no code. To build an agent-cascade app, run 'assembly init agent-cascade' instead. - Web search needs a TAVILY_API_KEY in the environment; without it the agent - keeps its URL-fetch and docs tools. + By default the agent loads a curated, no-auth MCP toolset (time, fetch, + memory, filesystem, weather) alongside its built-in URL fetch and AssemblyAI + docs. Firecrawl web search also loads when a FIRECRAWL_API_KEY is set (you'll + get a one-line notice when it isn't). Add your own servers with --mcp-config, + pointing at any standard mcpServers JSON file. A server that won't start is + skipped, so one broken tool never sinks the session. """ if list_voices: @@ -214,6 +231,7 @@ def live( llm_config=tuple(llm_config or ()), language=language, tts_config=tuple(tts_config or ()), + mcp_config=tuple(mcp_config or ()), show_code=show_code, ) run_with_options(ctx, agent_cascade_exec.run_agent_cascade, opts, json=json_out) diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index af466c5..408d147 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -8,7 +8,7 @@ from __future__ import annotations import contextlib -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -18,12 +18,13 @@ from aai_cli import code_gen from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from aai_cli.agent.render import AgentRenderer -from aai_cli.agent_cascade import engine, voices +from aai_cli.agent_cascade import engine, mcp_tools, voices from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt from aai_cli.app.agent_shared import validate_voice from aai_cli.app.context import AppState -from aai_cli.core import choices, client, config_builder, errors, llm, signals +from aai_cli.code_agent import firecrawl_search +from aai_cli.core import choices, client, config_builder, env, errors, llm, signals from aai_cli.core.errors import UsageError from aai_cli.streaming import turn_presets from aai_cli.streaming.sources import FileSource @@ -73,6 +74,8 @@ class AgentCascadeOptions: # Text-to-speech: language named, any other query param via --tts-config. language: str | None tts_config: tuple[str, ...] + # Tools: extra standard mcpServers JSON config files, on top of the default set. + mcp_config: tuple[Path, ...] # Print the equivalent Python instead of running a conversation. show_code: bool @@ -117,6 +120,31 @@ def _parse_tts_config(pairs: tuple[str, ...]) -> dict[str, str]: return extra +def _warn_without_web_search(*, json_mode: bool) -> None: + """Warn that web search is off unless a ``FIRECRAWL_API_KEY`` is set to enable it. + + The other default tools (URL fetch, AssemblyAI docs, and the MCP servers) need no + key; only Firecrawl web search does, so its absence is the one worth flagging up front. + """ + if not env.get(firecrawl_search.FIRECRAWL_API_KEY_ENV): + output.emit_warning( + "Web search is off — set FIRECRAWL_API_KEY to enable the agent's web search tool.", + json_mode=json_mode, + ) + + +def _resolve_mcp_servers(mcp_config: tuple[Path, ...]) -> dict[str, Mapping[str, object]]: + """The MCP servers for this run: the curated default set overlaid with any --mcp-config + files, so an explicit config can extend the defaults or override one by name. + + The default filesystem server is rooted at the working directory, scoping its file + access to one folder. + """ + servers: dict[str, Mapping[str, object]] = dict(mcp_tools.default_servers(Path.cwd())) + servers.update(mcp_tools.parse_mcp_config(mcp_config)) + return servers + + def _open_audio( renderer: AgentRenderer, *, @@ -178,6 +206,8 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: _print_show_code(opts, system_prompt_text) return + _warn_without_web_search(json_mode=json_mode) + from_file = bool(opts.source) or opts.sample if from_file and opts.device is not None: raise UsageError("--device applies only to microphone input.") @@ -189,6 +219,8 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: # fails fast instead of after the mic is live. llm_extra = llm.parse_gateway_overrides(opts.llm_config) tts_extra = _parse_tts_config(opts.tts_config) + # Resolve MCP servers before opening the device, so a malformed config fails fast. + mcp_servers = _resolve_mcp_servers(opts.mcp_config) api_key = state.resolve_api_key() config = CascadeConfig( @@ -202,6 +234,7 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: format_turns=opts.format_turns, llm_extra=llm_extra, tts_extra=tts_extra, + mcp_servers=mcp_servers, ) renderer = AgentRenderer(json_mode=json_mode, text_mode=text_mode, mic_input=not from_file) audio, player, sample_rate = _open_audio( diff --git a/pyproject.toml b/pyproject.toml index f330876..04be6b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ dependencies = [ "langgraph-checkpoint-sqlite>=3.1.0", "pyperclip>=1.11.0", "langchain-text-splitters>=1.0.0", + "langchain-firecrawl>=0.1.0", ] [project.urls] @@ -189,6 +190,10 @@ filterwarnings = [ # PydanticDeprecatedSince20 errors under the `error` rule above. Ignore that specific # third-party deprecation so it doesn't fail unrelated tests. "ignore::pydantic.warnings.PydanticDeprecatedSince20", + # firecrawl-py's pydantic models define fields named "json" (shadowing BaseModel.json), + # which pydantic flags with a UserWarning at import. It's third-party and harmless to us + # (we never touch those models), so ignore that one message under the `error` rule. + 'ignore:Field name .* shadows an attribute in parent:UserWarning', ] markers = [ "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY; skip otherwise)", @@ -442,6 +447,10 @@ max-statements = 40 # BLE001: connecting to the docs MCP server is best-effort — any failure (blocked host, # offline, transport error) degrades to "no docs tools", so a broad except is the shape. "aai_cli/code_agent/docs_mcp.py" = ["BLE001"] +# BLE001: launching each live-agent MCP server is best-effort — any failure (npx/uvx +# missing, offline host, transport error) skips just that server so one broken tool +# can't sink a live session, so a broad per-server except is the right shape. +"aai_cli/agent_cascade/mcp_tools.py" = ["BLE001"] # BLE001: a turn must never crash the TUI/REPL — any agent/gateway failure is caught and # surfaced as an ErrorText event so the user can simply retry. "aai_cli/code_agent/session.py" = ["BLE001"] diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 23ab4f3..e25e9a8 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -617,8 +617,12 @@ This only runs a conversation in the terminal — it writes no code. To build an agent-cascade app, run 'assembly init agent-cascade' instead. - Web search needs a TAVILY_API_KEY in the environment; without it the agent - keeps its URL-fetch and docs tools. + By default the agent loads a curated, no-auth MCP toolset (time, fetch, + memory, filesystem, weather) alongside its built-in URL fetch and AssemblyAI + docs. Firecrawl web search also loads when a FIRECRAWL_API_KEY is set (you'll + get a one-line notice when it isn't). Add your own servers with --mcp-config, + pointing at any standard mcpServers JSON file. A server that won't start is + skipped, so one broken tool never sinks the session. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ source [SOURCE] Audio file path or URL to speak to the agent. Omit │ @@ -691,6 +695,10 @@ │ (repeatable) │ │ --stt-config-file FILE JSON file of │ │ streaming fields │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Tools ──────────────────────────────────────────────────────────────────────╮ + │ --mcp-config FILE Extra MCP servers config JSON ({"mcpServers": │ + │ {…}}) on top of the defaults (repeatable) │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -700,6 +708,8 @@ $ assembly --sandbox live --voice michael --greeting "Hi there" Give the agent a persona $ assembly --sandbox live --system-prompt "You are a terse pirate." + Add your own MCP servers on top of the defaults + $ assembly --sandbox live --mcp-config ~/.config/mcp/servers.json See available voices $ assembly --sandbox live --list-voices Print equivalent Python instead of running diff --git a/tests/test_agent_cascade_brain.py b/tests/test_agent_cascade_brain.py index 3a446a7..d8bee24 100644 --- a/tests/test_agent_cascade_brain.py +++ b/tests/test_agent_cascade_brain.py @@ -59,7 +59,11 @@ def __init__(self, name: str): def test_system_prompt_appends_tool_guidance_for_present_tools(): prompt = brain.build_system_prompt( "You are a pirate.", - tools=[_NamedTool("tavily_search"), _NamedTool("fetch_url"), _NamedTool("docs_search")], + tools=[ + _NamedTool(brain.WEB_SEARCH_TOOL_NAME), + _NamedTool("fetch_url"), + _NamedTool("docs_search"), + ], ) # The persona is preserved, and the guidance advertises each capability that a present # tool backs (the plain cascade persona never mentions tools). @@ -90,6 +94,24 @@ def test_system_prompt_tells_model_not_to_promise_tools_when_none(): assert "Never say" in prompt +def test_extra_capability_lists_sorted_tool_names(): + # MCP tools are advertised generically, by name, alphabetically. + phrase = brain._extra_capability([_NamedTool("zeta"), _NamedTool("alpha")]) + assert phrase == "use your connected tools (alpha, zeta)" + + +def test_extra_capability_is_none_without_extra_tools(): + assert brain._extra_capability([]) is None + + +def test_system_prompt_advertises_mcp_extra_tools(): + # With MCP tools bound (but no built-in legs), the model must be told it HAS tools — + # not handed the "no external tools" guidance — and the tools are named. + prompt = brain.build_system_prompt("persona", tools=[], extra_tools=[_NamedTool("get_time")]) + assert "your own knowledge" not in prompt + assert "use your connected tools (get_time)" in prompt + + def test_join_clause_grammar(): # One/two/three capability phrases each render with natural conjunctions. assert brain._join_clause(["a"]) == "a" @@ -99,11 +121,20 @@ def test_join_clause_grammar(): def test_web_search_tool_name_matches_built_tool(monkeypatch): # The prompt builder detects search by WEB_SEARCH_TOOL_NAME, so pin it against the real - # tool's registered name — if langchain_tavily renames it, detection would silently break. - from aai_cli.code_agent import web_search + # Firecrawl tool's registered name — if it renames, detection would silently break. + from aai_cli.code_agent import firecrawl_search + + monkeypatch.setenv(firecrawl_search.FIRECRAWL_API_KEY_ENV, "fc-x") + tool = firecrawl_search.build_web_search_tool() + assert tool is not None + assert tool.name == firecrawl_search.WEB_SEARCH_TOOL_NAME == brain.WEB_SEARCH_TOOL_NAME + - monkeypatch.setenv(web_search.TAVILY_API_KEY_ENV, "tvly-x") - assert web_search.build_web_search_tool().name == web_search.WEB_SEARCH_TOOL_NAME +def test_web_search_absent_without_firecrawl_key(monkeypatch): + from aai_cli.code_agent import firecrawl_search + + monkeypatch.delenv(firecrawl_search.FIRECRAWL_API_KEY_ENV, raising=False) + assert firecrawl_search.build_web_search_tool() is None # --- build_completer (driving the real graph with a fake model) -------------- @@ -308,7 +339,7 @@ def test_reply_text_is_empty_without_an_assistant_message(): def test_build_live_tools_includes_search_when_keyed(monkeypatch): search = object() monkeypatch.setattr("aai_cli.code_agent.fetch_tool.build_fetch_tool", lambda: "fetch") - monkeypatch.setattr("aai_cli.code_agent.web_search.build_web_search_tool", lambda: search) + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: search) monkeypatch.setattr("aai_cli.code_agent.docs_mcp.load_docs_tools", lambda: ["docs"]) tools = brain.build_live_tools() # Fetch + the keyed search + the docs tools, in that order. @@ -317,7 +348,7 @@ def test_build_live_tools_includes_search_when_keyed(monkeypatch): def test_build_live_tools_omits_search_when_unkeyed(monkeypatch): monkeypatch.setattr("aai_cli.code_agent.fetch_tool.build_fetch_tool", lambda: "fetch") - monkeypatch.setattr("aai_cli.code_agent.web_search.build_web_search_tool", lambda: None) + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: None) monkeypatch.setattr("aai_cli.code_agent.docs_mcp.load_docs_tools", list) tools = brain.build_live_tools() # No TAVILY_API_KEY -> no search tool, just the fetch tool. @@ -346,6 +377,52 @@ def fake_build_model(api_key, *, model, max_tokens, extra): assert completer([{"role": "user", "content": "hi"}]) == "hi from the agent" +# --- build_graph MCP tool wiring --------------------------------------------- + + +def test_build_graph_binds_builtin_plus_mcp_tools_and_advertises_both(monkeypatch): + import deepagents + + captured = {} + + def fake_create(*, model, tools, system_prompt): + del model + captured["tools"] = tools + captured["system_prompt"] = system_prompt + return "graph" + + monkeypatch.setattr(deepagents, "create_deep_agent", fake_create) + monkeypatch.setattr(model_mod, "build_model", lambda *a, **k: object()) + builtin = [_NamedTool("fetch_url")] + extra = [_NamedTool("get_time")] + graph = brain.build_graph("k", CascadeConfig(), tools=builtin, mcp_tools=extra) + # The model is bound to both tool sets, in built-in-then-MCP order. + assert graph == "graph" + assert captured["tools"] == builtin + extra + # The prompt advertises the built-in fetch leg AND the MCP tool by name. + assert "fetch a specific URL" in captured["system_prompt"] + assert "use your connected tools (get_time)" in captured["system_prompt"] + + +def test_build_graph_loads_mcp_tools_from_config_when_not_injected(monkeypatch): + import deepagents + + seen = {} + + def fake_load(servers): + seen["servers"] = servers + return [_NamedTool("weather")] + + monkeypatch.setattr("aai_cli.agent_cascade.mcp_tools.load_mcp_tools", fake_load) + monkeypatch.setattr(model_mod, "build_model", lambda *a, **k: object()) + monkeypatch.setattr(deepagents, "create_deep_agent", lambda **kwargs: kwargs["tools"]) + cfg = CascadeConfig(mcp_servers={"weather": {"command": "npx"}}) + tools = brain.build_graph("k", cfg, tools=[]) + # The config's servers are loaded (default path) and their tools bound. + assert seen["servers"] == {"weather": {"command": "npx"}} + assert [t.name for t in tools] == ["weather"] + + # --- build_model new knobs --------------------------------------------------- diff --git a/tests/test_agent_cascade_command.py b/tests/test_agent_cascade_command.py index 93d25a4..a7c2374 100644 --- a/tests/test_agent_cascade_command.py +++ b/tests/test_agent_cascade_command.py @@ -48,6 +48,7 @@ llm_config=(), language=None, tts_config=(), + mcp_config=(), show_code=False, ) @@ -144,6 +145,19 @@ def test_stt_config_file_must_exist(): assert "does not exist" in result.output +def test_mcp_config_file_must_exist(): + # --mcp-config is existence-checked at parse time (exists=True), so a missing path + # fails as a Typer usage error before the body runs. Wide terminal so the "does not + # exist" message isn't wrapped by the 80-col error box. + result = runner.invoke( + app, + ["live", "--mcp-config", "/no/such/servers.json"], + env={"COLUMNS": "300"}, + ) + assert result.exit_code == 2 + assert "does not exist" in result.output + + # --- system prompt resolution ------------------------------------------------ @@ -200,6 +214,26 @@ def test_open_audio_mic_warns_and_uses_duplex_rate(monkeypatch): assert any("headphones" in note for note in notices) +# --- MCP servers (resolution unit-tested in test_agent_cascade_mcp.py) ------- +def test_default_mcp_servers_flow_into_cascade_config(monkeypatch): + monkeypatch.setattr(_exec.tts_session, "require_available", lambda _c: None) + monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") + monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) + monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") + captured = {} + + # Capture config at the deps seam so the graph (and its npx/uvx servers) never builds. + def fake_real(api_key, config, *, audio, stt_params): + captured["config"] = config + return "deps" + + monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) + monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None) + # With no flags, the default servers (e.g. weather) ride into the config the brain reads. + run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False) + assert "weather" in captured["config"].mcp_servers + + # --- run_agent_cascade wiring ---------------------------------------------- @@ -209,6 +243,9 @@ def test_run_wires_deps_and_invokes_cascade(monkeypatch): fake_source = types.SimpleNamespace(sample_rate=16000) monkeypatch.setattr(_exec, "FileSource", lambda src: fake_source) monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") + # CascadeDeps.real builds the brain graph (which would launch the default MCP servers); + # stub the completer so deps still wire up without spawning any npx/uvx subprocess. + monkeypatch.setattr(_exec.engine.brain, "build_completer", lambda api_key, config: lambda m: "") captured = {} def fake_run_cascade(*, renderer, player, config, deps): @@ -247,6 +284,8 @@ def _wire_run(monkeypatch, run_cascade): monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") + # Stub the brain completer so CascadeDeps.real never launches the default MCP servers. + monkeypatch.setattr(_exec.engine.brain, "build_completer", lambda api_key, config: lambda m: "") monkeypatch.setattr(_exec.engine, "run_cascade", run_cascade) rendered = {} monkeypatch.setattr( diff --git a/tests/test_agent_cascade_mcp.py b/tests/test_agent_cascade_mcp.py new file mode 100644 index 0000000..f7d1fb0 --- /dev/null +++ b/tests/test_agent_cascade_mcp.py @@ -0,0 +1,215 @@ +"""Tests for the MCP-server toolset behind `assembly live --mcp-config/--demo-tools`. + +The only network/subprocess seam is the per-server ``loader``, injected here so the +config parsing, connection translation, and best-effort per-server loading all run with +no sockets or `npx`/`uvx` subprocesses. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from aai_cli.agent_cascade import mcp_tools +from aai_cli.commands.agent_cascade import _exec +from aai_cli.core.errors import UsageError + +# --- default_servers --------------------------------------------------------- + + +def test_default_servers_curated_set_and_filesystem_root(): + root = Path("/notes/dir") + servers = mcp_tools.default_servers(root) + # The five curated, no-auth servers, each with a real launch command. + assert set(servers) == {"time", "fetch", "memory", "filesystem", "weather"} + assert servers["time"] == {"command": "uvx", "args": ["mcp-server-time"]} + assert servers["memory"]["args"] == ["-y", "@modelcontextprotocol/server-memory"] + # The filesystem server is scoped to the passed-in root directory. Compare against + # str(root), not a hardcoded "/notes/dir", so it holds on Windows (backslash paths). + assert servers["filesystem"]["args"] == [ + "-y", + "@modelcontextprotocol/server-filesystem", + str(root), + ] + + +# --- parse_mcp_config -------------------------------------------------------- + + +def _write(path: Path, payload: object) -> Path: + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_parse_mcp_config_reads_and_merges_later_file_wins(tmp_path): + a = _write(tmp_path / "a.json", {"mcpServers": {"time": {"command": "uvx"}, "x": {"url": "u"}}}) + b = _write(tmp_path / "b.json", {"mcpServers": {"time": {"command": "npx"}}}) + servers = mcp_tools.parse_mcp_config([a, b]) + # Both files' servers are present; the later file overrides a clashing name. + assert set(servers) == {"time", "x"} + assert servers["time"] == {"command": "npx"} + + +def test_parse_mcp_config_empty_paths_is_empty(): + assert mcp_tools.parse_mcp_config([]) == {} + + +def test_parse_mcp_config_malformed_json_is_usage_error(tmp_path): + bad = tmp_path / "bad.json" + bad.write_text("{not json", encoding="utf-8") + with pytest.raises(UsageError, match="Could not read MCP config"): + mcp_tools.parse_mcp_config([bad]) + + +def test_parse_mcp_config_missing_mcpservers_key_is_usage_error(tmp_path): + path = _write(tmp_path / "c.json", {"servers": {}}) + with pytest.raises(UsageError, match="no 'mcpServers'"): + mcp_tools.parse_mcp_config([path]) + + +def test_parse_mcp_config_spec_without_command_or_url_is_usage_error(tmp_path): + path = _write(tmp_path / "d.json", {"mcpServers": {"bad": {"args": ["x"]}}}) + with pytest.raises(UsageError, match="needs a 'command' or 'url'"): + mcp_tools.parse_mcp_config([path]) + + +# --- _validate_spec ---------------------------------------------------------- + + +def test_validate_spec_accepts_command_or_url(): + # Both shapes are valid and the spec is returned narrowed to a mapping. + assert mcp_tools._validate_spec("a", {"command": "uvx"}) == {"command": "uvx"} + assert mcp_tools._validate_spec("b", {"url": "https://x"}) == {"url": "https://x"} + + +def test_validate_spec_rejects_non_mapping(): + with pytest.raises(UsageError): + mcp_tools._validate_spec("a", ["not", "a", "mapping"]) + + +# --- _to_connection ---------------------------------------------------------- + + +def test_to_connection_stdio_carries_command_args_and_env(): + conn = mcp_tools._to_connection({"command": "npx", "args": ["-y", "pkg"], "env": {"K": "V"}}) + assert conn == { + "transport": "stdio", + "command": "npx", + "args": ["-y", "pkg"], + "env": {"K": "V"}, + } + + +def test_to_connection_stdio_without_args_or_env_defaults(): + conn = mcp_tools._to_connection({"command": "uvx"}) + assert conn == {"transport": "stdio", "command": "uvx", "args": [], "env": None} + + +def test_to_connection_url_becomes_streamable_http(): + conn = mcp_tools._to_connection({"url": "https://host/mcp"}) + assert conn == {"transport": "streamable_http", "url": "https://host/mcp"} + + +# --- load_mcp_tools / _safe_load --------------------------------------------- + + +def test_load_mcp_tools_combines_tools_from_each_server(): + def loader(name, conn) -> list: + del conn + return [f"{name}-tool"] + + tools = mcp_tools.load_mcp_tools({"a": {"command": "x"}, "b": {"command": "y"}}, loader=loader) + assert tools == ["a-tool", "b-tool"] + + +def test_load_mcp_tools_skips_a_server_that_fails_to_start(): + def loader(name, conn) -> list: + del conn + if name == "broken": + raise RuntimeError("npx not found") + return [f"{name}-tool"] + + tools = mcp_tools.load_mcp_tools( + {"broken": {"command": "x"}, "ok": {"command": "y"}}, loader=loader + ) + # The broken server contributes nothing; the working server's tool still loads. + assert tools == ["ok-tool"] + + +def test_load_mcp_tools_empty_servers_is_empty(): + # No servers -> the loader is never reached and the result is empty. + assert mcp_tools.load_mcp_tools({}) == [] + + +def test_safe_load_returns_empty_on_failure(): + def boom(name, conn) -> list: + raise RuntimeError("down") + + assert mcp_tools._safe_load(boom, "s", {"command": "x"}) == [] + + +# --- _resolve_mcp_servers (the default set + --mcp-config merge) -------------- + + +def test_resolve_mcp_servers_defaults_loaded_with_no_config(): + servers = _exec._resolve_mcp_servers(mcp_config=()) + # Every session loads the curated default set out of the box. + assert {"time", "weather", "memory", "fetch", "filesystem"} <= set(servers) + + +def test_resolve_mcp_servers_config_adds_to_defaults(tmp_path): + path = tmp_path / "servers.json" + path.write_text( + '{"mcpServers": {"custom": {"command": "uvx", "args": ["x"]}}}', encoding="utf-8" + ) + servers = _exec._resolve_mcp_servers(mcp_config=(path,)) + # The config server is added alongside (not instead of) the defaults. + assert servers["custom"] == {"command": "uvx", "args": ["x"]} + assert "weather" in servers + + +def test_resolve_mcp_servers_config_overrides_default_by_name(tmp_path): + path = tmp_path / "servers.json" + path.write_text('{"mcpServers": {"time": {"command": "my-time"}}}', encoding="utf-8") + servers = _exec._resolve_mcp_servers(mcp_config=(path,)) + # An explicit config entry overrides the default server of the same name. + assert servers["time"] == {"command": "my-time"} + + +# --- _warn_without_web_search (the FIRECRAWL_API_KEY notice) ------------------ + + +def test_warn_without_web_search_emits_when_firecrawl_key_missing(monkeypatch, capsys): + monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) + # JSON mode routes the non-fatal warning to a {"warning": …} line on stderr. + _exec._warn_without_web_search(json_mode=True) + assert "FIRECRAWL_API_KEY" in capsys.readouterr().err + + +def test_warn_without_web_search_silent_when_firecrawl_key_set(monkeypatch, capsys): + monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-x") + _exec._warn_without_web_search(json_mode=True) + # With the key present, web search is on, so nothing is emitted. + assert capsys.readouterr().err == "" + + +def test_load_server_drives_the_adapter_with_a_one_server_client(monkeypatch): + captured = {} + + class FakeClient: + def __init__(self, connections): + captured["connections"] = connections + + async def get_tools(self): + return ["tool-a"] + + monkeypatch.setattr( + "langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient, raising=True + ) + conn = mcp_tools._to_connection({"command": "uvx", "args": ["mcp-server-time"]}) + tools = mcp_tools._load_server("time", conn) + # The named server's connection is handed to the adapter and its tools returned. + assert tools == ["tool-a"] + assert captured["connections"] == {"time": conn} diff --git a/uv.lock b/uv.lock index 6673121..64baa45 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,7 @@ dependencies = [ { name = "jiwer" }, { name = "keyring" }, { name = "langchain-core" }, + { name = "langchain-firecrawl" }, { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langchain-tavily" }, @@ -94,6 +95,7 @@ requires-dist = [ { name = "jiwer", specifier = ">=4.0" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "langchain-core", specifier = ">=1.4.7" }, + { name = "langchain-firecrawl", specifier = ">=0.1.0" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.3.2" }, { name = "langchain-tavily", specifier = ">=0.2.18" }, @@ -971,6 +973,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] +[[package]] +name = "firecrawl-py" +version = "4.30.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "httpx" }, + { name = "nest-asyncio" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/e1/ae543db5901e6c8625b2cb42b05fd8d355b87e8cbb548aadbe1efc8c8d44/firecrawl_py-4.30.1.tar.gz", hash = "sha256:06e806302e0d1e5428dc16a9aab9e61caa3ee320cb49839029be79b8258ed736", size = 196270, upload-time = "2026-06-17T17:33:16.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/15/1a0eed2c1b6c12ad0660059ff89c42e93bc6f32a62bbb5fac59ceff48a43/firecrawl_py-4.30.1-py3-none-any.whl", hash = "sha256:cedd6727edf538731375a616f4f646fc84851d7a37fe67d2e39a066f45f31172", size = 244536, upload-time = "2026-06-17T17:33:15.632Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1586,6 +1606,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, ] +[[package]] +name = "langchain-firecrawl" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "firecrawl-py" }, + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/9d/933a344bbf6bf6e6df8e822413029f1ff665e531e3c92da9a8862154daa1/langchain_firecrawl-0.1.0.tar.gz", hash = "sha256:f25fba0fab0603045a37ef191dc584366df0ae2373a766848522b96002ad1019", size = 192277, upload-time = "2026-06-04T16:02:25.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/f0/687a9a1ec74e33e63cf9d14bbfccc73a0771a25c949572783126a97e1ed2/langchain_firecrawl-0.1.0-py3-none-any.whl", hash = "sha256:457e27fa183ee91c224ee17d6d87d94893a7b5ade1dcec4b7be03712fb2825db", size = 11786, upload-time = "2026-06-04T16:02:23.035Z" }, +] + [[package]] name = "langchain-google-genai" version = "4.2.5" @@ -2230,6 +2263,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0"