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.
@@ -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