diff --git a/README.md b/README.md index 23297f2..cefabdd 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,36 @@ Each tool returns a compact, agent-friendly summary (agents reason better on a The framework-free `RustChainClient` and `summarize_*` helpers are also exported, so you can use the data without LangChain. +## Async (fan out concurrent reads) + +For agents that need several RustChain facts at once, an async (httpx) client and +matching async tools let those reads run **concurrently** instead of blocking on +each request: + +```bash +pip install "langchain-rustchain-tools[async]" # pulls in httpx + langchain-core +``` + +```python +import asyncio +from rustchain_langchain import AsyncRustChainClient, get_async_rustchain_tools + +async def main(): + client = AsyncRustChainClient() + health, payouts, miners = await asyncio.gather( # concurrent, not one-by-one + client.health(), client.payouts(), client.miners() + ) + +asyncio.run(main()) + +tools = get_async_rustchain_tools() # same 7 names/schemas; each tool's _arun awaits httpx +``` + +`AsyncRustChainClient` mirrors `RustChainClient` method-for-method and returns the +same shapes, so the `summarize_*` helpers consume its output unchanged. The async +tools expose the same names and `args_schema` as the sync ones, so they are a +drop-in for agents that prefer the async path. + ## Point it at your own node ```python diff --git a/pyproject.toml b/pyproject.toml index dd36d5a..f34e444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,12 @@ dependencies = ["requests>=2.28"] [project.optional-dependencies] langchain = ["langchain-core>=0.2"] -test = ["pytest>=7", "langchain-core>=0.2"] +# The async tools build on langchain-core + pydantic (BaseTool/args_schema), +# so the async extra pulls those in too — the documented +# `pip install "...[async]"` then `get_async_rustchain_tools()` path works on +# its own without also requiring the `langchain` extra. +async = ["httpx>=0.24", "langchain-core>=0.2"] +test = ["pytest>=7", "langchain-core>=0.2", "httpx>=0.24"] [project.urls] Homepage = "https://rustchain.org" diff --git a/rustchain_langchain/__init__.py b/rustchain_langchain/__init__.py index 10163c4..37cfd55 100644 --- a/rustchain_langchain/__init__.py +++ b/rustchain_langchain/__init__.py @@ -6,8 +6,10 @@ corrected to /wallet/balance and a tested, never-raise wrapper. """ from .client import RustChainClient +from .async_client import AsyncRustChainClient from .tools import ( get_rustchain_tools, + get_async_rustchain_tools, summarize_network, summarize_payouts, summarize_miners, @@ -20,7 +22,9 @@ __version__ = "0.2.0" __all__ = [ "RustChainClient", + "AsyncRustChainClient", "get_rustchain_tools", + "get_async_rustchain_tools", "summarize_network", "summarize_payouts", "summarize_miners", diff --git a/rustchain_langchain/async_client.py b/rustchain_langchain/async_client.py new file mode 100644 index 0000000..4f99f3a --- /dev/null +++ b/rustchain_langchain/async_client.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: MIT +"""Async, read-only HTTP client for RustChain's public endpoints. + +The ``async`` twin of :class:`rustchain_langchain.client.RustChainClient`. It +exposes the same read-only, keyless surfaces (network stats, payouts, miners, +health, wallet balance, epoch, bounties) but every call is a coroutine backed by +``httpx.AsyncClient`` — so an agent can fan out several RustChain reads +concurrently with ``asyncio.gather(...)`` instead of blocking on each one. + +``httpx`` is imported lazily inside the request path so the rest of the package +(the sync client, the framework-free ``summarize_*`` helpers) keeps working +without it installed — the same contract as the lazy ``langchain-core`` import +in ``tools.py``. Install the async extra with +``pip install langchain-rustchain-tools[async]``. +""" +from __future__ import annotations + +from .client import ( + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT, + _bounties_search_url, + _reshape_bounty, +) + + +class AsyncRustChainClient: + """Async read-only client for the RustChain public API. + + Mirrors :class:`~rustchain_langchain.client.RustChainClient` method-for-method, + but every call is a coroutine. Returned shapes are identical, so the same + framework-free ``summarize_*`` helpers consume the output unchanged. + + Args: + base_url: RustChain node/site base URL (default https://rustchain.org). + timeout: per-request timeout in seconds. + verify: TLS verification (default True; set False only for self-signed + dev nodes). + """ + + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + timeout: int = DEFAULT_TIMEOUT, + verify: bool = True, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.verify = verify + + async def _get_json(self, path: str): + import httpx # lazy: keep the rest of the package httpx-free + + url = f"{self.base_url}/{path.lstrip('/')}" + async with httpx.AsyncClient(timeout=self.timeout, verify=self.verify) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.json() + + # --- public, read-only surfaces (async twins of RustChainClient) ---- + async def network_stats(self) -> dict: + """Self-verifying on-chain activity facts (facts.json).""" + return await self._get_json("/facts.json") + + async def payouts(self) -> dict: + """Chain-computed payout totals + recipient counts (payouts.json).""" + return await self._get_json("/payouts.json") + + async def metrics(self) -> dict: + """Repo/ecosystem metrics snapshot (metrics.json).""" + return await self._get_json("/metrics.json") + + async def miners(self) -> dict: + """Currently attesting miners, with device arch + antiquity multipliers.""" + return await self._get_json("/api/miners") + + async def health(self) -> dict: + """Node health (ok, db_rw, version, backup age).""" + return await self._get_json("/health") + + async def balance(self, miner_id: str) -> dict: + """RTC balance for a wallet / miner id. + + Uses the live /wallet/balance endpoint (the bare /balance path 404s). + """ + import httpx # lazy + + url = f"{self.base_url}/wallet/balance" + async with httpx.AsyncClient(timeout=self.timeout, verify=self.verify) as client: + resp = await client.get(url, params={"miner_id": miner_id}) + resp.raise_for_status() + return resp.json() + + async def epoch(self) -> dict: + """Current epoch: number, slot, enrolled miners, reward pot, total supply.""" + return await self._get_json("/epoch") + + async def bounties(self, limit: int = 10) -> list: + """Open RustChain bounties (GitHub issues on Scottcjn/rustchain-bounties). + + Read-only GitHub search; returns a list of {number, title, reward, url, + created}. Async twin of :meth:`RustChainClient.bounties` — same query, + reward parser and output shape via the shared canonical helpers. + """ + import httpx # lazy + + limit = max(1, min(int(limit), 50)) + async with httpx.AsyncClient(timeout=self.timeout, verify=self.verify) as client: + resp = await client.get( + _bounties_search_url(limit), + headers={"Accept": "application/vnd.github.v3+json"}, + ) + resp.raise_for_status() + items = resp.json().get("items", [])[:limit] + + return [_reshape_bounty(it) for it in items] diff --git a/rustchain_langchain/client.py b/rustchain_langchain/client.py index 542fe07..7304a1f 100644 --- a/rustchain_langchain/client.py +++ b/rustchain_langchain/client.py @@ -9,11 +9,67 @@ """ from __future__ import annotations +import re + import requests DEFAULT_BASE_URL = "https://rustchain.org" DEFAULT_TIMEOUT = 15 +# --- canonical bounty contract (shared by sync + async clients) --------- +# One query, one reward parser, one output shape so the sync client, the async +# client and ``summarize_bounties`` can never drift apart. The search is scoped +# to issues actually carrying the ``bounty`` label, and the reward is read from +# the title *and* the body (titles like ``[BOUNTY: 50 RTC]`` are common) with +# decimal amounts preserved. +BOUNTIES_SEARCH_QUERY = ( + "repo:Scottcjn/rustchain-bounties+state:open+is:issue+label:bounty" +) +_REWARD_RE = re.compile(r"(\d+(?:\.\d+)?)\s*RTC", re.IGNORECASE) + + +def _parse_reward(title: str, body: str) -> str: + """Extract an ``" RTC"`` reward from a bounty issue. + + Looks at the title first (the canonical place for ``[BOUNTY: 50 RTC]``), + then the body, and keeps decimal amounts (``2.5 RTC``). Falls back to + ``"see issue"`` when no amount is stated. + """ + for text in (title or "", body or ""): + m = _REWARD_RE.search(text) + if m: + return f"{m.group(1)} RTC" + return "see issue" + + +def _reshape_bounty(item: dict) -> dict: + """Map a raw GitHub issue dict to the canonical bounty shape. + + The single source of truth for ``{number, title, reward, url, created}`` — + both :class:`RustChainClient` and the async client funnel through this so + their output is byte-for-byte identical and ``summarize_bounties`` consumes + either unchanged. + """ + title = item.get("title") or "" + body = item.get("body", "") or "" + return { + "number": item.get("number"), + "title": title[:100], + "reward": _parse_reward(title, body), + "url": item.get("html_url"), + "created": (item.get("created_at") or "")[:10], + } + + +def _bounties_search_url(limit: int) -> str: + """Build the canonical GitHub search URL for open RustChain bounties.""" + limit = max(1, min(int(limit), 50)) + return ( + "https://api.github.com/search/issues?" + f"q={BOUNTIES_SEARCH_QUERY}&" + f"per_page={limit}&sort=created&order=desc" + ) + class RustChainClient: """Read-only client for the RustChain public API. @@ -81,31 +137,16 @@ def epoch(self) -> dict: def bounties(self, limit: int = 10) -> list: """Open RustChain bounties (GitHub issues on Scottcjn/rustchain-bounties). - Read-only GitHub search; returns a list of {number, title, reward, url, created}. + Read-only GitHub search; returns a list of {number, title, reward, url, + created}. Query, reward parsing and output shape are the shared canonical + helpers (:func:`_bounties_search_url`, :func:`_reshape_bounty`) so the + async client returns the identical contract. """ limit = max(1, min(int(limit), 50)) - url = ( - "https://api.github.com/search/issues?" - "q=repo:Scottcjn/rustchain-bounties+state:open+is:issue&" - f"per_page={limit}&sort=created&order=desc" - ) resp = requests.get( - url, timeout=self.timeout, + _bounties_search_url(limit), timeout=self.timeout, headers={"Accept": "application/vnd.github.v3+json"}, ) resp.raise_for_status() items = resp.json().get("items", [])[:limit] - import re - - out = [] - for it in items: - body = it.get("body", "") or "" - m = re.search(r"(\d+)\s*RTC", body) - out.append({ - "number": it.get("number"), - "title": (it.get("title") or "")[:100], - "reward": f"{m.group(1)} RTC" if m else "see issue", - "url": it.get("html_url"), - "created": (it.get("created_at") or "")[:10], - }) - return out + return [_reshape_bounty(it) for it in items] diff --git a/rustchain_langchain/tools.py b/rustchain_langchain/tools.py index a28fc73..5c9e6dd 100644 --- a/rustchain_langchain/tools.py +++ b/rustchain_langchain/tools.py @@ -17,6 +17,7 @@ from typing import List from .client import RustChainClient +from .async_client import AsyncRustChainClient # --- pure summarizers (framework-free, unit-tested) --------------------- @@ -213,3 +214,135 @@ async def _arun(self, limit: int = 10) -> str: _BalanceTool(), _BountiesTool(), ] + + +def get_async_rustchain_tools( + base_url: str = "https://rustchain.org", + timeout: int = 15, + verify: bool = True, +) -> List["object"]: + """Return the same RustChain tools backed by an async (httpx) client. + + Identical names, descriptions, schemas and summaries as + :func:`get_rustchain_tools`, but each tool's ``_arun`` awaits + :class:`AsyncRustChainClient`, so an agent can issue several RustChain reads + concurrently (e.g. under ``asyncio.gather``) instead of one blocking request + at a time. ``_run`` bridges to the coroutine for sync callers when no event + loop is already running. Requires ``langchain-core`` (and ``httpx`` at call + time). + """ + import asyncio + + from langchain_core.tools import BaseTool # lazy import + from pydantic import BaseModel, Field + from typing import Type + + client = AsyncRustChainClient(base_url=base_url, timeout=timeout, verify=verify) + + def _bridge(coro_factory, *args, **kwargs): + """Run an async tool body from a sync ``_run``: execute the coroutine + when no loop is spinning, otherwise tell the caller to use the async + path. Never raises into an agent loop.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro_factory(*args, **kwargs)) + return ( + "call this tool asynchronously (await ainvoke/_arun) — an event " + "loop is already running on this thread." + ) + + def _make(name: str, description: str, afetch, summarize): + class _AsyncTool(BaseTool): + async def _arun(self, *args, **kwargs) -> str: + try: + return summarize(await afetch()) + except Exception as e: # never raise inside an agent loop + return f"RustChain query failed ({type(e).__name__}): {e}" + + def _run(self, *args, **kwargs) -> str: + return _bridge(self._arun, *args, **kwargs) + + return _AsyncTool(name=name, description=description) + + # --- argument-taking async tools (balance, bounties) ---------------- + class _WalletInput(BaseModel): + miner_id: str = Field(description="Wallet address or miner id to query, e.g. 'dual-g4-125'") + + class _AsyncBalanceTool(BaseTool): + name: str = "rustchain_balance" + description: str = ( + "Check the RTC balance of a RustChain wallet/miner. " + "Input: miner_id (a wallet address or miner id)." + ) + args_schema: Type[BaseModel] = _WalletInput + + async def _arun(self, miner_id: str) -> str: + try: + return summarize_balance(await client.balance(miner_id)) + except Exception as e: + return f"RustChain query failed ({type(e).__name__}): {e}" + + def _run(self, miner_id: str) -> str: + return _bridge(self._arun, miner_id) + + class _BountyInput(BaseModel): + limit: int = Field(default=10, description="Max bounties to return (1-50)") + + class _AsyncBountiesTool(BaseTool): + name: str = "rustchain_bounties" + description: str = ( + "List open RustChain bounties (GitHub issues with RTC rewards). " + "Input: limit (default 10). Returns number, reward, title, URL." + ) + args_schema: Type[BaseModel] = _BountyInput + + async def _arun(self, limit: int = 10) -> str: + try: + return summarize_bounties(await client.bounties(limit)) + except Exception as e: + return f"RustChain query failed ({type(e).__name__}): {e}" + + def _run(self, limit: int = 10) -> str: + return _bridge(self._arun, limit) + + return [ + _make( + "rustchain_network_stats", + "Get RustChain's live on-chain activity (wallet transfers, RTC moved, " + "distinct wallets). Use when asked about RustChain network activity or size.", + client.network_stats, + summarize_network, + ), + _make( + "rustchain_payouts", + "Get total RTC paid out and the number of distinct recipients on RustChain. " + "Use for questions about how much RustChain has paid contributors/miners.", + client.payouts, + summarize_payouts, + ), + _make( + "rustchain_miners", + "Get the current attesting miners and a breakdown by hardware architecture " + "(PowerPC G4/G5, POWER8, x86, Apple Silicon, etc.). Use for questions about " + "who is mining or what hardware is on the network.", + client.miners, + summarize_miners, + ), + _make( + "rustchain_node_health", + "Check whether the RustChain node is healthy (ok, db read-write, version, " + "backup age). Use to verify the network is up before relying on it.", + client.health, + summarize_health, + ), + _make( + "rustchain_epoch", + "Get the current RustChain epoch: number, slot, enrolled miners, epoch " + "reward pot, and total supply. Use for questions about the current mining round.", + client.epoch, + summarize_epoch, + ), + _AsyncBalanceTool(), + _AsyncBountiesTool(), + ] diff --git a/tests/test_tools.py b/tests/test_tools.py index 4328def..0f9bce0 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -87,7 +87,7 @@ def test_tool_run_never_raises_on_failure(): try: from rustchain_langchain import get_rustchain_tools tools = get_rustchain_tools(base_url="https://example.test") - except Exception: + except (ImportError, ModuleNotFoundError): return tool = next(t for t in tools if t.name == "rustchain_payouts") with mock.patch("rustchain_langchain.client.requests.get", side_effect=RuntimeError("boom")): @@ -99,7 +99,7 @@ def test_tool_run_summarizes_on_success(): try: from rustchain_langchain import get_rustchain_tools tools = get_rustchain_tools(base_url="https://example.test") - except Exception: + except (ImportError, ModuleNotFoundError): return tool = next(t for t in tools if t.name == "rustchain_payouts") payload = {"total_paid_rtc": "66,531+", "unique_recipients": 1061, "transactions": 3234, "updated_at": "x"} @@ -135,3 +135,224 @@ def test_client_balance_uses_wallet_balance_endpoint(): out = c.balance("x") assert out["amount_rtc"] == 5.0 assert g.call_args[0][0] == "https://example.test/wallet/balance" # NOT bare /balance + + +# --- async client / async tools (httpx) --------------------------------- +# httpx is monkeypatched, so these never touch the network either. Async +# coroutines are driven with asyncio.run(...) so no pytest-asyncio is needed. +import asyncio + +from rustchain_langchain import AsyncRustChainClient + + +class _AsyncResp: + def __init__(self, payload): + self._p = payload + + def raise_for_status(self): + pass + + def json(self): + return self._p + + +def _fake_async_client(payload, capture=None): + """Drop-in for ``httpx.AsyncClient`` that records the request and yields + ``payload`` — no network, no real httpx connection.""" + cap = capture if capture is not None else {} + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + cap["init_kwargs"] = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url, **kwargs): + cap["url"] = url + cap["get_kwargs"] = kwargs + return _AsyncResp(payload) + + return _FakeAsyncClient + + +def test_async_client_builds_url_and_parses(): + cap = {} + c = AsyncRustChainClient(base_url="https://example.test", timeout=7) + with mock.patch("httpx.AsyncClient", _fake_async_client({"ok": True}, cap)): + out = asyncio.run(c.health()) + assert out == {"ok": True} + assert cap["url"] == "https://example.test/health" + assert cap["init_kwargs"].get("timeout") == 7 + + +def test_async_client_miners_path(): + cap = {} + c = AsyncRustChainClient(base_url="https://example.test") + with mock.patch("httpx.AsyncClient", _fake_async_client({"miners": []}, cap)): + asyncio.run(c.miners()) + assert cap["url"] == "https://example.test/api/miners" + + +def test_async_client_balance_passes_miner_id(): + cap = {} + c = AsyncRustChainClient(base_url="https://example.test") + payload = {"miner_id": "dual-g4-125", "amount_rtc": 42} + with mock.patch("httpx.AsyncClient", _fake_async_client(payload, cap)): + out = asyncio.run(c.balance("dual-g4-125")) + assert cap["url"] == "https://example.test/wallet/balance" + assert cap["get_kwargs"]["params"] == {"miner_id": "dual-g4-125"} + assert summarize_balance(out) == "Wallet 'dual-g4-125' holds 42 RTC." + + +def test_async_client_bounties_reshapes_items(): + cap = {} + c = AsyncRustChainClient(base_url="https://example.test") + payload = {"items": [ + {"number": 7, "title": "Add a thing", "body": "pays 25 RTC on merge", + "html_url": "https://x/7", "created_at": "2026-06-16T00:00:00Z"}, + {"number": 8, "title": "No reward", "body": "", "html_url": "https://x/8", + "created_at": "2026-06-15T00:00:00Z"}, + ]} + with mock.patch("httpx.AsyncClient", _fake_async_client(payload, cap)): + out = asyncio.run(c.bounties(limit=5)) + assert "search/issues" in cap["url"] + assert "label:bounty" in cap["url"] # canonical query filters to bounty issues + assert [b["number"] for b in out] == [7, 8] + assert out[0]["reward"] == "25 RTC" + assert out[1]["reward"] == "see issue" + + +def test_canonical_reward_parser_reads_title_and_decimals(): + """Shared parser used by both clients: title rewards like ``[BOUNTY: 50 RTC]`` + and decimal amounts must be captured, not dropped to ``see issue``.""" + from rustchain_langchain.client import _parse_reward, _reshape_bounty + + # title-only reward (the common `[BOUNTY: N RTC]` shape) + assert _parse_reward("[BOUNTY: 50 RTC] Add X", "no amount in body") == "50 RTC" + # decimal amount in the body + assert _parse_reward("Fix Y", "reward is 2.5 RTC on merge") == "2.5 RTC" + # title takes precedence over body when both carry an amount + assert _parse_reward("[BOUNTY: 75 RTC]", "10 RTC mentioned offhand") == "75 RTC" + # nothing stated -> graceful fallback + assert _parse_reward("No money here", "") == "see issue" + # _reshape_bounty applies the same parser end-to-end + shaped = _reshape_bounty({ + "number": 12, "title": "[BOUNTY: 50 RTC] Add a tool", + "body": "", "html_url": "https://x/12", "created_at": "2026-06-20T00:00:00Z", + }) + assert shaped == { + "number": 12, "title": "[BOUNTY: 50 RTC] Add a tool", "reward": "50 RTC", + "url": "https://x/12", "created": "2026-06-20", + } + + +def test_sync_and_async_bounties_share_canonical_shape(): + """The same raw issue must produce byte-identical output from the sync client + and the async client (the README's sync/async parity promise).""" + from rustchain_langchain import RustChainClient + raw = {"items": [ + {"number": 9, "title": "[BOUNTY: 12.5 RTC] Tweak Z", "body": "", + "html_url": "https://x/9", "created_at": "2026-06-14T00:00:00Z"}, + ]} + sync_c = RustChainClient(base_url="https://example.test") + with mock.patch("rustchain_langchain.client.requests.get", + return_value=_Resp(raw)): + sync_out = sync_c.bounties(limit=5) + async_c = AsyncRustChainClient(base_url="https://example.test") + with mock.patch("httpx.AsyncClient", _fake_async_client(raw)): + async_out = asyncio.run(async_c.bounties(limit=5)) + assert sync_out == async_out + assert sync_out[0]["reward"] == "12.5 RTC" + + +def test_async_client_methods_fan_out_concurrently(): + cap = {} + c = AsyncRustChainClient(base_url="https://example.test") + payload = {"ok": True} + + async def _gather(): + with mock.patch("httpx.AsyncClient", _fake_async_client(payload, cap)): + return await asyncio.gather(c.health(), c.health(), c.health()) + + results = asyncio.run(_gather()) + assert results == [payload, payload, payload] + + +def test_async_tool_arun_summarizes_on_success(): + try: + from rustchain_langchain import get_async_rustchain_tools + tools = get_async_rustchain_tools(base_url="https://example.test") + except (ImportError, ModuleNotFoundError): + return # langchain-core unavailable — skip gracefully + tool = next(t for t in tools if t.name == "rustchain_payouts") + payload = {"total_paid_rtc": "66,531+", "unique_recipients": 1061, + "transactions": 3234, "updated_at": "x"} + with mock.patch("httpx.AsyncClient", _fake_async_client(payload)): + out = asyncio.run(tool._arun()) + assert "66,531+ RTC paid" in out + + +def test_async_tool_arun_never_raises_on_failure(): + try: + from rustchain_langchain import get_async_rustchain_tools + tools = get_async_rustchain_tools(base_url="https://example.test") + except (ImportError, ModuleNotFoundError): + return + tool = next(t for t in tools if t.name == "rustchain_payouts") + + class _BoomClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, *a, **k): + raise RuntimeError("boom") + + with mock.patch("httpx.AsyncClient", _BoomClient): + out = asyncio.run(tool._arun()) + assert "RustChain query failed" in out # graceful, not an exception + + +def test_async_balance_tool_takes_miner_id(): + try: + from rustchain_langchain import get_async_rustchain_tools + tools = get_async_rustchain_tools(base_url="https://example.test") + except (ImportError, ModuleNotFoundError): + return + tool = next(t for t in tools if t.name == "rustchain_balance") + payload = {"miner_id": "g5-001", "amount_rtc": 7} + with mock.patch("httpx.AsyncClient", _fake_async_client(payload)): + out = asyncio.run(tool._arun("g5-001")) + assert out == "Wallet 'g5-001' holds 7 RTC." + + +def test_async_tool_sync_bridge_runs_when_no_loop(): + try: + from rustchain_langchain import get_async_rustchain_tools + tools = get_async_rustchain_tools(base_url="https://example.test") + except (ImportError, ModuleNotFoundError): + return + tool = next(t for t in tools if t.name == "rustchain_node_health") + payload = {"ok": True, "db_rw": True, "version": "2.2.1", "backup_age_hours": 1.0} + with mock.patch("httpx.AsyncClient", _fake_async_client(payload)): + out = tool._run() # no running loop -> bridges via asyncio.run + assert "ok=True" in out and "version=2.2.1" in out + + +def test_async_tools_match_sync_tool_names(): + try: + from rustchain_langchain import get_rustchain_tools, get_async_rustchain_tools + sync_names = {t.name for t in get_rustchain_tools(base_url="https://example.test")} + async_names = {t.name for t in get_async_rustchain_tools(base_url="https://example.test")} + except (ImportError, ModuleNotFoundError): + return + assert sync_names == async_names # async surface mirrors the sync one