grid-agent-sdk 1.0.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.
- grid_agent_sdk-1.0.0/PKG-INFO +13 -0
- grid_agent_sdk-1.0.0/grid_agent/__init__.py +0 -0
- grid_agent_sdk-1.0.0/grid_agent/agent.py +514 -0
- grid_agent_sdk-1.0.0/grid_agent/discovery.py +92 -0
- grid_agent_sdk-1.0.0/grid_agent/providers.py +156 -0
- grid_agent_sdk-1.0.0/grid_agent/session.py +268 -0
- grid_agent_sdk-1.0.0/grid_agent/types.py +83 -0
- grid_agent_sdk-1.0.0/grid_agent/wallet.py +152 -0
- grid_agent_sdk-1.0.0/grid_agent_sdk.egg-info/PKG-INFO +13 -0
- grid_agent_sdk-1.0.0/grid_agent_sdk.egg-info/SOURCES.txt +16 -0
- grid_agent_sdk-1.0.0/grid_agent_sdk.egg-info/dependency_links.txt +1 -0
- grid_agent_sdk-1.0.0/grid_agent_sdk.egg-info/requires.txt +11 -0
- grid_agent_sdk-1.0.0/grid_agent_sdk.egg-info/top_level.txt +1 -0
- grid_agent_sdk-1.0.0/pyproject.toml +22 -0
- grid_agent_sdk-1.0.0/setup.cfg +4 -0
- grid_agent_sdk-1.0.0/tests/test_agent.py +271 -0
- grid_agent_sdk-1.0.0/tests/test_openai_provider.py +35 -0
- grid_agent_sdk-1.0.0/tests/test_wallet.py +81 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grid-agent-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Requires-Dist: anthropic
|
|
5
|
+
Requires-Dist: openai
|
|
6
|
+
Requires-Dist: httpx
|
|
7
|
+
Requires-Dist: python-dotenv
|
|
8
|
+
Requires-Dist: web3
|
|
9
|
+
Requires-Dist: eth-account
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
13
|
+
Requires-Dist: mypy; extra == "dev"
|
|
File without changes
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GridAgent — autonomous agent with MCP tool dispatch and x402 payment signing.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import logging
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import Any, AsyncIterator
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .types import GridManifest, SessionInfo, ToolCallResult, X402Attestation
|
|
17
|
+
from .wallet import WalletSigner
|
|
18
|
+
from .session import SessionManager, USDC_BASE_SEPOLIA
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ─── MCP HTTP client ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
class MCPClient:
|
|
25
|
+
"""Async HTTP client for the Grid gateway /api/mcp endpoint."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, gateway_url: str, http_client: httpx.AsyncClient) -> None:
|
|
28
|
+
url = gateway_url.rstrip("/")
|
|
29
|
+
# FIX: Explicit endswith guards for both variants to prevent duplicate paths
|
|
30
|
+
if url.endswith("/api/mcp") or url.endswith("/mcp"):
|
|
31
|
+
self._url = url
|
|
32
|
+
else:
|
|
33
|
+
self._url = url + "/api/mcp"
|
|
34
|
+
self._http = http_client
|
|
35
|
+
|
|
36
|
+
async def call(
|
|
37
|
+
self,
|
|
38
|
+
tool: str,
|
|
39
|
+
input: dict[str, Any],
|
|
40
|
+
session_id: str,
|
|
41
|
+
agent_sig: str,
|
|
42
|
+
payment_header: str | None = None,
|
|
43
|
+
idempotency_key: str | None = None,
|
|
44
|
+
) -> httpx.Response:
|
|
45
|
+
import uuid
|
|
46
|
+
request_id = str(uuid.uuid4())
|
|
47
|
+
headers: dict[str, str] = {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"x-agent-sig": agent_sig,
|
|
50
|
+
"x-session-id": session_id,
|
|
51
|
+
"X-Request-Id": request_id,
|
|
52
|
+
}
|
|
53
|
+
if payment_header:
|
|
54
|
+
headers["x-payment"] = payment_header
|
|
55
|
+
if idempotency_key:
|
|
56
|
+
headers["x-idempotency-key"] = idempotency_key
|
|
57
|
+
|
|
58
|
+
return await self._http.post(
|
|
59
|
+
self._url,
|
|
60
|
+
json={"tool": tool, "input": input, "sessionId": session_id, "agentSig": agent_sig},
|
|
61
|
+
headers=headers,
|
|
62
|
+
timeout=60.0,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def stream(
|
|
66
|
+
self,
|
|
67
|
+
tool: str,
|
|
68
|
+
input: dict[str, Any],
|
|
69
|
+
session_id: str,
|
|
70
|
+
agent_sig: str,
|
|
71
|
+
payment_header: str | None = None,
|
|
72
|
+
idempotency_key: str | None = None,
|
|
73
|
+
) -> AsyncIterator[str]:
|
|
74
|
+
import uuid
|
|
75
|
+
request_id = str(uuid.uuid4())
|
|
76
|
+
headers: dict[str, str] = {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"x-agent-sig": agent_sig,
|
|
79
|
+
"x-session-id": session_id,
|
|
80
|
+
"X-Request-Id": request_id,
|
|
81
|
+
"Accept": "text/event-stream",
|
|
82
|
+
}
|
|
83
|
+
if payment_header:
|
|
84
|
+
headers["x-payment"] = payment_header
|
|
85
|
+
if idempotency_key:
|
|
86
|
+
headers["x-idempotency-key"] = idempotency_key
|
|
87
|
+
|
|
88
|
+
async with self._http.stream(
|
|
89
|
+
"POST",
|
|
90
|
+
self._url,
|
|
91
|
+
json={"tool": tool, "input": input, "sessionId": session_id, "agentSig": agent_sig},
|
|
92
|
+
headers=headers,
|
|
93
|
+
timeout=None,
|
|
94
|
+
) as resp:
|
|
95
|
+
if resp.status_code not in (200, 201):
|
|
96
|
+
body = await resp.aread()
|
|
97
|
+
raise RuntimeError(f"Gateway error {resp.status_code}: {body.decode()}")
|
|
98
|
+
async for line in resp.aiter_lines():
|
|
99
|
+
if line.startswith("data:"):
|
|
100
|
+
data = line[5:].strip()
|
|
101
|
+
if data == "[DONE]":
|
|
102
|
+
break
|
|
103
|
+
yield data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ─── Nonce counter ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
class _NonceCounter:
|
|
109
|
+
"""Persistent per-session nonce to prevent attestation replay across restarts."""
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._counters: dict[str, int] = {}
|
|
112
|
+
self._base_dir = os.path.join(os.getcwd(), ".grid", "nonces")
|
|
113
|
+
os.makedirs(self._base_dir, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
def _get_path(self, session_id: str) -> str:
|
|
116
|
+
# Use a safe filename for the session_id
|
|
117
|
+
safe_id = session_id.removeprefix("0x")
|
|
118
|
+
return os.path.join(self._base_dir, f"{safe_id}.nonce")
|
|
119
|
+
|
|
120
|
+
def next(self, session_id: str) -> int:
|
|
121
|
+
if session_id not in self._counters:
|
|
122
|
+
path = self._get_path(session_id)
|
|
123
|
+
if os.path.exists(path):
|
|
124
|
+
try:
|
|
125
|
+
with open(path, "r") as f:
|
|
126
|
+
self._counters[session_id] = int(f.read().strip())
|
|
127
|
+
except (ValueError, IOError):
|
|
128
|
+
self._counters[session_id] = 0
|
|
129
|
+
else:
|
|
130
|
+
self._counters[session_id] = 0
|
|
131
|
+
|
|
132
|
+
self._counters[session_id] += 1
|
|
133
|
+
|
|
134
|
+
# Persist to disk
|
|
135
|
+
try:
|
|
136
|
+
with open(self._get_path(session_id), "w") as f:
|
|
137
|
+
f.write(str(self._counters[session_id]))
|
|
138
|
+
except IOError as e:
|
|
139
|
+
logger.warning(f"Failed to persist nonce for {session_id}: {e}")
|
|
140
|
+
|
|
141
|
+
return self._counters[session_id]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ─── GridAgent ────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
from .providers import LLMProvider, AnthropicProvider, OpenAIProvider, OpenAICompatibleProvider
|
|
147
|
+
|
|
148
|
+
class GridAgent:
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
provider: LLMProvider,
|
|
153
|
+
gateway_url: str,
|
|
154
|
+
manifest: GridManifest,
|
|
155
|
+
signer: WalletSigner,
|
|
156
|
+
rpc_url: str,
|
|
157
|
+
contract_address: str,
|
|
158
|
+
usdc_address: str = USDC_BASE_SEPOLIA,
|
|
159
|
+
max_tool_retries: int = 2,
|
|
160
|
+
) -> None:
|
|
161
|
+
self._provider = provider
|
|
162
|
+
self._manifest = manifest
|
|
163
|
+
self._signer = signer
|
|
164
|
+
self._max_retries = max_tool_retries
|
|
165
|
+
self._nonces = _NonceCounter()
|
|
166
|
+
self._http = httpx.AsyncClient()
|
|
167
|
+
self._mcp = MCPClient(gateway_url, self._http)
|
|
168
|
+
self._sandbox = os.environ.get("GRID_SANDBOX", "").lower() in ("1", "true", "yes")
|
|
169
|
+
self._session_mgr = SessionManager(
|
|
170
|
+
rpc_url=rpc_url,
|
|
171
|
+
contract_address=contract_address,
|
|
172
|
+
signer=signer,
|
|
173
|
+
usdc_address=usdc_address,
|
|
174
|
+
sandbox=self._sandbox,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_env(cls, manifest_path: str = "grid.manifest.json") -> "GridAgent":
|
|
179
|
+
from dotenv import load_dotenv
|
|
180
|
+
load_dotenv()
|
|
181
|
+
|
|
182
|
+
# Preflight check for core Grid variables
|
|
183
|
+
required_grid_env = ["GRID_RPC_URL", "GRID_OWNER_KEY"]
|
|
184
|
+
missing = [e for e in required_grid_env if e not in os.environ]
|
|
185
|
+
if missing:
|
|
186
|
+
raise EnvironmentError(f"Missing core Grid environment variables: {', '.join(missing)}")
|
|
187
|
+
|
|
188
|
+
if not os.path.exists(manifest_path):
|
|
189
|
+
raise FileNotFoundError(f"Grid manifest not found at {manifest_path}. Run `grid deploy` first.")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
with open(manifest_path) as fh:
|
|
193
|
+
manifest = GridManifest.from_dict(json.load(fh))
|
|
194
|
+
except json.JSONDecodeError as e:
|
|
195
|
+
raise RuntimeError(f"Failed to parse Grid manifest at {manifest_path}: {str(e)}")
|
|
196
|
+
|
|
197
|
+
signer = WalletSigner.from_env()
|
|
198
|
+
|
|
199
|
+
# FIX: Universal Provider Selection
|
|
200
|
+
provider_name = os.environ.get("GRID_PROVIDER", "").lower()
|
|
201
|
+
|
|
202
|
+
if provider_name == "anthropic" or "ANTHROPIC_API_KEY" in os.environ:
|
|
203
|
+
provider = AnthropicProvider(
|
|
204
|
+
api_key=os.environ["ANTHROPIC_API_KEY"],
|
|
205
|
+
model=os.environ.get("GRID_MODEL", "claude-3-5-sonnet-20240620")
|
|
206
|
+
)
|
|
207
|
+
elif provider_name == "openai" or "OPENAI_API_KEY" in os.environ:
|
|
208
|
+
provider = OpenAIProvider(
|
|
209
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
210
|
+
model=os.environ.get("GRID_MODEL", "gpt-4-turbo")
|
|
211
|
+
)
|
|
212
|
+
elif provider_name == "local" or "GRID_LOCAL_MODEL_URL" in os.environ:
|
|
213
|
+
provider = OpenAICompatibleProvider(
|
|
214
|
+
base_url=os.environ["GRID_LOCAL_MODEL_URL"],
|
|
215
|
+
model=os.environ.get("GRID_MODEL", "local-model")
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
raise EnvironmentError(
|
|
219
|
+
"No LLM provider configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GRID_LOCAL_MODEL_URL."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return cls(
|
|
223
|
+
provider=provider,
|
|
224
|
+
gateway_url=manifest.gateway_url,
|
|
225
|
+
manifest=manifest,
|
|
226
|
+
signer=signer,
|
|
227
|
+
rpc_url=os.environ["GRID_RPC_URL"],
|
|
228
|
+
contract_address=os.environ.get("GRID_CONTRACT_ADDRESS", manifest.contract_address),
|
|
229
|
+
usdc_address=os.environ.get("GRID_USDC_ADDRESS", USDC_BASE_SEPOLIA),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@asynccontextmanager
|
|
233
|
+
async def session(self, allowance_usdc: float, ttl_seconds: int = 3600) -> AsyncIterator[SessionInfo]:
|
|
234
|
+
"""
|
|
235
|
+
Context manager for an on-chain Grid session.
|
|
236
|
+
Ensures the session is closed even if the agent crashes.
|
|
237
|
+
"""
|
|
238
|
+
sess = await self._session_mgr.open(allowance_usdc, ttl_seconds)
|
|
239
|
+
try:
|
|
240
|
+
yield sess
|
|
241
|
+
finally:
|
|
242
|
+
try:
|
|
243
|
+
await self._session_mgr.close(sess.session_id)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(f"Failed to close session {sess.session_id}: {str(e)}")
|
|
246
|
+
|
|
247
|
+
async def run(
|
|
248
|
+
self,
|
|
249
|
+
prompt: str,
|
|
250
|
+
session: SessionInfo,
|
|
251
|
+
system: str | None = None,
|
|
252
|
+
) -> str:
|
|
253
|
+
# Preflight expiry check
|
|
254
|
+
if session.expiry and time.time() > session.expiry:
|
|
255
|
+
raise RuntimeError(f"Session {session.session_id!r} expired at {session.expiry}.")
|
|
256
|
+
|
|
257
|
+
tools = self._build_tool_schemas()
|
|
258
|
+
system = system or (
|
|
259
|
+
"You are an autonomous agent with access to paid API tools via the Grid protocol. "
|
|
260
|
+
"Use tools as needed. Tools are metered — prefer fewer calls to conserve budget."
|
|
261
|
+
)
|
|
262
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
|
|
263
|
+
|
|
264
|
+
while True:
|
|
265
|
+
response = self._provider.create_message(messages, tools, system)
|
|
266
|
+
assistant_blocks, stop_reason = self._provider.parse_response(response)
|
|
267
|
+
|
|
268
|
+
messages.append({"role": "assistant", "content": assistant_blocks})
|
|
269
|
+
|
|
270
|
+
if stop_reason != "tool_use":
|
|
271
|
+
return " ".join(
|
|
272
|
+
b["text"] for b in assistant_blocks if b["type"] == "text"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
tool_blocks = [b for b in assistant_blocks if b["type"] == "tool_use"]
|
|
276
|
+
|
|
277
|
+
# return_exceptions=True to prevent one tool crash from killing the entire run
|
|
278
|
+
results_raw = await asyncio.gather(
|
|
279
|
+
*[self._dispatch_tool(b, session) for b in tool_blocks],
|
|
280
|
+
return_exceptions=True,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Build tool_result turn
|
|
284
|
+
result_content: list[dict[str, Any]] = []
|
|
285
|
+
for i, tr in enumerate(results_raw):
|
|
286
|
+
# Handle exceptions from gather
|
|
287
|
+
if isinstance(tr, Exception):
|
|
288
|
+
logger.error(f"Tool execution failed: {str(tr)}")
|
|
289
|
+
result_content.append({
|
|
290
|
+
"type": "tool_result",
|
|
291
|
+
"tool_use_id": tool_blocks[i]["id"],
|
|
292
|
+
"content": f"Internal Error: {str(tr)}",
|
|
293
|
+
"is_error": True,
|
|
294
|
+
})
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
session.spent_micro += tr.cost_usdc_micro
|
|
298
|
+
result_content.append({
|
|
299
|
+
"type": "tool_result",
|
|
300
|
+
"tool_use_id": tr.call_id,
|
|
301
|
+
"content": (
|
|
302
|
+
json.dumps(tr.content)
|
|
303
|
+
if not isinstance(tr.content, str)
|
|
304
|
+
else tr.content
|
|
305
|
+
) if tr.error is None else f"Error: {tr.error}",
|
|
306
|
+
"is_error": tr.error is not None,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
messages.append({"role": "user", "content": result_content})
|
|
310
|
+
|
|
311
|
+
# ── Tool dispatch with 402 retry ──────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
async def _dispatch_tool(
|
|
314
|
+
self,
|
|
315
|
+
block: Any,
|
|
316
|
+
session: SessionInfo,
|
|
317
|
+
) -> ToolCallResult:
|
|
318
|
+
call_id = block.id
|
|
319
|
+
tool_name = block.name
|
|
320
|
+
tool_input = block.input
|
|
321
|
+
|
|
322
|
+
# Use the hot session key to sign the specific tool call authorisation
|
|
323
|
+
agent_sig = self._signer.sign_mcp_call(
|
|
324
|
+
tool=tool_name,
|
|
325
|
+
session_id=session.session_id,
|
|
326
|
+
input_data=tool_input
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
for attempt in range(self._max_retries + 1):
|
|
330
|
+
try:
|
|
331
|
+
resp = await self._mcp.call(
|
|
332
|
+
tool=tool_name,
|
|
333
|
+
input=tool_input,
|
|
334
|
+
session_id=session.session_id,
|
|
335
|
+
agent_sig=agent_sig,
|
|
336
|
+
)
|
|
337
|
+
except httpx.RequestError as e:
|
|
338
|
+
return ToolCallResult(
|
|
339
|
+
call_id=call_id,
|
|
340
|
+
content=None,
|
|
341
|
+
error=f"Network error: {str(e)}",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if resp.status_code in (200, 201):
|
|
345
|
+
try:
|
|
346
|
+
body = resp.json()
|
|
347
|
+
return ToolCallResult(
|
|
348
|
+
call_id=call_id,
|
|
349
|
+
content=body.get("content", body),
|
|
350
|
+
cost_usdc_micro=body.get("usage", {}).get("cost_usdc_micro", 0),
|
|
351
|
+
)
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
return ToolCallResult(
|
|
354
|
+
call_id=call_id,
|
|
355
|
+
content=None,
|
|
356
|
+
error="Gateway returned invalid JSON",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if resp.status_code == 402 and attempt < self._max_retries:
|
|
360
|
+
try:
|
|
361
|
+
body_402 = resp.json()
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
return ToolCallResult(
|
|
364
|
+
call_id=call_id,
|
|
365
|
+
content=None,
|
|
366
|
+
error="Gateway returned invalid JSON on 402",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# This will raise RuntimeError if allowance is insufficient, which is expected
|
|
370
|
+
attestation = self._build_attestation(body_402, session)
|
|
371
|
+
payment_hdr = self._payment_header(attestation)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
paid_resp = await self._mcp.call(
|
|
375
|
+
tool=tool_name,
|
|
376
|
+
input=tool_input,
|
|
377
|
+
session_id=session.session_id,
|
|
378
|
+
agent_sig=agent_sig,
|
|
379
|
+
payment_header=payment_hdr,
|
|
380
|
+
)
|
|
381
|
+
except httpx.RequestError as e:
|
|
382
|
+
return ToolCallResult(
|
|
383
|
+
call_id=call_id,
|
|
384
|
+
content=None,
|
|
385
|
+
error=f"Network error during payment retry: {str(e)}",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if paid_resp.status_code in (200, 201):
|
|
389
|
+
try:
|
|
390
|
+
body = paid_resp.json()
|
|
391
|
+
# Prefer response cost, fall back to attested amount
|
|
392
|
+
return ToolCallResult(
|
|
393
|
+
call_id=call_id,
|
|
394
|
+
content=body.get("content", body),
|
|
395
|
+
cost_usdc_micro=body.get("usage", {}).get(
|
|
396
|
+
"cost_usdc_micro", attestation.amount
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
except json.JSONDecodeError:
|
|
400
|
+
return ToolCallResult(
|
|
401
|
+
call_id=call_id,
|
|
402
|
+
content=None,
|
|
403
|
+
error="Gateway returned invalid JSON after payment",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Vendor rejected even after payment — surface as error
|
|
407
|
+
return ToolCallResult(
|
|
408
|
+
call_id=call_id,
|
|
409
|
+
content=None,
|
|
410
|
+
error=f"Vendor rejected paid request: HTTP {paid_resp.status_code}",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Non-402 error or exhausted retries
|
|
414
|
+
return ToolCallResult(
|
|
415
|
+
call_id=call_id,
|
|
416
|
+
content=None,
|
|
417
|
+
error=f"HTTP {resp.status_code}: {resp.text[:200]}",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return ToolCallResult(call_id=call_id, content=None, error="Max retries exceeded")
|
|
421
|
+
|
|
422
|
+
def _build_attestation(
|
|
423
|
+
self,
|
|
424
|
+
body_402: dict[str, Any],
|
|
425
|
+
session: SessionInfo,
|
|
426
|
+
) -> X402Attestation:
|
|
427
|
+
vendor = body_402.get("vendor") or self._manifest.contract_address
|
|
428
|
+
if not vendor:
|
|
429
|
+
raise RuntimeError("Vendor missing in 402 response and no contract address in manifest")
|
|
430
|
+
|
|
431
|
+
# Typed guard for amount
|
|
432
|
+
raw_amount = body_402.get("amount", 0)
|
|
433
|
+
try:
|
|
434
|
+
amount = int(raw_amount)
|
|
435
|
+
except (TypeError, ValueError):
|
|
436
|
+
raise RuntimeError(f"Invalid amount in 402 response: {raw_amount!r}")
|
|
437
|
+
|
|
438
|
+
nonce = self._nonces.next(session.session_id)
|
|
439
|
+
|
|
440
|
+
if session.spent_micro + amount > session.allowance_micro:
|
|
441
|
+
raise RuntimeError(
|
|
442
|
+
f"Insufficient session allowance: need {amount} more micro-USDC "
|
|
443
|
+
f"but only {session.remaining_micro} remaining."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
sig = self._signer.sign_attestation(
|
|
447
|
+
session_id=session.session_id,
|
|
448
|
+
vendor=vendor,
|
|
449
|
+
amount=amount,
|
|
450
|
+
nonce=nonce,
|
|
451
|
+
contract_address=self._manifest.contract_address,
|
|
452
|
+
)
|
|
453
|
+
return X402Attestation(
|
|
454
|
+
session_id=session.session_id,
|
|
455
|
+
vendor=vendor,
|
|
456
|
+
amount=amount,
|
|
457
|
+
nonce=nonce,
|
|
458
|
+
signature=sig,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def _payment_header(self, att: X402Attestation) -> str:
|
|
462
|
+
return json.dumps({
|
|
463
|
+
"v": 1,
|
|
464
|
+
"sessionId": att.session_id,
|
|
465
|
+
"vendor": att.vendor,
|
|
466
|
+
"amount": att.amount,
|
|
467
|
+
"nonce": str(att.nonce),
|
|
468
|
+
"signature": att.signature,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
# ── Anthropic tool schema builder ─────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
def _build_tool_schemas(self) -> list[dict[str, Any]]:
|
|
474
|
+
schemas = []
|
|
475
|
+
for t in self._manifest.tools:
|
|
476
|
+
props: dict[str, Any] = {}
|
|
477
|
+
required: list[str] = []
|
|
478
|
+
|
|
479
|
+
for p in t.parameters:
|
|
480
|
+
props[p["name"]] = {
|
|
481
|
+
# Preserve parameter type instead of hardcoding "string"
|
|
482
|
+
"type": p.get("type", "string"),
|
|
483
|
+
"description": p.get("description", ""),
|
|
484
|
+
}
|
|
485
|
+
if p.get("required"):
|
|
486
|
+
required.append(p["name"])
|
|
487
|
+
|
|
488
|
+
if t.has_request_body:
|
|
489
|
+
props["body"] = {"type": "object", "description": "JSON request body"}
|
|
490
|
+
|
|
491
|
+
schemas.append({
|
|
492
|
+
"name": t.operation_id,
|
|
493
|
+
"description": (
|
|
494
|
+
f"{t.method} {t.path} — "
|
|
495
|
+
f"costs ${t.pricing_amount / 1_000_000:.4f} USDC/{t.pricing_model}"
|
|
496
|
+
),
|
|
497
|
+
"input_schema": {
|
|
498
|
+
"type": "object",
|
|
499
|
+
"properties": props,
|
|
500
|
+
"required": required,
|
|
501
|
+
},
|
|
502
|
+
})
|
|
503
|
+
return schemas
|
|
504
|
+
|
|
505
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
async def aclose(self) -> None:
|
|
508
|
+
await self._http.aclose()
|
|
509
|
+
|
|
510
|
+
async def __aenter__(self) -> "GridAgent":
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
514
|
+
await self.aclose()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DiscoveryManager — Autonomous on-chain tool discovery via CapabilityRegistry.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, List, Optional
|
|
7
|
+
from web3 import Web3
|
|
8
|
+
from web3.contract import Contract
|
|
9
|
+
|
|
10
|
+
class DiscoveryManager:
|
|
11
|
+
"""
|
|
12
|
+
Enables agents to autonomously find and resolve Grid tools using the
|
|
13
|
+
on-chain CapabilityRegistry.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, rpc_url: str, registry_address: str) -> None:
|
|
17
|
+
self._w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
18
|
+
self._registry_abi = [
|
|
19
|
+
{
|
|
20
|
+
"name": "listingCount",
|
|
21
|
+
"type": "function",
|
|
22
|
+
"stateMutability": "view",
|
|
23
|
+
"inputs": [],
|
|
24
|
+
"outputs": [{"name": "", "type": "uint256"}],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "listings",
|
|
28
|
+
"type": "function",
|
|
29
|
+
"stateMutability": "view",
|
|
30
|
+
"inputs": [{"name": "", "type": "uint256"}],
|
|
31
|
+
"outputs": [
|
|
32
|
+
{"name": "vendor", "type": "address"},
|
|
33
|
+
{"name": "toolId", "type": "string"},
|
|
34
|
+
{"name": "manifestCid", "type": "string"},
|
|
35
|
+
{"name": "gatewayUrl", "type": "string"},
|
|
36
|
+
{"name": "name", "type": "string"},
|
|
37
|
+
{"name": "description", "type": "string"},
|
|
38
|
+
{"name": "tags", "type": "string[]"},
|
|
39
|
+
{"name": "version", "type": "uint16"},
|
|
40
|
+
{"name": "registeredAt", "type": "uint64"},
|
|
41
|
+
{"name": "updatedAt", "type": "uint64"},
|
|
42
|
+
{"name": "active", "type": "bool"},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
self._registry: Contract = self._w3.eth.contract(
|
|
47
|
+
address=Web3.to_checksum_address(registry_address),
|
|
48
|
+
abi=self._registry_abi,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def list_all_tools(self, filter_tag: Optional[str] = None) -> List[dict[str, Any]]:
|
|
52
|
+
"""
|
|
53
|
+
Scan the registry for all active tools.
|
|
54
|
+
In production, this would ideally use a subgraph or an off-chain indexer
|
|
55
|
+
for performance, but we use direct RPC calls here for the scaffold.
|
|
56
|
+
"""
|
|
57
|
+
count = self._registry.functions.listingCount().call()
|
|
58
|
+
tools = []
|
|
59
|
+
|
|
60
|
+
for i in range(1, count + 1):
|
|
61
|
+
l = self._registry.functions.listings(i).call()
|
|
62
|
+
# Tuple indices based on Listing struct:
|
|
63
|
+
# 0:vendor, 1:toolId, 2:manifestCid, 3:gatewayUrl, 4:name, 5:description, 6:tags, 10:active
|
|
64
|
+
if not l[10]: # active
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
tags = l[6]
|
|
68
|
+
if filter_tag and filter_tag not in tags:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
tools.append({
|
|
72
|
+
"listing_id": i,
|
|
73
|
+
"vendor": l[0],
|
|
74
|
+
"tool_id": l[1],
|
|
75
|
+
"manifest_cid": l[2],
|
|
76
|
+
"gateway_url": l[3],
|
|
77
|
+
"name": l[4],
|
|
78
|
+
"description": l[5],
|
|
79
|
+
"tags": tags,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return tools
|
|
83
|
+
|
|
84
|
+
def resolve_tool(self, tool_id: str) -> Optional[dict[str, Any]]:
|
|
85
|
+
"""
|
|
86
|
+
Find the latest manifest for a specific tool ID.
|
|
87
|
+
"""
|
|
88
|
+
tools = self.list_all_tools()
|
|
89
|
+
for t in tools:
|
|
90
|
+
if t["tool_id"] == tool_id:
|
|
91
|
+
return t
|
|
92
|
+
return None
|