grid-agent-sdk 1.0.0__py3-none-any.whl
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/__init__.py +0 -0
- grid_agent/agent.py +514 -0
- grid_agent/discovery.py +92 -0
- grid_agent/providers.py +156 -0
- grid_agent/session.py +268 -0
- grid_agent/types.py +83 -0
- grid_agent/wallet.py +152 -0
- grid_agent_sdk-1.0.0.dist-info/METADATA +13 -0
- grid_agent_sdk-1.0.0.dist-info/RECORD +11 -0
- grid_agent_sdk-1.0.0.dist-info/WHEEL +5 -0
- grid_agent_sdk-1.0.0.dist-info/top_level.txt +1 -0
grid_agent/__init__.py
ADDED
|
File without changes
|
grid_agent/agent.py
ADDED
|
@@ -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()
|
grid_agent/discovery.py
ADDED
|
@@ -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
|
grid_agent/providers.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, List, Dict
|
|
3
|
+
|
|
4
|
+
class LLMProvider(ABC):
|
|
5
|
+
"""Abstract base class for LLM providers (Anthropic, OpenAI, etc.)"""
|
|
6
|
+
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def create_message(
|
|
9
|
+
self,
|
|
10
|
+
messages: List[Dict[str, Any]],
|
|
11
|
+
tools: List[Dict[str, Any]],
|
|
12
|
+
system: str
|
|
13
|
+
) -> Any:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def parse_response(self, response: Any) -> tuple[List[Dict[str, Any]], str | None]:
|
|
18
|
+
"""Returns (new_messages, stop_reason)"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class AnthropicProvider(LLMProvider):
|
|
22
|
+
def __init__(self, api_key: str, model: str):
|
|
23
|
+
import anthropic
|
|
24
|
+
self.client = anthropic.Anthropic(api_key=api_key)
|
|
25
|
+
self.model = model
|
|
26
|
+
|
|
27
|
+
def create_message(self, messages, tools, system):
|
|
28
|
+
return self.client.messages.create(
|
|
29
|
+
model=self.model,
|
|
30
|
+
max_tokens=4096,
|
|
31
|
+
system=system,
|
|
32
|
+
tools=tools,
|
|
33
|
+
messages=messages,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def parse_response(self, response):
|
|
37
|
+
blocks = []
|
|
38
|
+
for block in response.content:
|
|
39
|
+
if block.type == "text":
|
|
40
|
+
blocks.append({"type": "text", "text": block.text})
|
|
41
|
+
elif block.type == "tool_use":
|
|
42
|
+
blocks.append({
|
|
43
|
+
"type": "tool_use",
|
|
44
|
+
"id": block.id,
|
|
45
|
+
"name": block.name,
|
|
46
|
+
"input": block.input,
|
|
47
|
+
})
|
|
48
|
+
return blocks, response.stop_reason
|
|
49
|
+
|
|
50
|
+
class OpenAIProvider(LLMProvider):
|
|
51
|
+
def __init__(self, api_key: str, model: str = "gpt-4-turbo"):
|
|
52
|
+
from openai import OpenAI
|
|
53
|
+
self.client = OpenAI(api_key=api_key)
|
|
54
|
+
self.model = model
|
|
55
|
+
|
|
56
|
+
def create_message(self, messages, tools, system):
|
|
57
|
+
# Transform messages to OpenAI format
|
|
58
|
+
openai_msgs = [{"role": "system", "content": system}]
|
|
59
|
+
|
|
60
|
+
for m in messages:
|
|
61
|
+
role = m["role"]
|
|
62
|
+
content = m["content"]
|
|
63
|
+
|
|
64
|
+
if role == "user" and isinstance(content, list):
|
|
65
|
+
# Check for tool_result blocks
|
|
66
|
+
tool_results = [b for b in content if b["type"] == "tool_result"]
|
|
67
|
+
text_blocks = [b for b in content if b["type"] == "text"]
|
|
68
|
+
|
|
69
|
+
# Add text content if present
|
|
70
|
+
if text_blocks:
|
|
71
|
+
openai_msgs.append({
|
|
72
|
+
"role": "user",
|
|
73
|
+
"content": " ".join(b["text"] for b in text_blocks)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
# Add each tool result as a separate 'tool' role message
|
|
77
|
+
for tr in tool_results:
|
|
78
|
+
openai_msgs.append({
|
|
79
|
+
"role": "tool",
|
|
80
|
+
"tool_call_id": tr["tool_use_id"],
|
|
81
|
+
"content": tr["content"]
|
|
82
|
+
})
|
|
83
|
+
elif role == "assistant" and isinstance(content, list):
|
|
84
|
+
# Transform assistant tool_use blocks to tool_calls
|
|
85
|
+
tool_calls = []
|
|
86
|
+
text = ""
|
|
87
|
+
for b in content:
|
|
88
|
+
if b["type"] == "text":
|
|
89
|
+
text += b["text"]
|
|
90
|
+
elif b["type"] == "tool_use":
|
|
91
|
+
import json
|
|
92
|
+
tool_calls.append({
|
|
93
|
+
"id": b["id"],
|
|
94
|
+
"type": "function",
|
|
95
|
+
"function": {
|
|
96
|
+
"name": b["name"],
|
|
97
|
+
"arguments": json.dumps(b["input"])
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
msg_obj = {"role": "assistant", "content": text if text else None}
|
|
102
|
+
if tool_calls:
|
|
103
|
+
msg_obj["tool_calls"] = tool_calls
|
|
104
|
+
openai_msgs.append(msg_obj)
|
|
105
|
+
else:
|
|
106
|
+
openai_msgs.append({"role": role, "content": content})
|
|
107
|
+
|
|
108
|
+
# Simplified tool schema conversion (OpenAI format)
|
|
109
|
+
openai_tools = []
|
|
110
|
+
for t in tools:
|
|
111
|
+
openai_tools.append({
|
|
112
|
+
"type": "function",
|
|
113
|
+
"function": {
|
|
114
|
+
"name": t["name"],
|
|
115
|
+
"description": t["description"],
|
|
116
|
+
"parameters": t["input_schema"]
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return self.client.chat.completions.create(
|
|
121
|
+
model=self.model,
|
|
122
|
+
messages=openai_msgs,
|
|
123
|
+
tools=openai_tools if openai_tools else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def parse_response(self, response):
|
|
127
|
+
choice = response.choices[0]
|
|
128
|
+
msg = choice.message
|
|
129
|
+
blocks = []
|
|
130
|
+
|
|
131
|
+
if msg.content:
|
|
132
|
+
blocks.append({"type": "text", "text": msg.content})
|
|
133
|
+
|
|
134
|
+
stop_reason = None
|
|
135
|
+
if choice.finish_reason == "tool_calls":
|
|
136
|
+
stop_reason = "tool_use"
|
|
137
|
+
for tc in msg.tool_calls:
|
|
138
|
+
import json
|
|
139
|
+
blocks.append({
|
|
140
|
+
"type": "tool_use",
|
|
141
|
+
"id": tc.id,
|
|
142
|
+
"name": tc.function.name,
|
|
143
|
+
"input": json.loads(tc.function.arguments),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return blocks, stop_reason
|
|
147
|
+
|
|
148
|
+
class OpenAICompatibleProvider(OpenAIProvider):
|
|
149
|
+
"""
|
|
150
|
+
Support for local or open-source models (Ollama, vLLM, LiteLLM)
|
|
151
|
+
using the OpenAI-compatible chat completions API.
|
|
152
|
+
"""
|
|
153
|
+
def __init__(self, base_url: str, api_key: str = "sk-no-key-needed", model: str = "local-model"):
|
|
154
|
+
from openai import OpenAI
|
|
155
|
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
|
156
|
+
self.model = model
|
grid_agent/session.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SessionManager — opens and tracks GridEscrow v2 sessions on L2 Base.
|
|
3
|
+
|
|
4
|
+
v2 changes:
|
|
5
|
+
- openSession() takes a 4th `sandbox` bool parameter
|
|
6
|
+
- Constructor takes feeRecipient + feeBps (no SDK impact, just ABI update)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from web3 import Web3
|
|
14
|
+
from web3.contract import Contract
|
|
15
|
+
|
|
16
|
+
from .types import SessionInfo
|
|
17
|
+
from .wallet import WalletSigner
|
|
18
|
+
|
|
19
|
+
_ESCROW_ABI: list[dict[str, Any]] = [
|
|
20
|
+
{
|
|
21
|
+
"name": "deposit",
|
|
22
|
+
"type": "function",
|
|
23
|
+
"stateMutability": "nonpayable",
|
|
24
|
+
"inputs": [{"name": "amount", "type": "uint96"}],
|
|
25
|
+
"outputs": [],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "openSession",
|
|
29
|
+
"type": "function",
|
|
30
|
+
"stateMutability": "nonpayable",
|
|
31
|
+
"inputs": [
|
|
32
|
+
{"name": "sessionKey", "type": "address"},
|
|
33
|
+
{"name": "allowance", "type": "uint96"},
|
|
34
|
+
{"name": "ttl", "type": "uint40"},
|
|
35
|
+
{"name": "sandbox", "type": "bool"}, # v2: test mode flag
|
|
36
|
+
],
|
|
37
|
+
"outputs": [{"name": "sessionId", "type": "bytes32"}],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "sessions",
|
|
41
|
+
"type": "function",
|
|
42
|
+
"stateMutability": "view",
|
|
43
|
+
"inputs": [{"name": "", "type": "bytes32"}],
|
|
44
|
+
"outputs": [
|
|
45
|
+
{"name": "agent", "type": "address"},
|
|
46
|
+
{"name": "sessionKey", "type": "address"},
|
|
47
|
+
{"name": "allowance", "type": "uint96"},
|
|
48
|
+
{"name": "spent", "type": "uint96"},
|
|
49
|
+
{"name": "expiry", "type": "uint40"},
|
|
50
|
+
{"name": "active", "type": "bool"},
|
|
51
|
+
{"name": "sandbox", "type": "bool"},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "closeSession",
|
|
56
|
+
"type": "function",
|
|
57
|
+
"stateMutability": "nonpayable",
|
|
58
|
+
"inputs": [{"name": "sessionId", "type": "bytes32"}],
|
|
59
|
+
"outputs": [],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "balances",
|
|
63
|
+
"type": "function",
|
|
64
|
+
"stateMutability": "view",
|
|
65
|
+
"inputs": [{"name": "", "type": "address"}],
|
|
66
|
+
"outputs": [{"name": "", "type": "uint96"}],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "vendorReputation",
|
|
70
|
+
"type": "function",
|
|
71
|
+
"stateMutability": "view",
|
|
72
|
+
"inputs": [{"name": "vendor", "type": "address"}],
|
|
73
|
+
"outputs": [{"name": "totalVolume", "type": "uint256"}],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "feeBps",
|
|
77
|
+
"type": "function",
|
|
78
|
+
"stateMutability": "view",
|
|
79
|
+
"inputs": [],
|
|
80
|
+
"outputs": [{"name": "", "type": "uint16"}],
|
|
81
|
+
},
|
|
82
|
+
# Events (for log parsing)
|
|
83
|
+
{
|
|
84
|
+
"name": "SessionOpened",
|
|
85
|
+
"type": "event",
|
|
86
|
+
"inputs": [
|
|
87
|
+
{"name": "sessionId", "type": "bytes32", "indexed": True},
|
|
88
|
+
{"name": "agent", "type": "address", "indexed": True},
|
|
89
|
+
{"name": "sessionKey", "type": "address", "indexed": False},
|
|
90
|
+
{"name": "expiry", "type": "uint40", "indexed": False},
|
|
91
|
+
{"name": "sandbox", "type": "bool", "indexed": False},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
_ERC20_ABI: list[dict[str, Any]] = [
|
|
97
|
+
{
|
|
98
|
+
"name": "approve",
|
|
99
|
+
"type": "function",
|
|
100
|
+
"stateMutability": "nonpayable",
|
|
101
|
+
"inputs": [
|
|
102
|
+
{"name": "spender", "type": "address"},
|
|
103
|
+
{"name": "amount", "type": "uint256"},
|
|
104
|
+
],
|
|
105
|
+
"outputs": [{"name": "", "type": "bool"}],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "allowance",
|
|
109
|
+
"type": "function",
|
|
110
|
+
"stateMutability": "view",
|
|
111
|
+
"inputs": [
|
|
112
|
+
{"name": "owner", "type": "address"},
|
|
113
|
+
{"name": "spender", "type": "address"},
|
|
114
|
+
],
|
|
115
|
+
"outputs": [{"name": "", "type": "uint256"}],
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
120
|
+
USDC_BASE_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SessionManager:
|
|
124
|
+
"""
|
|
125
|
+
Manages the full on-chain lifecycle of a GridEscrow v2 session.
|
|
126
|
+
|
|
127
|
+
Usage (sync — wrap in asyncio.to_thread() from async code):
|
|
128
|
+
mgr = SessionManager(rpc_url, contract_address, signer)
|
|
129
|
+
sess = mgr.open(allowance_usdc=5.0, ttl_seconds=3600)
|
|
130
|
+
# ... tool calls ...
|
|
131
|
+
mgr.close(sess.session_id)
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
rpc_url: str,
|
|
137
|
+
contract_address: str,
|
|
138
|
+
signer: WalletSigner,
|
|
139
|
+
usdc_address: str = USDC_BASE_SEPOLIA,
|
|
140
|
+
sandbox: bool = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
143
|
+
self._contract: Contract = self._w3.eth.contract(
|
|
144
|
+
address=Web3.to_checksum_address(contract_address),
|
|
145
|
+
abi=_ESCROW_ABI,
|
|
146
|
+
)
|
|
147
|
+
self._usdc: Contract = self._w3.eth.contract(
|
|
148
|
+
address=Web3.to_checksum_address(usdc_address),
|
|
149
|
+
abi=_ERC20_ABI,
|
|
150
|
+
)
|
|
151
|
+
self._signer = signer
|
|
152
|
+
self._contract_address = contract_address
|
|
153
|
+
self._sandbox = sandbox
|
|
154
|
+
|
|
155
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def open(self, allowance_usdc: float, ttl_seconds: int = 3600) -> SessionInfo:
|
|
158
|
+
"""
|
|
159
|
+
Full session open flow:
|
|
160
|
+
1. USDC approve (if needed)
|
|
161
|
+
2. deposit()
|
|
162
|
+
3. generate fresh session key
|
|
163
|
+
4. openSession() → sessionId bytes32
|
|
164
|
+
"""
|
|
165
|
+
allowance_micro = int(allowance_usdc * 1_000_000)
|
|
166
|
+
owner = self._signer.owner_address
|
|
167
|
+
session_key_address = self._signer.generate_session_key()
|
|
168
|
+
|
|
169
|
+
if not self._sandbox:
|
|
170
|
+
current = self._usdc.functions.allowance(
|
|
171
|
+
owner, self._contract_address
|
|
172
|
+
).call()
|
|
173
|
+
if current < allowance_micro:
|
|
174
|
+
self._send_tx(
|
|
175
|
+
self._usdc.functions.approve(self._contract_address, allowance_micro)
|
|
176
|
+
)
|
|
177
|
+
self._send_tx(self._contract.functions.deposit(allowance_micro))
|
|
178
|
+
|
|
179
|
+
receipt = self._send_tx(
|
|
180
|
+
self._contract.functions.openSession(
|
|
181
|
+
Web3.to_checksum_address(session_key_address),
|
|
182
|
+
allowance_micro,
|
|
183
|
+
ttl_seconds,
|
|
184
|
+
self._sandbox,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
session_id = self._extract_session_id(receipt)
|
|
189
|
+
return SessionInfo(
|
|
190
|
+
session_id=session_id,
|
|
191
|
+
session_key_address=session_key_address,
|
|
192
|
+
allowance_micro=allowance_micro,
|
|
193
|
+
expiry=int(time.time()) + ttl_seconds,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def close(self, session_id: str) -> None:
|
|
197
|
+
session_id_bytes = bytes.fromhex(session_id.removeprefix("0x"))
|
|
198
|
+
self._send_tx(self._contract.functions.closeSession(session_id_bytes))
|
|
199
|
+
|
|
200
|
+
def sync(self, session_id: str) -> SessionInfo:
|
|
201
|
+
"""
|
|
202
|
+
Synchronize session state from the blockchain.
|
|
203
|
+
Useful for recovering after a crash or if local state is lost.
|
|
204
|
+
"""
|
|
205
|
+
session_id_bytes = bytes.fromhex(session_id.removeprefix("0x"))
|
|
206
|
+
data = self._contract.functions.sessions(session_id_bytes).call()
|
|
207
|
+
|
|
208
|
+
# data format matches the 'sessions' view function output
|
|
209
|
+
return SessionInfo(
|
|
210
|
+
session_id=session_id,
|
|
211
|
+
session_key_address=data[1],
|
|
212
|
+
allowance_micro=data[2],
|
|
213
|
+
spent_micro=data[3],
|
|
214
|
+
expiry=data[4],
|
|
215
|
+
active=data[5],
|
|
216
|
+
sandbox=data[6],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def on_chain_balance(self) -> int:
|
|
220
|
+
return self._contract.functions.balances(self._signer.owner_address).call()
|
|
221
|
+
|
|
222
|
+
def vendor_reputation(self, vendor_address: str) -> int:
|
|
223
|
+
"""Returns cumulative net USDC (micro-units) settled to this vendor."""
|
|
224
|
+
return self._contract.functions.vendorReputation(
|
|
225
|
+
Web3.to_checksum_address(vendor_address)
|
|
226
|
+
).call()
|
|
227
|
+
|
|
228
|
+
def protocol_fee_bps(self) -> int:
|
|
229
|
+
return self._contract.functions.feeBps().call()
|
|
230
|
+
|
|
231
|
+
# ── Internals ──────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def _send_tx(self, fn: Any) -> Any:
|
|
234
|
+
owner = self._signer.owner_address
|
|
235
|
+
nonce = self._w3.eth.get_transaction_count(owner)
|
|
236
|
+
|
|
237
|
+
# EIP-1559 Gas Estimation for Base L2
|
|
238
|
+
fee_history = self._w3.eth.fee_history(1, "latest", [25])
|
|
239
|
+
base_fee = fee_history["baseFeePerGas"][-1]
|
|
240
|
+
priority_fee = fee_history["reward"][0][0]
|
|
241
|
+
max_fee = (base_fee * 2) + priority_fee
|
|
242
|
+
|
|
243
|
+
tx = fn.build_transaction({
|
|
244
|
+
"from": owner,
|
|
245
|
+
"nonce": nonce,
|
|
246
|
+
"maxFeePerGas": max_fee,
|
|
247
|
+
"maxPriorityFeePerGas": priority_fee,
|
|
248
|
+
"chainId": self._w3.eth.chain_id,
|
|
249
|
+
})
|
|
250
|
+
tx["gas"] = self._w3.eth.estimate_gas(tx)
|
|
251
|
+
private_key = self._signer._owner.key.hex() # type: ignore[attr-defined]
|
|
252
|
+
signed = self._w3.eth.account.sign_transaction(tx, private_key=private_key)
|
|
253
|
+
tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
254
|
+
return self._w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
|
|
255
|
+
|
|
256
|
+
def _extract_session_id(self, receipt: Any) -> str:
|
|
257
|
+
for log in receipt.get("logs", []):
|
|
258
|
+
topics = log.get("topics", [])
|
|
259
|
+
if len(topics) >= 2:
|
|
260
|
+
raw = topics[1]
|
|
261
|
+
return "0x" + (raw.hex() if isinstance(raw, bytes) else raw)
|
|
262
|
+
# Deterministic fallback
|
|
263
|
+
block = self._w3.eth.get_block(receipt["blockNumber"])
|
|
264
|
+
raw_id = Web3.solidity_keccak(
|
|
265
|
+
["address", "address", "uint256"],
|
|
266
|
+
[self._signer.owner_address, self._signer.session_address, block["timestamp"]],
|
|
267
|
+
)
|
|
268
|
+
return "0x" + raw_id.hex()
|
grid_agent/types.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Shared types for the Grid Agent SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class GridTool:
|
|
9
|
+
id: str
|
|
10
|
+
operation_id: str
|
|
11
|
+
method: str
|
|
12
|
+
path: str
|
|
13
|
+
pricing_amount: int # USDC micro-units (6 dec)
|
|
14
|
+
pricing_currency: str
|
|
15
|
+
pricing_model: str
|
|
16
|
+
has_request_body: bool
|
|
17
|
+
parameters: list[dict[str, Any]] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GridManifest:
|
|
22
|
+
name: str
|
|
23
|
+
version: str
|
|
24
|
+
base_url: str
|
|
25
|
+
gateway_url: str
|
|
26
|
+
contract_address: str
|
|
27
|
+
tools: list[GridTool]
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, d: dict[str, Any]) -> "GridManifest":
|
|
31
|
+
tools = [
|
|
32
|
+
GridTool(
|
|
33
|
+
id=t["id"],
|
|
34
|
+
operation_id=t["operationId"],
|
|
35
|
+
method=t["method"],
|
|
36
|
+
path=t["path"],
|
|
37
|
+
pricing_amount=t["pricing"]["amount"],
|
|
38
|
+
pricing_currency=t["pricing"]["currency"],
|
|
39
|
+
pricing_model=t["pricing"]["model"],
|
|
40
|
+
has_request_body=t.get("hasRequestBody", False),
|
|
41
|
+
parameters=t.get("parameters", []),
|
|
42
|
+
)
|
|
43
|
+
for t in d.get("tools", [])
|
|
44
|
+
]
|
|
45
|
+
return cls(
|
|
46
|
+
name=d["name"],
|
|
47
|
+
version=d["version"],
|
|
48
|
+
base_url=d["baseUrl"],
|
|
49
|
+
gateway_url=d.get("gatewayUrl", ""),
|
|
50
|
+
contract_address=d.get("contractAddress", ""),
|
|
51
|
+
tools=tools,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SessionInfo:
|
|
57
|
+
session_id: str # bytes32 hex
|
|
58
|
+
session_key_address: str # hot wallet address
|
|
59
|
+
allowance_micro: int # USDC micro-units earmarked
|
|
60
|
+
spent_micro: int = 0
|
|
61
|
+
expiry: int = 0 # unix timestamp
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def remaining_micro(self) -> int:
|
|
65
|
+
return self.allowance_micro - self.spent_micro
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class X402Attestation:
|
|
70
|
+
"""Signed payment attestation — matches the EIP-712 schema in GridEscrow."""
|
|
71
|
+
session_id: str
|
|
72
|
+
vendor: str
|
|
73
|
+
amount: int # USDC micro-units
|
|
74
|
+
nonce: int
|
|
75
|
+
signature: str # hex
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ToolCallResult:
|
|
80
|
+
call_id: str # Unique block id for the tool call (used to match results to requests)
|
|
81
|
+
content: Any
|
|
82
|
+
cost_usdc_micro: int = 0
|
|
83
|
+
error: str | None = None
|
grid_agent/wallet.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WalletSigner — EIP-712 session key signing for x402 attestations.
|
|
3
|
+
|
|
4
|
+
The agent holds two keys:
|
|
5
|
+
- owner_key: The wallet that deposited USDC and opened the session on-chain.
|
|
6
|
+
- session_key: An ephemeral hot key that signs x402 attestations off-chain.
|
|
7
|
+
|
|
8
|
+
Only the session key is used at call time. The owner key is only needed
|
|
9
|
+
to call deposit() / openSession() on the contract.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from eth_account import Account
|
|
19
|
+
from eth_account.messages import encode_typed_data, encode_defunct
|
|
20
|
+
from eth_account.signers.local import LocalAccount
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# EIP-712 domain matches GridEscrow constructor exactly
|
|
24
|
+
_DOMAIN_TYPE = [
|
|
25
|
+
{"name": "name", "type": "string"},
|
|
26
|
+
{"name": "version", "type": "string"},
|
|
27
|
+
{"name": "chainId", "type": "uint256"},
|
|
28
|
+
{"name": "verifyingContract", "type": "address"},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_ATTESTATION_TYPE = [
|
|
32
|
+
{"name": "sessionId", "type": "bytes32"},
|
|
33
|
+
{"name": "vendor", "type": "address"},
|
|
34
|
+
{"name": "amount", "type": "uint96"},
|
|
35
|
+
{"name": "nonce", "type": "uint64"},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WalletSigner:
|
|
40
|
+
"""
|
|
41
|
+
Manages the agent's owner wallet and ephemeral session key.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
owner_private_key: str,
|
|
47
|
+
chain_id: int = 84532, # Base Sepolia default
|
|
48
|
+
session_private_key: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._owner: LocalAccount = Account.from_key(owner_private_key)
|
|
51
|
+
self._chain_id = chain_id
|
|
52
|
+
self._session: LocalAccount | None = (
|
|
53
|
+
Account.from_key(session_private_key) if session_private_key else None
|
|
54
|
+
)
|
|
55
|
+
self.__session_private_key = session_private_key
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_env(cls) -> "WalletSigner":
|
|
59
|
+
"""Construct from GRID_OWNER_KEY and optional GRID_SESSION_KEY env vars."""
|
|
60
|
+
owner_key = os.environ["GRID_OWNER_KEY"]
|
|
61
|
+
session_key = os.environ.get("GRID_SESSION_KEY")
|
|
62
|
+
chain_id = int(os.environ.get("GRID_CHAIN_ID", "84532"))
|
|
63
|
+
return cls(owner_key, chain_id, session_key)
|
|
64
|
+
|
|
65
|
+
# ── Session key ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def generate_session_key(self) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Generate a fresh ephemeral session key.
|
|
70
|
+
Returns the address to register on-chain via openSession().
|
|
71
|
+
"""
|
|
72
|
+
private_key = "0x" + secrets.token_hex(32)
|
|
73
|
+
self._session = Account.from_key(private_key)
|
|
74
|
+
self.__session_private_key = private_key
|
|
75
|
+
return self._session.address
|
|
76
|
+
|
|
77
|
+
def get_session_private_key(self) -> str | None:
|
|
78
|
+
"""Explicitly export the hot key. Handle with extreme care."""
|
|
79
|
+
return self.__session_private_key
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def owner_address(self) -> str:
|
|
83
|
+
return self._owner.address
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def session_address(self) -> str:
|
|
87
|
+
if not self._session:
|
|
88
|
+
raise RuntimeError("No session key. Call generate_session_key() first.")
|
|
89
|
+
return self._session.address
|
|
90
|
+
|
|
91
|
+
# ── Signing ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def sign_attestation(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
session_id: str,
|
|
97
|
+
vendor: str,
|
|
98
|
+
amount: int,
|
|
99
|
+
nonce: int,
|
|
100
|
+
contract_address: str,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Produce an EIP-712 signature over an x402 attestation.
|
|
104
|
+
Uses the session key — never the owner key.
|
|
105
|
+
|
|
106
|
+
Returns the 65-byte hex signature (r + s + v).
|
|
107
|
+
"""
|
|
108
|
+
if not self._session:
|
|
109
|
+
raise RuntimeError("No session key active.")
|
|
110
|
+
|
|
111
|
+
structured_data: dict[str, Any] = {
|
|
112
|
+
"domain": {
|
|
113
|
+
"name": "GridEscrow",
|
|
114
|
+
"version": "1",
|
|
115
|
+
"chainId": self._chain_id,
|
|
116
|
+
"verifyingContract": contract_address,
|
|
117
|
+
},
|
|
118
|
+
"types": {
|
|
119
|
+
"EIP712Domain": _DOMAIN_TYPE,
|
|
120
|
+
"X402Attestation": _ATTESTATION_TYPE,
|
|
121
|
+
},
|
|
122
|
+
"primaryType": "X402Attestation",
|
|
123
|
+
"message": {
|
|
124
|
+
"sessionId": bytes.fromhex(session_id.removeprefix("0x")),
|
|
125
|
+
"vendor": vendor,
|
|
126
|
+
"amount": amount,
|
|
127
|
+
"nonce": nonce,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
signable = encode_typed_data(full_message=structured_data)
|
|
132
|
+
signed = self._session.sign_message(signable)
|
|
133
|
+
return signed.signature.hex()
|
|
134
|
+
|
|
135
|
+
def sign_mcp_call(self, tool: str, session_id: str, input_data: dict[str, Any]) -> str:
|
|
136
|
+
"""
|
|
137
|
+
Sign the MCP tool call payload with the session key.
|
|
138
|
+
Used to prove to the Gateway that this specific call is authorised.
|
|
139
|
+
"""
|
|
140
|
+
if not self._session:
|
|
141
|
+
raise RuntimeError("No session key active.")
|
|
142
|
+
|
|
143
|
+
# Consistent JSON formatting is critical for signature matching
|
|
144
|
+
message = json.dumps({
|
|
145
|
+
"tool": tool,
|
|
146
|
+
"sessionId": session_id,
|
|
147
|
+
"input": input_data,
|
|
148
|
+
}, separators=(',', ':'), sort_keys=True)
|
|
149
|
+
|
|
150
|
+
signable = encode_defunct(text=message)
|
|
151
|
+
signed = self._session.sign_message(signable)
|
|
152
|
+
return "0x" + signed.signature.hex()
|
|
@@ -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"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
grid_agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
grid_agent/agent.py,sha256=4nJDXJichgQk-anoU2C8NfYazUD5H0-HLCI8s4hRcvA,19911
|
|
3
|
+
grid_agent/discovery.py,sha256=RH8ss_N6Hidzfs1t0ajo_poWa3cBEtjezJ-Uzc7rvc4,3431
|
|
4
|
+
grid_agent/providers.py,sha256=XOaaHW1fWcifJojPsc7JP0UHZ24pyhOcvv_mw1JTu6I,5607
|
|
5
|
+
grid_agent/session.py,sha256=36gZ4I879x0QsUfzNg3STqx-RLtwvqimSqbR7HLuqso,9677
|
|
6
|
+
grid_agent/types.py,sha256=7gEo5UA_3o65n07TQYTPB8pLDxMPICNHQkLFbqnE7mU,2293
|
|
7
|
+
grid_agent/wallet.py,sha256=XCR_UD-ZkRrLFHDjfyMkiHEJqlzJj2oVFsPv84GM86g,5388
|
|
8
|
+
grid_agent_sdk-1.0.0.dist-info/METADATA,sha256=SyWnzyEJtIMARuguQtKWSkM2-p8s5VzXDtgsX58woq8,342
|
|
9
|
+
grid_agent_sdk-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
grid_agent_sdk-1.0.0.dist-info/top_level.txt,sha256=huV_R8YO8bEKdPOOqQHGxCUyGvJlmj2Diwd5lf03D-g,11
|
|
11
|
+
grid_agent_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
grid_agent
|