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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions rustchain_langchain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,7 +22,9 @@
__version__ = "0.2.0"
__all__ = [
"RustChainClient",
"AsyncRustChainClient",
"get_rustchain_tools",
"get_async_rustchain_tools",
"summarize_network",
"summarize_payouts",
"summarize_miners",
Expand Down
115 changes: 115 additions & 0 deletions rustchain_langchain/async_client.py
Original file line number Diff line number Diff line change
@@ -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]
83 changes: 62 additions & 21 deletions rustchain_langchain/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``"<amount> 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.
Expand Down Expand Up @@ -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]
Loading