From 0442476c1fd698b6d217c03f83266f4d22c61540 Mon Sep 17 00:00:00 2001 From: Vyacheslav-Tomashevskiy Date: Tue, 16 Jun 2026 20:50:50 +0200 Subject: [PATCH 1/3] feat: provenance client methods + tool wrapper + export (#1 Task 5) --- rustchain_langchain/__init__.py | 4 +- rustchain_langchain/client.py | 57 +++++++++++++++++++++++ rustchain_langchain/tools.py | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/rustchain_langchain/__init__.py b/rustchain_langchain/__init__.py index 37cfd55..97725df 100644 --- a/rustchain_langchain/__init__.py +++ b/rustchain_langchain/__init__.py @@ -17,9 +17,10 @@ summarize_balance, summarize_epoch, summarize_bounties, + summarize_provenance, ) -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ "RustChainClient", "AsyncRustChainClient", @@ -32,4 +33,5 @@ "summarize_balance", "summarize_epoch", "summarize_bounties", + "summarize_provenance", ] diff --git a/rustchain_langchain/client.py b/rustchain_langchain/client.py index 7304a1f..fffa35c 100644 --- a/rustchain_langchain/client.py +++ b/rustchain_langchain/client.py @@ -150,3 +150,60 @@ def bounties(self, limit: int = 10) -> list: resp.raise_for_status() items = resp.json().get("items", [])[:limit] return [_reshape_bounty(it) for it in items] + + def beacon_agents(self) -> list: + """Registered Beacon agent-identity cards (id ``bcn_``, name, status).""" + data = self._get_json("/beacon/api/agents") + return data if isinstance(data, list) else [] + + def beacon_contracts(self) -> list: + """Open Beacon economic contracts (leases/offers between agents).""" + data = self._get_json("/beacon/api/contracts") + return data if isinstance(data, list) else [] + + def provenance(self, agent_id: str) -> dict: + """RIP-0310 Proof-of-Provenance status for a Beacon agent id. + + Read-only / keyless. Composes the deployed provenance signals for a + ``bcn_`` identity: its Beacon agent card (Agent layer) and any + Beacon contracts it is party to (Economic layer). The Content-binding + layer (a live ``BindingCert``) is specified in RIP-0310 but not yet + deployed, so it is reported as such rather than fabricated. Returns a + structured dict; ``summarize_provenance`` turns it into an agent-friendly + string. + """ + agent_id = (agent_id or "").strip() + agents = self.beacon_agents() + card = next((a for a in agents if a.get("agent_id") == agent_id), None) + if card is None: + # tolerate being given a display name instead of a bcn_ id + card = next( + (a for a in agents + if (a.get("name") or "").lower() == agent_id.lower()), + None, + ) + + contracts = [] + if card is not None: + aid = card.get("agent_id") + for c in self.beacon_contracts(): + if c.get("from") == aid or c.get("to") == aid: + role = "payer" if c.get("from") == aid else "payee" + other = c.get("to") if role == "payer" else c.get("from") + contracts.append({ + "id": c.get("id"), + "type": c.get("type"), + "amount": c.get("amount"), + "currency": c.get("currency"), + "state": c.get("state"), + "role": role, + "counterparty": other, + }) + + return { + "agent_id": agent_id, + "found": card is not None, + "registered_agents": len(agents), + "identity": card, + "contracts": contracts, + } diff --git a/rustchain_langchain/tools.py b/rustchain_langchain/tools.py index 5c9e6dd..0b8ddfb 100644 --- a/rustchain_langchain/tools.py +++ b/rustchain_langchain/tools.py @@ -103,6 +103,61 @@ def summarize_bounties(items) -> str: return "\n".join(lines) +def summarize_provenance(data: dict) -> str: + """Render RIP-0310 Proof-of-Provenance status for a Beacon agent id. + + Faithful to the spec's stated per-layer deployment status: the Agent + (identity) and Economic layers are live and surfaced here; the Hardware + layer is a precondition that lives on the RustChain node; the Content + binding is specified but not yet deployed. We never overclaim a VERIFIED + content binding that the live API does not expose. + """ + aid = data.get("agent_id", "?") + if not data.get("found"): + n = data.get("registered_agents", "?") + return ( + f"No Beacon agent found for id '{aid}'. RIP-0310 provenance needs a " + f"registered identity (id 'bcn_'); {n} agents are registered in " + f"the Beacon atlas. Check the id, or list agents first." + ) + + ident = data.get("identity") or {} + name = ident.get("name", "?") + status = ident.get("status", "?") + relay = ident.get("relay", False) + model = ident.get("model_id") or ident.get("provider_name") or ident.get("provider") or "?" + caps = ident.get("capabilities") + contracts = data.get("contracts") or [] + + lines = [f"Provenance for Beacon agent {ident.get('agent_id', aid)} (RIP-0310 Proof-of-Provenance):"] + cap_str = f", capabilities: {', '.join(caps)}" if isinstance(caps, list) and caps else "" + lines.append( + f"- Agent layer: IDENTITY PRESENT — \"{name}\", status {status}, " + f"model {model}, relay={relay}{cap_str} " + f"(Beacon agent.json / bcn_ identity; RIP-0310 rates the Agent layer \"partial\")." + ) + if contracts: + c0 = contracts[0] + more = f" (+{len(contracts) - 1} more)" if len(contracts) > 1 else "" + lines.append( + f"- Economic layer: {len(contracts)} Beacon contract(s) on record — e.g. " + f"{c0.get('id')} {c0.get('type')} {c0.get('amount')} {c0.get('currency')} " + f"{c0.get('state')} as {c0.get('role')} with {c0.get('counterparty')}{more}." + ) + else: + lines.append("- Economic layer: no Beacon contracts on record for this agent.") + lines.append( + "- Hardware layer: not exposed via Beacon — PoA hardware attestation is a " + "precondition layer that lives on the RustChain node." + ) + lines.append( + "- Content layer: specified but not yet deployed (RIP-0310 Content-Provenance " + "binding); no live BindingCert endpoint, so a full who+what+when content claim " + "cannot be verified yet." + ) + return "\n".join(lines) + + # --- LangChain tool wrappers -------------------------------------------- def get_rustchain_tools( base_url: str = "https://rustchain.org", @@ -174,6 +229,30 @@ def _run(self, limit: int = 10) -> str: async def _arun(self, limit: int = 10) -> str: return self._run(limit) + class _ProvenanceInput(BaseModel): + agent_id: str = Field( + description="Beacon agent id ('bcn_', e.g. 'bcn_sophia_elya') or its display name" + ) + + class _ProvenanceTool(BaseTool): + name: str = "rustchain_provenance" + description: str = ( + "Surface RIP-0310 Proof-of-Provenance status for a Beacon agent. " + "Input: agent_id (a 'bcn_' id or display name). Reports the agent's " + "Beacon identity, its economic contracts, and which provenance layers are " + "live vs. not-yet-deployed. Use to check who/what is behind a Beacon agent." + ) + args_schema: Type[BaseModel] = _ProvenanceInput + + def _run(self, agent_id: str) -> str: + try: + return summarize_provenance(client.provenance(agent_id)) + except Exception as e: + return f"RustChain query failed ({type(e).__name__}): {e}" + + async def _arun(self, agent_id: str) -> str: + return self._run(agent_id) + return [ _make( "rustchain_network_stats", @@ -213,6 +292,7 @@ async def _arun(self, limit: int = 10) -> str: ), _BalanceTool(), _BountiesTool(), + _ProvenanceTool(), ] From 7e1ad49e9226d1f932e3f6ce7d2ae850d58177bc Mon Sep 17 00:00:00 2001 From: Vyacheslav-Tomashevskiy Date: Tue, 16 Jun 2026 20:51:16 +0200 Subject: [PATCH 2/3] test: provenance client/summarizer/tool coverage (6 new tests, 20 pass) --- tests/test_tools.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/test_tools.py b/tests/test_tools.py index 0f9bce0..9a1fc07 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -12,6 +12,7 @@ summarize_balance, summarize_epoch, summarize_bounties, + summarize_provenance, ) @@ -356,3 +357,88 @@ def test_async_tools_match_sync_tool_names(): except (ImportError, ModuleNotFoundError): return assert sync_names == async_names # async surface mirrors the sync one +_AGENTS = [ + {"agent_id": "bcn_sophia_elya", "name": "Sophia Elya", "status": "active", + "relay": False, "model_id": "gpt-x", "capabilities": ["chat", "curate"]}, + {"agent_id": "bcn_quiet_one", "name": "QuietOne", "status": "presumed_dead", "relay": True}, +] +_CONTRACTS = [ + {"id": "ctr_05ce6bf7", "type": "lease_to_own", "amount": 5.0, "currency": "RTC", + "state": "offered", "from": "bcn_sophia_elya", "to": "relay_sh_sophia_elya"}, + {"id": "ctr_other", "type": "lease", "amount": 2.0, "currency": "RTC", + "state": "active", "from": "bcn_nobody", "to": "bcn_else"}, +] + + +def test_client_provenance_composes_identity_and_contracts(): + c = RustChainClient(base_url="https://example.test") + # provenance() calls beacon_agents() then beacon_contracts() + with mock.patch("rustchain_langchain.client.requests.get", + side_effect=[_Resp(_AGENTS), _Resp(_CONTRACTS)]): + out = c.provenance("bcn_sophia_elya") + assert out["found"] is True + assert out["identity"]["name"] == "Sophia Elya" + assert out["registered_agents"] == 2 + assert len(out["contracts"]) == 1 + assert out["contracts"][0]["role"] == "payer" + assert out["contracts"][0]["counterparty"] == "relay_sh_sophia_elya" + + +def test_client_provenance_matches_display_name(): + c = RustChainClient(base_url="https://example.test") + with mock.patch("rustchain_langchain.client.requests.get", + side_effect=[_Resp(_AGENTS), _Resp(_CONTRACTS)]): + out = c.provenance("Sophia Elya") + assert out["found"] is True and out["identity"]["agent_id"] == "bcn_sophia_elya" + + +def test_client_provenance_not_found(): + c = RustChainClient(base_url="https://example.test") + with mock.patch("rustchain_langchain.client.requests.get", return_value=_Resp(_AGENTS)): + out = c.provenance("bcn_ghost") + assert out["found"] is False and out["identity"] is None and out["contracts"] == [] + + +def test_summarize_provenance_found(): + s = summarize_provenance({ + "agent_id": "bcn_sophia_elya", "found": True, "registered_agents": 2, + "identity": _AGENTS[0], + "contracts": [{"id": "ctr_05ce6bf7", "type": "lease_to_own", "amount": 5.0, + "currency": "RTC", "state": "offered", "role": "payer", + "counterparty": "relay_sh_sophia_elya"}], + }) + assert "Sophia Elya" in s + assert "IDENTITY PRESENT" in s + assert "1 Beacon contract" in s and "ctr_05ce6bf7" in s + assert "not yet deployed" in s # honest about the un-built content layer + + +def test_summarize_provenance_not_found(): + s = summarize_provenance({"agent_id": "bcn_ghost", "found": False, + "registered_agents": 116, "identity": None, "contracts": []}) + assert "No Beacon agent found" in s and "116" in s + + +def test_provenance_tool_run(): + try: + from rustchain_langchain import get_rustchain_tools + tools = get_rustchain_tools(base_url="https://example.test") + except Exception: + return + tool = next(t for t in tools if t.name == "rustchain_provenance") + with mock.patch("rustchain_langchain.client.requests.get", + side_effect=[_Resp(_AGENTS), _Resp(_CONTRACTS)]): + out = tool._run("bcn_sophia_elya") + assert "Sophia Elya" in out and "Agent layer" in out + + +def test_provenance_tool_never_raises(): + try: + from rustchain_langchain import get_rustchain_tools + tools = get_rustchain_tools(base_url="https://example.test") + except Exception: + return + tool = next(t for t in tools if t.name == "rustchain_provenance") + with mock.patch("rustchain_langchain.client.requests.get", side_effect=RuntimeError("boom")): + out = tool._run("bcn_x") + assert "RustChain query failed" in out From 40be3cf83a5cfe45f6044b4e13cbffbda2eb8343 Mon Sep 17 00:00:00 2001 From: Vyacheslav-Tomashevskiy Date: Tue, 16 Jun 2026 20:51:41 +0200 Subject: [PATCH 3/3] docs: list rustchain_provenance + bump version 0.3.0 (#1 Task 5) --- README.md | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cefabdd..6ae9345 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Each tool returns a compact, agent-friendly summary (agents reason better on a | `rustchain_epoch` | current epoch: number, slot, enrolled miners, reward pot, supply | | `rustchain_balance` | RTC balance for a wallet/miner (arg: `miner_id`) | | `rustchain_bounties` | open RustChain bounties with RTC rewards (arg: `limit`) | +| `rustchain_provenance` | RIP-0310 Proof-of-Provenance status for a Beacon agent (arg: `agent_id`) | The framework-free `RustChainClient` and `summarize_*` helpers are also exported, so you can use the data without LangChain. diff --git a/pyproject.toml b/pyproject.toml index f34e444..11f32bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "langchain-rustchain-tools" -version = "0.2.0" +version = "0.3.0" description = "Read-only LangChain tools for RustChain — let any agent query the hardware-attested agent economy." readme = "README.md" requires-python = ">=3.9"