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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion rustchain_langchain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
summarize_balance,
summarize_epoch,
summarize_bounties,
summarize_provenance,
)

__version__ = "0.2.0"
__version__ = "0.3.0"
__all__ = [
"RustChainClient",
"AsyncRustChainClient",
Expand All @@ -32,4 +33,5 @@
"summarize_balance",
"summarize_epoch",
"summarize_bounties",
"summarize_provenance",
]
57 changes: 57 additions & 0 deletions rustchain_langchain/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<hex>``, 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_<id>`` 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,
}
80 changes: 80 additions & 0 deletions rustchain_langchain/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<hex>'); {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",
Expand Down Expand Up @@ -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_<hex>', 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_<hex>' 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",
Expand Down Expand Up @@ -213,6 +292,7 @@ async def _arun(self, limit: int = 10) -> str:
),
_BalanceTool(),
_BountiesTool(),
_ProvenanceTool(),
]


Expand Down
86 changes: 86 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
summarize_balance,
summarize_epoch,
summarize_bounties,
summarize_provenance,
)


Expand Down Expand Up @@ -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
Loading