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 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()
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ grid_agent