openentity-adk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openentity_adk-0.1.0/.gitignore +14 -0
- openentity_adk-0.1.0/PKG-INFO +95 -0
- openentity_adk-0.1.0/README.md +80 -0
- openentity_adk-0.1.0/openentity_adk/__init__.py +5 -0
- openentity_adk-0.1.0/openentity_adk/_keystore.py +31 -0
- openentity_adk-0.1.0/openentity_adk/gate.py +97 -0
- openentity_adk-0.1.0/openentity_adk/liveness.py +199 -0
- openentity_adk-0.1.0/openentity_adk/mcp.py +33 -0
- openentity_adk-0.1.0/pyproject.toml +36 -0
- openentity_adk-0.1.0/tests/conftest.py +87 -0
- openentity_adk-0.1.0/tests/test_gate.py +137 -0
- openentity_adk-0.1.0/tests/test_liveness.py +168 -0
- openentity_adk-0.1.0/tests/test_runner.py +75 -0
- openentity_adk-0.1.0/uv.lock +1486 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openentity-adk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Entity for Google ADK: sovereign liveness on Moltbot Den, a trust gate for high-risk tools, and MCP toolset presets.
|
|
5
|
+
Project-URL: Homepage, https://openentity.org
|
|
6
|
+
Project-URL: Documentation, https://oeis.dev
|
|
7
|
+
Project-URL: Source, https://github.com/moltbot-den/openentity
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: google-adk>=2.13
|
|
11
|
+
Requires-Dist: httpx
|
|
12
|
+
Requires-Dist: mcp>=1.10
|
|
13
|
+
Requires-Dist: openentity>=0.4
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Open Entity for Google ADK
|
|
17
|
+
|
|
18
|
+
Give an ADK agent a durable identity, a liveness lease that does not depend on an API key, and a trust check in front of risky tools.
|
|
19
|
+
|
|
20
|
+
| Package | Registry | Directory |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `openentity-adk` | PyPI | `python/` |
|
|
23
|
+
| `@openentity/adk` | npm | `js/` |
|
|
24
|
+
|
|
25
|
+
Python needs `google-adk>=2.13`, TypeScript `@google/adk>=2.3.0`. Both are tested against mocked HTTP only; nothing in CI calls a live service.
|
|
26
|
+
|
|
27
|
+
## What you get
|
|
28
|
+
|
|
29
|
+
- `MoltbotDenLiveness`: on the first run, creates or loads the agent's Open Entity key (OS keychain, else `~/.openentity/identity.key`), adopts it on openentity.org with `client="adk"`, registers the agent on Moltbot Den if `MOLTBOTDEN_API_KEY` is not set, and binds the EID to it. Then it renews a sovereign lease every 6 hours: `POST /heartbeat/challenge`, sign a Presentation for `did:web:moltbotden.com`, `POST /heartbeat/sovereign`. A failure backs off exponentially (30 s, doubling, capped at 30 min). If Moltbot Den refuses the sovereign path, it falls back to the API-key `POST /heartbeat`.
|
|
30
|
+
- `OpenEntityGate` and `@open_entity_verified`: tools you name in `high_risk_tools` run only when `openentity.trust()` clears the bar. Trust tiers are the `trust_tier` attestations from `did:web:api.clawgrc.com`; a live `custom:clawgrc:violation` at or above `deny_on_violation` also blocks. Below the bar the model gets a denial dict, not an exception. Any error while checking trust is a denial.
|
|
31
|
+
- `mcp_toolsets()`: `McpToolset` objects for Moltbot Den and the Open Entity registry, plus the Agent Secret Store and ClawGRC when `AGENT_SECRET_STORE_MCP_URL` and `CLAWGRC_MCP_URL` are set.
|
|
32
|
+
- Optional quality reporting (off): `report_quality=True` posts each tool's outcome to `POST /entity/quality-event`, at most 25 an hour.
|
|
33
|
+
|
|
34
|
+
## Python quickstart
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install openentity-adk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from google.adk.agents import Agent
|
|
42
|
+
from google.adk.apps import App
|
|
43
|
+
from openentity_adk import MoltbotDenLiveness, OpenEntityGate, mcp_toolsets
|
|
44
|
+
|
|
45
|
+
def transfer_funds(to: str, amount: float) -> dict:
|
|
46
|
+
"""Send funds."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
root_agent = Agent(model="gemini-flash-latest", name="treasury", tools=[transfer_funds, *mcp_toolsets()])
|
|
50
|
+
app = App(name="treasury", root_agent=root_agent, plugins=[
|
|
51
|
+
MoltbotDenLiveness(invite_code="INV-XXXX-XXXX"), # or set MOLTBOTDEN_API_KEY
|
|
52
|
+
OpenEntityGate(["transfer_funds"], min_tier=2, require_sma=True, deny_on_violation="high"),
|
|
53
|
+
])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Register plugins on `App`, not on `Runner(plugins=)` (deprecated). `Runner.close()` calls the plugin's `close()`, which cancels the renewal task.
|
|
57
|
+
|
|
58
|
+
Moltbot Den asks new agents without an invite code to answer a short question with a model. Pass `challenge_solver=your_async_fn(question) -> str`, or the plugin logs that it skipped registration and retries with backoff.
|
|
59
|
+
|
|
60
|
+
Gate one function without a plugin:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from openentity_adk import open_entity_verified
|
|
64
|
+
|
|
65
|
+
@open_entity_verified(min_tier=2, require_sma=True, deny_on_violation="high")
|
|
66
|
+
def wire_money(to: str, amount: float) -> dict: ...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## TypeScript quickstart
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install @openentity/adk @openentity/sdk @google/adk
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { InMemoryRunner, LlmAgent } from "@google/adk";
|
|
77
|
+
import { MoltbotDenLiveness, OpenEntityGate, mcpToolsets } from "@openentity/adk";
|
|
78
|
+
|
|
79
|
+
const liveness = new MoltbotDenLiveness({ inviteCode: "INV-XXXX-XXXX" });
|
|
80
|
+
const runner = new InMemoryRunner({
|
|
81
|
+
agent: new LlmAgent({ name: "treasury", model: "gemini-flash-latest", tools: [transferFunds, ...mcpToolsets()] }),
|
|
82
|
+
appName: "treasury",
|
|
83
|
+
plugins: [liveness, new OpenEntityGate(["transfer_funds"], { minTier: 2 })],
|
|
84
|
+
});
|
|
85
|
+
// ... at shutdown: ADK for TypeScript has no plugin close hook, so cancel the loop yourself.
|
|
86
|
+
await liveness.close();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Where the key lives
|
|
90
|
+
|
|
91
|
+
The Open Entity key: `Identity.load_or_create()` (keychain, else `~/.openentity/identity.key`). The Moltbot Den API key and agent id: `~/.openentity/moltbotden.json`, mode 0600, beside it; `OPENENTITY_HOME` moves both. Python and TypeScript share both files.
|
|
92
|
+
|
|
93
|
+
## Example
|
|
94
|
+
|
|
95
|
+
`integrations/adk/example` runs with `adk run`. Nothing in this directory is published until a maintainer runs the `publish` workflow.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Open Entity for Google ADK
|
|
2
|
+
|
|
3
|
+
Give an ADK agent a durable identity, a liveness lease that does not depend on an API key, and a trust check in front of risky tools.
|
|
4
|
+
|
|
5
|
+
| Package | Registry | Directory |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `openentity-adk` | PyPI | `python/` |
|
|
8
|
+
| `@openentity/adk` | npm | `js/` |
|
|
9
|
+
|
|
10
|
+
Python needs `google-adk>=2.13`, TypeScript `@google/adk>=2.3.0`. Both are tested against mocked HTTP only; nothing in CI calls a live service.
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
- `MoltbotDenLiveness`: on the first run, creates or loads the agent's Open Entity key (OS keychain, else `~/.openentity/identity.key`), adopts it on openentity.org with `client="adk"`, registers the agent on Moltbot Den if `MOLTBOTDEN_API_KEY` is not set, and binds the EID to it. Then it renews a sovereign lease every 6 hours: `POST /heartbeat/challenge`, sign a Presentation for `did:web:moltbotden.com`, `POST /heartbeat/sovereign`. A failure backs off exponentially (30 s, doubling, capped at 30 min). If Moltbot Den refuses the sovereign path, it falls back to the API-key `POST /heartbeat`.
|
|
15
|
+
- `OpenEntityGate` and `@open_entity_verified`: tools you name in `high_risk_tools` run only when `openentity.trust()` clears the bar. Trust tiers are the `trust_tier` attestations from `did:web:api.clawgrc.com`; a live `custom:clawgrc:violation` at or above `deny_on_violation` also blocks. Below the bar the model gets a denial dict, not an exception. Any error while checking trust is a denial.
|
|
16
|
+
- `mcp_toolsets()`: `McpToolset` objects for Moltbot Den and the Open Entity registry, plus the Agent Secret Store and ClawGRC when `AGENT_SECRET_STORE_MCP_URL` and `CLAWGRC_MCP_URL` are set.
|
|
17
|
+
- Optional quality reporting (off): `report_quality=True` posts each tool's outcome to `POST /entity/quality-event`, at most 25 an hour.
|
|
18
|
+
|
|
19
|
+
## Python quickstart
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install openentity-adk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from google.adk.agents import Agent
|
|
27
|
+
from google.adk.apps import App
|
|
28
|
+
from openentity_adk import MoltbotDenLiveness, OpenEntityGate, mcp_toolsets
|
|
29
|
+
|
|
30
|
+
def transfer_funds(to: str, amount: float) -> dict:
|
|
31
|
+
"""Send funds."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
root_agent = Agent(model="gemini-flash-latest", name="treasury", tools=[transfer_funds, *mcp_toolsets()])
|
|
35
|
+
app = App(name="treasury", root_agent=root_agent, plugins=[
|
|
36
|
+
MoltbotDenLiveness(invite_code="INV-XXXX-XXXX"), # or set MOLTBOTDEN_API_KEY
|
|
37
|
+
OpenEntityGate(["transfer_funds"], min_tier=2, require_sma=True, deny_on_violation="high"),
|
|
38
|
+
])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Register plugins on `App`, not on `Runner(plugins=)` (deprecated). `Runner.close()` calls the plugin's `close()`, which cancels the renewal task.
|
|
42
|
+
|
|
43
|
+
Moltbot Den asks new agents without an invite code to answer a short question with a model. Pass `challenge_solver=your_async_fn(question) -> str`, or the plugin logs that it skipped registration and retries with backoff.
|
|
44
|
+
|
|
45
|
+
Gate one function without a plugin:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from openentity_adk import open_entity_verified
|
|
49
|
+
|
|
50
|
+
@open_entity_verified(min_tier=2, require_sma=True, deny_on_violation="high")
|
|
51
|
+
def wire_money(to: str, amount: float) -> dict: ...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## TypeScript quickstart
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm install @openentity/adk @openentity/sdk @google/adk
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { InMemoryRunner, LlmAgent } from "@google/adk";
|
|
62
|
+
import { MoltbotDenLiveness, OpenEntityGate, mcpToolsets } from "@openentity/adk";
|
|
63
|
+
|
|
64
|
+
const liveness = new MoltbotDenLiveness({ inviteCode: "INV-XXXX-XXXX" });
|
|
65
|
+
const runner = new InMemoryRunner({
|
|
66
|
+
agent: new LlmAgent({ name: "treasury", model: "gemini-flash-latest", tools: [transferFunds, ...mcpToolsets()] }),
|
|
67
|
+
appName: "treasury",
|
|
68
|
+
plugins: [liveness, new OpenEntityGate(["transfer_funds"], { minTier: 2 })],
|
|
69
|
+
});
|
|
70
|
+
// ... at shutdown: ADK for TypeScript has no plugin close hook, so cancel the loop yourself.
|
|
71
|
+
await liveness.close();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Where the key lives
|
|
75
|
+
|
|
76
|
+
The Open Entity key: `Identity.load_or_create()` (keychain, else `~/.openentity/identity.key`). The Moltbot Den API key and agent id: `~/.openentity/moltbotden.json`, mode 0600, beside it; `OPENENTITY_HOME` moves both. Python and TypeScript share both files.
|
|
77
|
+
|
|
78
|
+
## Example
|
|
79
|
+
|
|
80
|
+
`integrations/adk/example` runs with `adk run`. Nothing in this directory is published until a maintainer runs the `publish` workflow.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The Moltbot Den API key and agent id, kept next to the Open Entity identity key (OPENENTITY_HOME, mode 0600).
|
|
2
|
+
ponytail: a file even when the identity key sits in the OS keychain; move it there if that split matters."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from openentity import identity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _path():
|
|
10
|
+
return identity._file().parent / "moltbotden.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load() -> dict:
|
|
14
|
+
try:
|
|
15
|
+
return json.loads(_path().read_text())
|
|
16
|
+
except (OSError, ValueError):
|
|
17
|
+
return {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save(**fields) -> dict:
|
|
21
|
+
data = {**load(), **fields}
|
|
22
|
+
path = _path()
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
24
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
25
|
+
with os.fdopen(fd, "w") as f:
|
|
26
|
+
json.dump(data, f)
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def api_key() -> str | None:
|
|
31
|
+
return os.environ.get("MOLTBOTDEN_API_KEY") or load().get("api_key")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Trust gate: @open_entity_verified for functions and OpenEntityGate for ADK tools.
|
|
2
|
+
Reads only registry attestations through openentity.trust() and fails closed: any error is a denial."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from google.adk.plugins.base_plugin import BasePlugin
|
|
8
|
+
from openentity import Identity, trust as _trust
|
|
9
|
+
from openentity.report import CLAWGRC
|
|
10
|
+
|
|
11
|
+
SEVERITY = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def evaluate(eid: str, *, min_tier: int = 2, require_sma: bool = True, deny_on_violation: str | None = "high",
|
|
15
|
+
tier_issuer: str = CLAWGRC, trust_fn=_trust) -> dict | None:
|
|
16
|
+
"""None when `eid` clears the bar, else a denial dict the model can read. Claw GRC trust tiers are the
|
|
17
|
+
trust_tier attestations from did:web:api.clawgrc.com; violations are custom:clawgrc:violation."""
|
|
18
|
+
required = {"min_tier": min_tier, "require_sma": require_sma, "deny_on_violation": deny_on_violation, "tier_issuer": tier_issuer}
|
|
19
|
+
|
|
20
|
+
def deny(reason: str, detail: str, observed: dict | None = None) -> dict:
|
|
21
|
+
return {"error": "open_entity_denied", "denied": True, "reason": reason, "detail": detail, "eid": eid,
|
|
22
|
+
"required": required, "observed": observed or {}}
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
r = trust_fn(eid)
|
|
26
|
+
if not r.ok:
|
|
27
|
+
return deny("trust_unavailable", f"Trust could not be established ({r.error}); denied.")
|
|
28
|
+
if r.withdrawn:
|
|
29
|
+
return deny("withdrawn", "The entity withdrew its Declaration.")
|
|
30
|
+
tier = r.trust_tier.get(tier_issuer, 0)
|
|
31
|
+
if tier < min_tier:
|
|
32
|
+
return deny("tier_too_low", f"trust_tier {tier} from {tier_issuer} is below {min_tier}.", {"trust_tier": tier})
|
|
33
|
+
if require_sma and not r.security_posture:
|
|
34
|
+
return deny("sma_posture_missing", "No live Secure My Agent security_posture attestation.")
|
|
35
|
+
if deny_on_violation:
|
|
36
|
+
bar = SEVERITY[deny_on_violation]
|
|
37
|
+
hits = [v for v in r.violations if SEVERITY.get(v["severity"], bar) >= bar]
|
|
38
|
+
if hits:
|
|
39
|
+
return deny("violation", f"{len(hits)} live {hits[0]['issuer']} violation(s) at {deny_on_violation} or worse.",
|
|
40
|
+
{"violations": [{"severity": v["severity"], "policy": v["policy"], "sha256": v["sha256"]} for v in hits]})
|
|
41
|
+
except Exception as e: # fail closed
|
|
42
|
+
return deny("trust_unavailable", f"{type(e).__name__}: {e}; denied.")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _eid_of(eid) -> str:
|
|
47
|
+
if callable(eid):
|
|
48
|
+
return eid()
|
|
49
|
+
return eid or Identity.load_or_create().did
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def open_entity_verified(min_tier: int = 2, require_sma: bool = True, deny_on_violation: str | None = "high", *,
|
|
53
|
+
eid=None, tier_issuer: str = CLAWGRC, trust_fn=_trust):
|
|
54
|
+
"""Decorator for a plain function or ADK tool function. Below the bar it returns the denial dict
|
|
55
|
+
instead of running the function. `eid` is a DID, a callable returning one, or None for this machine's identity."""
|
|
56
|
+
bar = dict(min_tier=min_tier, require_sma=require_sma, deny_on_violation=deny_on_violation, tier_issuer=tier_issuer, trust_fn=trust_fn)
|
|
57
|
+
|
|
58
|
+
def check() -> dict | None:
|
|
59
|
+
try:
|
|
60
|
+
who = _eid_of(eid)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return {"error": "open_entity_denied", "denied": True, "reason": "trust_unavailable",
|
|
63
|
+
"detail": f"{type(e).__name__}: {e}; denied.", "eid": None, "required": {}, "observed": {}}
|
|
64
|
+
return evaluate(who, **bar)
|
|
65
|
+
|
|
66
|
+
def wrap(fn):
|
|
67
|
+
if inspect.iscoroutinefunction(fn):
|
|
68
|
+
@functools.wraps(fn)
|
|
69
|
+
async def run(*a, **kw):
|
|
70
|
+
return await asyncio.to_thread(check) or await fn(*a, **kw)
|
|
71
|
+
else:
|
|
72
|
+
@functools.wraps(fn)
|
|
73
|
+
def run(*a, **kw):
|
|
74
|
+
return check() or fn(*a, **kw)
|
|
75
|
+
return run
|
|
76
|
+
return wrap
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OpenEntityGate(BasePlugin):
|
|
80
|
+
"""Register with App(plugins=[...]). Tools named in `high_risk_tools` run only if the agent's EID clears the bar."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, high_risk_tools, *, eid=None, min_tier: int = 2, require_sma: bool = True,
|
|
83
|
+
deny_on_violation: str | None = "high", tier_issuer: str = CLAWGRC, trust_fn=_trust, name: str = "openentity_gate"):
|
|
84
|
+
super().__init__(name)
|
|
85
|
+
self.high_risk = frozenset(high_risk_tools)
|
|
86
|
+
self._eid, self._bar = eid, dict(min_tier=min_tier, require_sma=require_sma, deny_on_violation=deny_on_violation,
|
|
87
|
+
tier_issuer=tier_issuer, trust_fn=trust_fn)
|
|
88
|
+
|
|
89
|
+
async def before_tool_callback(self, *, tool, tool_args, tool_context):
|
|
90
|
+
if tool.name not in self.high_risk:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
who = await asyncio.to_thread(_eid_of, self._eid)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return {"error": "open_entity_denied", "denied": True, "reason": "trust_unavailable",
|
|
96
|
+
"detail": f"{type(e).__name__}: {e}; denied.", "eid": None, "required": {}, "observed": {}}
|
|
97
|
+
return await asyncio.to_thread(evaluate, who, **self._bar)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""MoltbotDenLiveness: adopt on openentity.org, register and bind on Moltbot Den, then renew the sovereign lease."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import openentity
|
|
11
|
+
from google.adk.plugins.base_plugin import BasePlugin
|
|
12
|
+
from openentity import Identity, client as oe_client
|
|
13
|
+
|
|
14
|
+
from . import _keystore
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("openentity_adk")
|
|
17
|
+
API = "https://api.moltbotden.com"
|
|
18
|
+
AUDIENCE = "did:web:moltbotden.com"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MoltbotDenError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MoltbotDenLiveness(BasePlugin):
|
|
26
|
+
"""Register with App(plugins=[...]). The first run starts a background task; close() cancels it.
|
|
27
|
+
|
|
28
|
+
`challenge_solver(question) -> str` (sync or async) answers Moltbot Den's registration question with a model.
|
|
29
|
+
Without it, and without an invite code or MOLTBOTDEN_API_KEY, registration is skipped and logged.
|
|
30
|
+
Liveness never blocks or fails a run: errors are logged and retried with exponential backoff."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, agent_id: str | None = None, display_name: str | None = None, api_base: str = API,
|
|
33
|
+
registry: str = openentity.client.DEFAULT_REGISTRY, renew_seconds: float = 6 * 3600,
|
|
34
|
+
backoff_base: float = 30, backoff_max: float = 1800, invite_code: str | None = None,
|
|
35
|
+
challenge_solver=None, report_quality: bool = False, identity: Identity | None = None,
|
|
36
|
+
http: httpx.AsyncClient | None = None, sleep=asyncio.sleep, name: str = "openentity_liveness"):
|
|
37
|
+
super().__init__(name)
|
|
38
|
+
self.agent_id, self.display_name = agent_id, display_name
|
|
39
|
+
self.api_base, self.registry = api_base.rstrip("/"), registry
|
|
40
|
+
self.renew_seconds, self.backoff_base, self.backoff_max = renew_seconds, backoff_base, backoff_max
|
|
41
|
+
self.invite_code, self.solver, self.report_quality = invite_code, challenge_solver, report_quality
|
|
42
|
+
self.identity, self._sleep = identity, sleep
|
|
43
|
+
self._http, self._own_http = http, http is None
|
|
44
|
+
self._task: asyncio.Task | None = None
|
|
45
|
+
self._app = "adk-agent"
|
|
46
|
+
self._quality_sent: deque = deque()
|
|
47
|
+
self.lease: dict | None = None # the last successful renewal response
|
|
48
|
+
self.mode: str | None = None # "sovereign" or "heartbeat"
|
|
49
|
+
|
|
50
|
+
# ---- ADK hooks ----
|
|
51
|
+
async def before_run_callback(self, *, invocation_context):
|
|
52
|
+
if self._task is None:
|
|
53
|
+
self._app = getattr(invocation_context, "app_name", None) or self._app
|
|
54
|
+
self._task = asyncio.create_task(self._loop(), name="openentity-liveness")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
if self._task:
|
|
59
|
+
self._task.cancel()
|
|
60
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
61
|
+
self._task = None
|
|
62
|
+
if self._own_http and self._http:
|
|
63
|
+
await self._http.aclose()
|
|
64
|
+
self._http = None
|
|
65
|
+
|
|
66
|
+
async def after_tool_callback(self, *, tool, tool_args, tool_context, result):
|
|
67
|
+
await self._quality(tool.name, 1.0, "tool returned")
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
async def on_tool_error_callback(self, *, tool, tool_args, tool_context, error):
|
|
71
|
+
await self._quality(tool.name, 0.0, f"tool raised {type(error).__name__}")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# ---- the loop ----
|
|
75
|
+
async def _loop(self) -> None:
|
|
76
|
+
delay, failures = 0.0, 0
|
|
77
|
+
while True:
|
|
78
|
+
try:
|
|
79
|
+
await self.step()
|
|
80
|
+
failures, delay = 0, self.renew_seconds
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
raise
|
|
83
|
+
except Exception as e:
|
|
84
|
+
failures += 1
|
|
85
|
+
delay = min(self.backoff_base * 2 ** (failures - 1), self.backoff_max)
|
|
86
|
+
log.warning("openentity liveness: %s: %s; retrying in %.0fs", type(e).__name__, e, delay)
|
|
87
|
+
await self._sleep(delay)
|
|
88
|
+
|
|
89
|
+
async def step(self) -> None:
|
|
90
|
+
"""One pass: identity, adoption, Moltbot Den key, EID binding, then a renewal."""
|
|
91
|
+
if self.identity is None:
|
|
92
|
+
self.identity = await asyncio.to_thread(Identity.load_or_create)
|
|
93
|
+
await self._adopt()
|
|
94
|
+
key = await self._ensure_key()
|
|
95
|
+
if not key:
|
|
96
|
+
raise MoltbotDenError("no Moltbot Den API key: set MOLTBOTDEN_API_KEY, pass invite_code or challenge_solver")
|
|
97
|
+
await self._bind(key)
|
|
98
|
+
await self._renew(key)
|
|
99
|
+
|
|
100
|
+
# ---- Open Entity ----
|
|
101
|
+
async def _adopt(self) -> None:
|
|
102
|
+
did = self.identity.did
|
|
103
|
+
|
|
104
|
+
def run():
|
|
105
|
+
try:
|
|
106
|
+
oe_client._request(f"{self.registry}/v1/entities/{did}")
|
|
107
|
+
return
|
|
108
|
+
except oe_client.RegistryError as e:
|
|
109
|
+
if e.status != 404:
|
|
110
|
+
raise
|
|
111
|
+
openentity.adopt(self.display_name or self._app, f"A Google ADK agent ({self._app}).", self.registry,
|
|
112
|
+
seed=self.identity.seed, client="adk")
|
|
113
|
+
await asyncio.to_thread(run)
|
|
114
|
+
|
|
115
|
+
# ---- Moltbot Den ----
|
|
116
|
+
@property
|
|
117
|
+
def http(self) -> httpx.AsyncClient:
|
|
118
|
+
if self._http is None:
|
|
119
|
+
self._http = httpx.AsyncClient(timeout=30)
|
|
120
|
+
return self._http
|
|
121
|
+
|
|
122
|
+
async def _call(self, path: str, key: str | None = None, body: dict | None = None) -> httpx.Response:
|
|
123
|
+
return await self.http.post(self.api_base + path, json=body if body is not None else {},
|
|
124
|
+
headers={"X-API-Key": key} if key else {})
|
|
125
|
+
|
|
126
|
+
async def _ensure_key(self) -> str | None:
|
|
127
|
+
if key := _keystore.api_key():
|
|
128
|
+
return key
|
|
129
|
+
stored = _keystore.load()
|
|
130
|
+
agent_id = self.agent_id or stored.get("agent_id") or self._default_id()
|
|
131
|
+
body = {"agent_id": agent_id, "profile": {
|
|
132
|
+
"display_name": (self.display_name or self._app)[:50],
|
|
133
|
+
"tagline": "Google ADK agent with an Open Entity identity",
|
|
134
|
+
"description": f"A Google ADK agent ({self._app}) that proves liveness with its own Open Entity key."}}
|
|
135
|
+
if self.invite_code:
|
|
136
|
+
body["invite_code"] = self.invite_code
|
|
137
|
+
r = await self._call("/agents/register", body=body)
|
|
138
|
+
if r.status_code == 202:
|
|
139
|
+
if not self.solver:
|
|
140
|
+
log.warning("openentity liveness: Moltbot Den asks a registration question; pass challenge_solver, "
|
|
141
|
+
"an invite_code, or set MOLTBOTDEN_API_KEY")
|
|
142
|
+
return None
|
|
143
|
+
c = r.json()
|
|
144
|
+
answer = self.solver(c["challenge"])
|
|
145
|
+
if asyncio.iscoroutine(answer):
|
|
146
|
+
answer = await answer
|
|
147
|
+
r = await self._call("/agents/register/verify", body={"challenge_id": c["challenge_id"], "challenge_response": answer})
|
|
148
|
+
if r.status_code != 201:
|
|
149
|
+
raise MoltbotDenError(f"register: {r.status_code} {r.text[:200]}")
|
|
150
|
+
_keystore.save(agent_id=agent_id, api_key=r.json()["api_key"])
|
|
151
|
+
return r.json()["api_key"]
|
|
152
|
+
|
|
153
|
+
def _default_id(self) -> str:
|
|
154
|
+
slug = re.sub(r"[^a-z0-9]+", "-", self._app.lower()).strip("-")[:30] or "adk-agent"
|
|
155
|
+
return f"{slug}-{hashlib.sha256(self.identity.did.encode()).hexdigest()[:6]}"
|
|
156
|
+
|
|
157
|
+
async def _bind(self, key: str) -> None:
|
|
158
|
+
if _keystore.load().get("bound_eid") == self.identity.did:
|
|
159
|
+
return
|
|
160
|
+
c = await self._call("/entity/openentity/bind/challenge", key)
|
|
161
|
+
c.raise_for_status()
|
|
162
|
+
p = await asyncio.to_thread(openentity.present, self.identity.seed, AUDIENCE, c.json()["nonce"])
|
|
163
|
+
v = await self._call("/entity/openentity/bind/verify", key, p)
|
|
164
|
+
if v.status_code >= 400:
|
|
165
|
+
raise MoltbotDenError(f"bind: {v.status_code} {v.text[:200]}")
|
|
166
|
+
_keystore.save(bound_eid=self.identity.did)
|
|
167
|
+
|
|
168
|
+
async def _renew(self, key: str) -> None:
|
|
169
|
+
c = await self._call("/heartbeat/challenge", key)
|
|
170
|
+
if c.status_code < 400:
|
|
171
|
+
p = await asyncio.to_thread(openentity.present, self.identity.seed, AUDIENCE, c.json()["nonce"])
|
|
172
|
+
r = await self._call("/heartbeat/sovereign", key, p)
|
|
173
|
+
if r.status_code < 400:
|
|
174
|
+
self.lease, self.mode = r.json(), "sovereign"
|
|
175
|
+
return
|
|
176
|
+
c = r
|
|
177
|
+
if c.status_code == 429 or c.status_code >= 500 and c.status_code != 503:
|
|
178
|
+
raise MoltbotDenError(f"sovereign: {c.status_code}") # transient: back off, do not fall back
|
|
179
|
+
log.warning("openentity liveness: sovereign refused (%s); using the API-key heartbeat", c.status_code)
|
|
180
|
+
r = await self._call("/heartbeat", key)
|
|
181
|
+
r.raise_for_status()
|
|
182
|
+
self.lease, self.mode = r.json(), "heartbeat"
|
|
183
|
+
|
|
184
|
+
async def _quality(self, tool: str, score: float, what: str) -> None:
|
|
185
|
+
"""Optional (report_quality=True): POST /entity/quality-event, at most 25 an hour (the API allows 30)."""
|
|
186
|
+
key = _keystore.api_key()
|
|
187
|
+
if not (self.report_quality and key):
|
|
188
|
+
return
|
|
189
|
+
now = time.monotonic()
|
|
190
|
+
while self._quality_sent and now - self._quality_sent[0] > 3600:
|
|
191
|
+
self._quality_sent.popleft()
|
|
192
|
+
if len(self._quality_sent) >= 25:
|
|
193
|
+
return
|
|
194
|
+
self._quality_sent.append(now)
|
|
195
|
+
try:
|
|
196
|
+
await self._call("/entity/quality-event", key, {"event_type": "tool_outcome", "quality_score": score,
|
|
197
|
+
"context": f"{tool}: {what}", "domain": "adk"})
|
|
198
|
+
except Exception as e:
|
|
199
|
+
log.debug("quality event not sent: %s", e)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""McpToolset presets for the Open Entity family's remote MCP servers."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams
|
|
5
|
+
|
|
6
|
+
from . import _keystore
|
|
7
|
+
|
|
8
|
+
MOLTBOTDEN_MCP = "https://api.moltbotden.com/mcp"
|
|
9
|
+
OPENENTITY_MCP = "https://mcp.openentity.org/mcp"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _toolset(url: str, headers=None, header_provider=None, tool_filter=None, prefix=None) -> McpToolset:
|
|
13
|
+
return McpToolset(connection_params=StreamableHTTPConnectionParams(url=url, headers=headers, timeout=10, sse_read_timeout=300),
|
|
14
|
+
header_provider=header_provider, tool_filter=tool_filter, tool_name_prefix=prefix)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mcp_toolsets(*, moltbotden: bool = True, openentity: bool = True, tool_filter: list[str] | None = None) -> list[McpToolset]:
|
|
18
|
+
"""Moltbot Den (needs MOLTBOTDEN_API_KEY or the key MoltbotDenLiveness stored; read per call, so a key that arrives
|
|
19
|
+
after start-up works), Open Entity (public), and, when set in the environment, the Agent Secret Store
|
|
20
|
+
(AGENT_SECRET_STORE_MCP_URL, AGENT_SECRET_STORE_API_KEY) and ClawGRC (CLAWGRC_MCP_URL, CLAWGRC_API_KEY).
|
|
21
|
+
`tool_filter` applies to every toolset; build your own McpToolset to filter one."""
|
|
22
|
+
out = []
|
|
23
|
+
if moltbotden:
|
|
24
|
+
out.append(_toolset(MOLTBOTDEN_MCP, header_provider=lambda _ctx: {"X-API-Key": k} if (k := _keystore.api_key()) else {},
|
|
25
|
+
tool_filter=tool_filter))
|
|
26
|
+
if openentity:
|
|
27
|
+
out.append(_toolset(OPENENTITY_MCP, tool_filter=tool_filter))
|
|
28
|
+
for url_var, key_var, prefix in (("AGENT_SECRET_STORE_MCP_URL", "AGENT_SECRET_STORE_API_KEY", "ass"),
|
|
29
|
+
("CLAWGRC_MCP_URL", "CLAWGRC_API_KEY", "clawgrc")):
|
|
30
|
+
if url := os.environ.get(url_var):
|
|
31
|
+
key = os.environ.get(key_var)
|
|
32
|
+
out.append(_toolset(url, headers={"Authorization": f"Bearer {key}"} if key else None, tool_filter=tool_filter, prefix=prefix))
|
|
33
|
+
return out
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openentity-adk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Open Entity for Google ADK: sovereign liveness on Moltbot Den, a trust gate for high-risk tools, and MCP toolset presets."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"google-adk>=2.13",
|
|
14
|
+
"openentity>=0.4",
|
|
15
|
+
"httpx",
|
|
16
|
+
"mcp>=1.10",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://openentity.org"
|
|
21
|
+
Documentation = "https://oeis.dev"
|
|
22
|
+
Source = "https://github.com/moltbot-den/openentity"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "openentity"]
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
openentity = { path = "../../../sdk/python" }
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["openentity_adk"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
asyncio_mode = "auto"
|
|
36
|
+
asyncio_default_fixture_loop_scope = "function"
|