agent0-sdk 0.2.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.
@@ -0,0 +1,860 @@
1
+ """
2
+ Agent class for managing individual agents.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import time
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+ from typing import TYPE_CHECKING
13
+ from .models import (
14
+ AgentId, Address, URI, Timestamp, IdemKey,
15
+ EndpointType, TrustModel, Endpoint, RegistrationFile
16
+ )
17
+ from .web3_client import Web3Client
18
+ from .endpoint_crawler import EndpointCrawler
19
+
20
+ if TYPE_CHECKING:
21
+ from .sdk import SDK
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class Agent:
27
+ """Represents an individual agent with its registration data."""
28
+
29
+ def __init__(self, sdk: "SDK", registration_file: RegistrationFile):
30
+ """Initialize agent with SDK and registration file."""
31
+ self.sdk = sdk
32
+ self.registration_file = registration_file
33
+ # Track which metadata has changed since last registration to avoid sending unchanged data
34
+ self._dirty_metadata = set()
35
+ self._last_registered_wallet = None
36
+ self._last_registered_ens = None
37
+ # Initialize endpoint crawler for fetching capabilities
38
+ self._endpoint_crawler = EndpointCrawler(timeout=5)
39
+
40
+ # Read-only properties for direct access
41
+ @property
42
+ def agentId(self) -> Optional[AgentId]:
43
+ """Get agent ID (read-only)."""
44
+ return self.registration_file.agentId
45
+
46
+ @property
47
+ def agentURI(self) -> Optional[URI]:
48
+ """Get agent URI (read-only)."""
49
+ return self.registration_file.agentURI
50
+
51
+ @property
52
+ def name(self) -> str:
53
+ """Get agent name (read-only)."""
54
+ return self.registration_file.name
55
+
56
+ @property
57
+ def description(self) -> str:
58
+ """Get agent description (read-only)."""
59
+ return self.registration_file.description
60
+
61
+ @property
62
+ def image(self) -> Optional[URI]:
63
+ """Get agent image URI (read-only)."""
64
+ return self.registration_file.image
65
+
66
+ @property
67
+ def active(self) -> bool:
68
+ """Get agent active status (read-only)."""
69
+ return self.registration_file.active
70
+
71
+ @property
72
+ def x402support(self) -> bool:
73
+ """Get agent x402 support status (read-only)."""
74
+ return self.registration_file.x402support
75
+
76
+ @property
77
+ def walletAddress(self) -> Optional[Address]:
78
+ """Get agent wallet address (read-only)."""
79
+ return self.registration_file.walletAddress
80
+
81
+ @property
82
+ def walletChainId(self) -> Optional[int]:
83
+ """Get agent wallet chain ID (read-only)."""
84
+ return self.registration_file.walletChainId
85
+
86
+ @property
87
+ def endpoints(self) -> List[Endpoint]:
88
+ """Get agent endpoints list (read-only - use setter methods to modify)."""
89
+ return self.registration_file.endpoints
90
+
91
+ @property
92
+ def trustModels(self) -> List[Union[TrustModel, str]]:
93
+ """Get agent trust models list (read-only - use setter methods to modify)."""
94
+ return self.registration_file.trustModels
95
+
96
+ @property
97
+ def metadata(self) -> Dict[str, Any]:
98
+ """Get agent metadata dict (read-only - use setter methods to modify)."""
99
+ return self.registration_file.metadata
100
+
101
+ @property
102
+ def updatedAt(self) -> Timestamp:
103
+ """Get last update timestamp (read-only)."""
104
+ return self.registration_file.updatedAt
105
+
106
+ @property
107
+ def owners(self) -> List[Address]:
108
+ """Get agent owners list (read-only)."""
109
+ return self.registration_file.owners
110
+
111
+ @property
112
+ def operators(self) -> List[Address]:
113
+ """Get agent operators list (read-only)."""
114
+ return self.registration_file.operators
115
+
116
+ # Derived endpoint properties (convenience)
117
+ @property
118
+ def mcpEndpoint(self) -> Optional[str]:
119
+ """Get MCP endpoint value (read-only)."""
120
+ for endpoint in self.registration_file.endpoints:
121
+ if endpoint.type == EndpointType.MCP:
122
+ return endpoint.value
123
+ return None
124
+
125
+ @property
126
+ def a2aEndpoint(self) -> Optional[str]:
127
+ """Get A2A endpoint value (read-only)."""
128
+ for endpoint in self.registration_file.endpoints:
129
+ if endpoint.type == EndpointType.A2A:
130
+ return endpoint.value
131
+ return None
132
+
133
+ @property
134
+ def ensEndpoint(self) -> Optional[str]:
135
+ """Get ENS endpoint value (read-only)."""
136
+ for endpoint in self.registration_file.endpoints:
137
+ if endpoint.type == EndpointType.ENS:
138
+ return endpoint.value
139
+ return None
140
+
141
+ @property
142
+ def mcpTools(self) -> Optional[List[str]]:
143
+ """Get MCP tools list (read-only)."""
144
+ for endpoint in self.registration_file.endpoints:
145
+ if endpoint.type == EndpointType.MCP:
146
+ return endpoint.meta.get('mcpTools')
147
+ return None
148
+
149
+ @property
150
+ def mcpPrompts(self) -> Optional[List[str]]:
151
+ """Get MCP prompts list (read-only)."""
152
+ for endpoint in self.registration_file.endpoints:
153
+ if endpoint.type == EndpointType.MCP:
154
+ return endpoint.meta.get('mcpPrompts')
155
+ return None
156
+
157
+ @property
158
+ def mcpResources(self) -> Optional[List[str]]:
159
+ """Get MCP resources list (read-only)."""
160
+ for endpoint in self.registration_file.endpoints:
161
+ if endpoint.type == EndpointType.MCP:
162
+ return endpoint.meta.get('mcpResources')
163
+ return None
164
+
165
+ @property
166
+ def a2aSkills(self) -> Optional[List[str]]:
167
+ """Get A2A skills list (read-only)."""
168
+ for endpoint in self.registration_file.endpoints:
169
+ if endpoint.type == EndpointType.A2A:
170
+ return endpoint.meta.get('a2aSkills')
171
+ return None
172
+
173
+ def registrationFile(self) -> RegistrationFile:
174
+ """Get the compiled registration file."""
175
+ return self.registration_file
176
+
177
+ def _collectMetadataForRegistration(self) -> List[Dict[str, Any]]:
178
+ """Collect all metadata entries for registration."""
179
+ metadata_entries = []
180
+
181
+ # Add wallet address metadata
182
+ if self.walletAddress:
183
+ addr_bytes = bytes.fromhex(self.walletAddress[2:]) # Remove '0x' prefix
184
+ metadata_entries.append({
185
+ "key": "agentWallet",
186
+ "value": addr_bytes
187
+ })
188
+
189
+ # Add ENS name metadata
190
+ if self.ensEndpoint:
191
+ name_bytes = self.ensEndpoint.encode('utf-8')
192
+ metadata_entries.append({
193
+ "key": "agentName",
194
+ "value": name_bytes
195
+ })
196
+
197
+ # Add custom metadata
198
+ for key, value in self.metadata.items():
199
+ if isinstance(value, str):
200
+ value_bytes = value.encode('utf-8')
201
+ elif isinstance(value, (int, float)):
202
+ value_bytes = str(value).encode('utf-8')
203
+ else:
204
+ value_bytes = str(value).encode('utf-8')
205
+
206
+ metadata_entries.append({
207
+ "key": key,
208
+ "value": value_bytes
209
+ })
210
+
211
+ return metadata_entries
212
+
213
+ # Endpoint management
214
+ def setMCP(self, endpoint: str, version: str = "2025-06-18", auto_fetch: bool = True) -> 'Agent':
215
+ """
216
+ Set MCP endpoint with version.
217
+
218
+ Args:
219
+ endpoint: MCP endpoint URL
220
+ version: MCP version
221
+ auto_fetch: If True, automatically fetch capabilities from the endpoint (default: True)
222
+ """
223
+ # Remove existing MCP endpoint if any
224
+ self.registration_file.endpoints = [
225
+ ep for ep in self.registration_file.endpoints
226
+ if ep.type != EndpointType.MCP
227
+ ]
228
+
229
+ # Try to fetch capabilities from the endpoint (soft fail)
230
+ meta = {"version": version}
231
+ if auto_fetch:
232
+ try:
233
+ capabilities = self._endpoint_crawler.fetch_mcp_capabilities(endpoint)
234
+ if capabilities:
235
+ meta.update(capabilities)
236
+ logger.debug(
237
+ f"Fetched MCP capabilities: {len(capabilities.get('mcpTools', []))} tools, "
238
+ f"{len(capabilities.get('mcpPrompts', []))} prompts, "
239
+ f"{len(capabilities.get('mcpResources', []))} resources"
240
+ )
241
+ except Exception as e:
242
+ # Soft fail - continue without capabilities
243
+ logger.debug(f"Could not fetch MCP capabilities (non-blocking): {e}")
244
+
245
+ # Add new MCP endpoint
246
+ mcp_endpoint = Endpoint(
247
+ type=EndpointType.MCP,
248
+ value=endpoint,
249
+ meta=meta
250
+ )
251
+ self.registration_file.endpoints.append(mcp_endpoint)
252
+ self.registration_file.updatedAt = int(time.time())
253
+ return self
254
+
255
+ def setA2A(self, agentcard: str, version: str = "0.30", auto_fetch: bool = True) -> 'Agent':
256
+ """
257
+ Set A2A endpoint with version.
258
+
259
+ Args:
260
+ agentcard: A2A endpoint URL
261
+ version: A2A version
262
+ auto_fetch: If True, automatically fetch skills from the endpoint (default: True)
263
+ """
264
+ # Remove existing A2A endpoint if any
265
+ self.registration_file.endpoints = [
266
+ ep for ep in self.registration_file.endpoints
267
+ if ep.type != EndpointType.A2A
268
+ ]
269
+
270
+ # Try to fetch capabilities from the endpoint (soft fail)
271
+ meta = {"version": version}
272
+ if auto_fetch:
273
+ try:
274
+ capabilities = self._endpoint_crawler.fetch_a2a_capabilities(agentcard)
275
+ if capabilities:
276
+ meta.update(capabilities)
277
+ skills_count = len(capabilities.get('a2aSkills', []))
278
+ logger.debug(f"Fetched A2A capabilities: {skills_count} skills")
279
+ except Exception as e:
280
+ # Soft fail - continue without capabilities
281
+ logger.debug(f"Could not fetch A2A capabilities (non-blocking): {e}")
282
+
283
+ # Add new A2A endpoint
284
+ a2a_endpoint = Endpoint(
285
+ type=EndpointType.A2A,
286
+ value=agentcard,
287
+ meta=meta
288
+ )
289
+ self.registration_file.endpoints.append(a2a_endpoint)
290
+ self.registration_file.updatedAt = int(time.time())
291
+ return self
292
+
293
+ def removeEndpoint(
294
+ self,
295
+ type: Optional[EndpointType] = None,
296
+ value: Optional[str] = None
297
+ ) -> 'Agent':
298
+ """Remove endpoint(s) with wildcard semantics."""
299
+ if type is None and value is None:
300
+ # Remove all endpoints
301
+ self.registration_file.endpoints.clear()
302
+ else:
303
+ # Remove matching endpoints
304
+ self.registration_file.endpoints = [
305
+ ep for ep in self.registration_file.endpoints
306
+ if not (
307
+ (type is None or ep.type == type) and
308
+ (value is None or ep.value == value)
309
+ )
310
+ ]
311
+
312
+ self.registration_file.updatedAt = int(time.time())
313
+ return self
314
+
315
+ def removeEndpoints(self) -> 'Agent':
316
+ """Remove all endpoints."""
317
+ return self.removeEndpoint()
318
+
319
+ # Trust models
320
+ def setTrust(
321
+ self,
322
+ reputation: bool = False,
323
+ cryptoEconomic: bool = False,
324
+ teeAttestation: bool = False
325
+ ) -> 'Agent':
326
+ """Set trust models using keyword arguments."""
327
+ trust_models = []
328
+ if reputation:
329
+ trust_models.append(TrustModel.REPUTATION)
330
+ if cryptoEconomic:
331
+ trust_models.append(TrustModel.CRYPTO_ECONOMIC)
332
+ if teeAttestation:
333
+ trust_models.append(TrustModel.TEE_ATTESTATION)
334
+
335
+ self.registration_file.trustModels = trust_models
336
+ self.registration_file.updatedAt = int(time.time())
337
+ return self
338
+
339
+ def trustModels(self, models: List[Union[TrustModel, str]]) -> 'Agent':
340
+ """Set trust models (replace set)."""
341
+ self.registration_file.trustModels = models
342
+ self.registration_file.updatedAt = int(time.time())
343
+ return self
344
+
345
+ # Basic info
346
+ def updateInfo(
347
+ self,
348
+ name: Optional[str] = None,
349
+ description: Optional[str] = None,
350
+ image: Optional[URI] = None
351
+ ) -> 'Agent':
352
+ """Update basic agent information."""
353
+ if name is not None:
354
+ self.registration_file.name = name
355
+ if description is not None:
356
+ self.registration_file.description = description
357
+ if image is not None:
358
+ self.registration_file.image = image
359
+
360
+ self.registration_file.updatedAt = int(time.time())
361
+ return self
362
+
363
+ def setAgentWallet(self, addr: Optional[Address], chainId: Optional[int] = None) -> 'Agent':
364
+ """Set agent wallet address in registration file (will be saved on-chain during next register call)."""
365
+ # Validate address format if provided
366
+ if addr:
367
+ if not addr.startswith("0x") or len(addr) != 42:
368
+ raise ValueError(f"Invalid Ethereum address format: {addr}. Must be 42 characters starting with '0x'")
369
+
370
+ # Validate hexadecimal characters
371
+ try:
372
+ int(addr[2:], 16)
373
+ except ValueError:
374
+ raise ValueError(f"Invalid hexadecimal characters in address: {addr}")
375
+
376
+ # Determine chain ID to use
377
+ if chainId is None:
378
+ # Extract chain ID from agentId if available, otherwise use SDK's chain ID
379
+ if self.agentId and ":" in self.agentId:
380
+ try:
381
+ chainId = int(self.agentId.split(":")[0]) # First part is chainId
382
+ except (ValueError, IndexError):
383
+ chainId = self.sdk.chainId # Use SDK's chain ID as fallback
384
+ else:
385
+ chainId = self.sdk.chainId # Use SDK's chain ID as fallback
386
+
387
+ # Check if wallet changed
388
+ if addr != self._last_registered_wallet:
389
+ self._dirty_metadata.add("agentWallet")
390
+
391
+ # Update local registration file
392
+ self.registration_file.walletAddress = addr
393
+ self.registration_file.walletChainId = chainId
394
+ self.registration_file.updatedAt = int(time.time())
395
+
396
+ return self
397
+
398
+ def setENS(self, name: str, version: str = "1.0") -> 'Agent':
399
+ """Set ENS name both on-chain and in registration file."""
400
+ # Remove existing ENS endpoints
401
+ self.registration_file.endpoints = [
402
+ ep for ep in self.registration_file.endpoints
403
+ if ep.type != EndpointType.ENS
404
+ ]
405
+
406
+ # Check if ENS changed
407
+ if name != self._last_registered_ens:
408
+ self._dirty_metadata.add("agentName")
409
+
410
+ # Add new ENS endpoint
411
+ ens_endpoint = Endpoint(
412
+ type=EndpointType.ENS,
413
+ value=name,
414
+ meta={"version": version}
415
+ )
416
+ self.registration_file.endpoints.append(ens_endpoint)
417
+ self.registration_file.updatedAt = int(time.time())
418
+
419
+ return self
420
+
421
+ def setActive(self, active: bool) -> 'Agent':
422
+ """Set agent active status."""
423
+ self.registration_file.active = active
424
+ self.registration_file.updatedAt = int(time.time())
425
+ return self
426
+
427
+ def setX402Support(self, x402Support: bool) -> 'Agent':
428
+ """Set agent x402 payment support."""
429
+ self.registration_file.x402support = x402Support
430
+ self.registration_file.updatedAt = int(time.time())
431
+ return self
432
+
433
+ # Metadata management
434
+ def setMetadata(self, kv: Dict[str, Any]) -> 'Agent':
435
+ """Set metadata (SDK-managed bag)."""
436
+ # Mark all provided keys as dirty
437
+ for key in kv.keys():
438
+ self._dirty_metadata.add(key)
439
+
440
+ self.registration_file.metadata.update(kv)
441
+ self.registration_file.updatedAt = int(time.time())
442
+ return self
443
+
444
+ def getMetadata(self) -> Dict[str, Any]:
445
+ """Get metadata."""
446
+ return self.registration_file.metadata.copy()
447
+
448
+ def delMetadata(self, key: str) -> 'Agent':
449
+ """Delete a metadata key."""
450
+ if key in self.registration_file.metadata:
451
+ del self.registration_file.metadata[key]
452
+ # Mark this key as dirty for tracking
453
+ self._dirty_metadata.discard(key) # Remove from dirty set since it's being deleted
454
+ self.registration_file.updatedAt = int(time.time())
455
+ return self
456
+
457
+ # Local inspection
458
+ def getRegistrationFile(self) -> RegistrationFile:
459
+ """Get current in-memory file (not necessarily published yet)."""
460
+ return self.registration_file
461
+
462
+ # Registration (on-chain)
463
+ def registerIPFS(self) -> RegistrationFile:
464
+ """Register agent on-chain with IPFS flow (mint -> pin -> set URI) or update existing registration."""
465
+ # Validate basic info
466
+ if not self.registration_file.name or not self.registration_file.description:
467
+ raise ValueError("Agent must have name and description before registration")
468
+
469
+ if self.registration_file.agentId:
470
+ # Agent already registered - update registration file and redeploy
471
+ logger.debug("Agent already registered, updating registration file")
472
+
473
+ # Upload updated registration file to IPFS
474
+ ipfsCid = self.sdk.ipfs_client.addRegistrationFile(
475
+ self.registration_file,
476
+ chainId=self.sdk.chain_id(),
477
+ identityRegistryAddress=self.sdk.identity_registry.address
478
+ )
479
+
480
+ # Update metadata on-chain if agent is already registered
481
+ # Only send transactions for dirty (changed) metadata to save gas
482
+ if self._dirty_metadata:
483
+ metadata_entries = self._collectMetadataForRegistration()
484
+ agentId = int(self.agentId.split(":")[-1])
485
+ for entry in metadata_entries:
486
+ # Only send transaction if this metadata key is dirty
487
+ if entry["key"] in self._dirty_metadata:
488
+ txHash = self.sdk.web3_client.transact_contract(
489
+ self.sdk.identity_registry,
490
+ "setMetadata",
491
+ agentId,
492
+ entry["key"],
493
+ entry["value"]
494
+ )
495
+ try:
496
+ self.sdk.web3_client.wait_for_transaction(txHash, timeout=30)
497
+ except Exception as e:
498
+ logger.warning(f"Transaction timeout for {entry['key']}: {e}")
499
+ logger.debug(f"Updated metadata on-chain: {entry['key']}")
500
+ else:
501
+ logger.debug("No metadata changes detected, skipping metadata updates")
502
+
503
+ # Update agent URI on-chain
504
+ agentId = int(self.agentId.split(":")[-1])
505
+ txHash = self.sdk.web3_client.transact_contract(
506
+ self.sdk.identity_registry,
507
+ "setAgentUri",
508
+ agentId,
509
+ f"ipfs://{ipfsCid}"
510
+ )
511
+ try:
512
+ self.sdk.web3_client.wait_for_transaction(txHash, timeout=30)
513
+ logger.debug(f"Updated agent URI on-chain: {txHash}")
514
+ except Exception as e:
515
+ logger.warning(f"URI update timeout (transaction sent: {txHash}): {e}")
516
+
517
+ # Clear dirty flags after successful registration
518
+ self._last_registered_wallet = self.walletAddress
519
+ self._last_registered_ens = self.ensEndpoint
520
+ self._dirty_metadata.clear()
521
+
522
+ return self.registration_file
523
+ else:
524
+ # First time registration
525
+ logger.debug("Registering agent for the first time")
526
+
527
+ # Step 1: Register on-chain without URI
528
+ self._registerWithoutUri()
529
+
530
+ # Step 2: Prepare registration file with agent ID (already set by _registerWithoutUri)
531
+ # No need to modify agentId as it's already set correctly
532
+
533
+ # Step 3: Upload to IPFS
534
+ ipfsCid = self.sdk.ipfs_client.addRegistrationFile(
535
+ self.registration_file,
536
+ chainId=self.sdk.chain_id(),
537
+ identityRegistryAddress=self.sdk.identity_registry.address
538
+ )
539
+
540
+ # Step 4: Set agent URI on-chain
541
+ agentId = int(self.agentId.split(":")[-1])
542
+ txHash = self.sdk.web3_client.transact_contract(
543
+ self.sdk.identity_registry,
544
+ "setAgentUri",
545
+ agentId,
546
+ f"ipfs://{ipfsCid}"
547
+ )
548
+ try:
549
+ self.sdk.web3_client.wait_for_transaction(txHash, timeout=30)
550
+ logger.debug(f"Set agent URI on-chain: {txHash}")
551
+ except Exception as e:
552
+ logger.warning(f"URI set timeout (transaction sent: {txHash}): {e}")
553
+
554
+ # Clear dirty flags after successful registration
555
+ self._last_registered_wallet = self.walletAddress
556
+ self._last_registered_ens = self.ensEndpoint
557
+ self._dirty_metadata.clear()
558
+
559
+ return self.registration_file
560
+
561
+ def register(self, agentUri: str) -> RegistrationFile:
562
+ """Register agent on-chain with direct URI or update existing registration."""
563
+ # Validate basic info
564
+ if not self.registration_file.name or not self.registration_file.description:
565
+ raise ValueError("Agent must have name and description before registration")
566
+
567
+ if self.registration_file.agentId:
568
+ # Agent already registered - update agent URI
569
+ logger.debug("Agent already registered, updating agent URI")
570
+ self.setAgentUri(agentUri)
571
+ return self.registration_file
572
+ else:
573
+ # First time registration
574
+ logger.debug("Registering agent for the first time")
575
+ return self._registerWithUri(agentUri)
576
+
577
+ def _registerWithoutUri(self, idem: Optional[IdemKey] = None) -> RegistrationFile:
578
+ """Register without URI (IPFS flow step 1) with metadata."""
579
+ # Collect metadata for registration
580
+ metadata_entries = self._collectMetadataForRegistration()
581
+
582
+ # Mint agent with metadata
583
+ txHash = self.sdk.web3_client.transact_contract(
584
+ self.sdk.identity_registry,
585
+ "register",
586
+ "", # Empty tokenUri for now
587
+ metadata_entries
588
+ )
589
+
590
+ # Wait for transaction
591
+ receipt = self.sdk.web3_client.wait_for_transaction(txHash)
592
+
593
+ # Get agent ID from events
594
+ agentId = self._extractAgentIdFromReceipt(receipt)
595
+
596
+ # Update registration file
597
+ self.registration_file.agentId = f"{self.sdk.chain_id()}:{agentId}"
598
+ self.registration_file.updatedAt = int(time.time())
599
+
600
+ return self.registration_file
601
+
602
+ def _registerWithUri(self, agentURI: URI, idem: Optional[IdemKey] = None) -> RegistrationFile:
603
+ """Register with direct URI and metadata."""
604
+ # Update registration file
605
+ self.registration_file.agentURI = agentURI
606
+ self.registration_file.updatedAt = int(time.time())
607
+
608
+ # Collect metadata for registration
609
+ metadata_entries = self._collectMetadataForRegistration()
610
+
611
+ # Mint agent with URI and metadata
612
+ txHash = self.sdk.web3_client.transact_contract(
613
+ self.sdk.identity_registry,
614
+ "register",
615
+ agentURI,
616
+ metadata_entries
617
+ )
618
+
619
+ # Wait for transaction
620
+ receipt = self.sdk.web3_client.wait_for_transaction(txHash)
621
+
622
+ # Get agent ID from events
623
+ agentId = self._extractAgentIdFromReceipt(receipt)
624
+
625
+ # Update registration file
626
+ self.registration_file.agentId = f"{self.sdk.chain_id()}:{agentId}"
627
+ self.registration_file.updatedAt = int(time.time())
628
+
629
+ return self.registration_file
630
+
631
+ def _extractAgentIdFromReceipt(self, receipt: Dict[str, Any]) -> int:
632
+ """Extract agent ID from transaction receipt."""
633
+ # Look for Transfer event (ERC-721)
634
+ for i, log in enumerate(receipt.get('logs', [])):
635
+ try:
636
+ topics = log.get('topics', [])
637
+ if len(topics) >= 4:
638
+ topic0 = topics[0].hex()
639
+ # Check if this is a Transfer event (ERC-721) by looking at the topic
640
+ if topic0 == 'ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef':
641
+ # The fourth topic should contain the token ID
642
+ agentId_hex = topics[3].hex()
643
+ agentId = int(agentId_hex, 16)
644
+ return agentId
645
+ except Exception:
646
+ continue
647
+
648
+ # If no Transfer event found, try to get the token ID from the transaction
649
+ # This is a fallback for cases where the event might not be properly indexed
650
+ try:
651
+ # Get the transaction details
652
+ tx = self.sdk.web3_client.w3.eth.get_transaction(receipt['transactionHash'])
653
+
654
+ # Try to call the contract to get the latest token ID
655
+ # This assumes the contract has a method to get the total supply or latest ID
656
+ try:
657
+ total_supply = self.sdk.identity_registry.functions.totalSupply().call()
658
+ if total_supply > 0:
659
+ # Return the latest token ID (total supply - 1, since it's 0-indexed)
660
+ agentId = total_supply - 1
661
+ return agentId
662
+ except Exception:
663
+ pass
664
+
665
+ except Exception:
666
+ pass
667
+
668
+ raise ValueError("Could not extract agent ID from transaction receipt")
669
+
670
+ def updateRegistration(
671
+ self,
672
+ agentURI: Optional[URI] = None,
673
+ idem: Optional[IdemKey] = None,
674
+ ) -> RegistrationFile:
675
+ """Update registration after edits."""
676
+ if not self.registration_file.agentId:
677
+ raise ValueError("Agent must be registered before updating")
678
+
679
+ # Update URI if provided
680
+ if agentURI is not None:
681
+ self.registration_file.agentURI = agentURI
682
+
683
+ # Update timestamp
684
+ self.registration_file.updatedAt = int(time.time())
685
+
686
+ # Update on-chain URI if needed
687
+ if agentURI is not None:
688
+ agentId = int(self.registration_file.agentId.split(":")[-1])
689
+ txHash = self.sdk.web3_client.transact_contract(
690
+ self.sdk.identity_registry,
691
+ "setAgentUri",
692
+ agentId,
693
+ agentURI
694
+ )
695
+ self.sdk.web3_client.wait_for_transaction(txHash)
696
+
697
+ return self.registration_file
698
+
699
+ def setAgentUri(self, uri: str) -> 'Agent':
700
+ """Set the agent URI in registration file (will be saved on-chain during next register call)."""
701
+ if not self.registration_file.agentId:
702
+ raise ValueError("Agent must be registered before setting URI")
703
+
704
+ # Update local registration file
705
+ self.registration_file.agentURI = uri
706
+ self.registration_file.updatedAt = int(time.time())
707
+
708
+ return self
709
+
710
+ # Ownership and lifecycle controls
711
+ def transfer(
712
+ self,
713
+ to: Address,
714
+ approve_operator: bool = False,
715
+ idem: Optional[IdemKey] = None,
716
+ ) -> Dict[str, Any]:
717
+ """Transfer agent ownership."""
718
+ if not self.registration_file.agentId:
719
+ raise ValueError("Agent must be registered before transferring")
720
+
721
+ agentId = int(self.registration_file.agentId.split(":")[-1])
722
+
723
+ # Transfer ownership
724
+ txHash = self.sdk.web3_client.transact_contract(
725
+ self.sdk.identity_registry,
726
+ "transferFrom",
727
+ self.sdk.web3_client.account.address,
728
+ to,
729
+ agentId
730
+ )
731
+
732
+ receipt = self.sdk.web3_client.wait_for_transaction(txHash)
733
+
734
+ return {
735
+ "txHash": txHash,
736
+ "agentId": self.registration_file.agentId,
737
+ "from": self.sdk.web3_client.account.address,
738
+ "to": to
739
+ }
740
+
741
+ def addOperator(self, operator: Address, idem: Optional[IdemKey] = None) -> Dict[str, Any]:
742
+ """Add operator (setApprovalForAll)."""
743
+ if not self.registration_file.agentId:
744
+ raise ValueError("Agent must be registered before adding operators")
745
+
746
+ txHash = self.sdk.web3_client.transact_contract(
747
+ self.sdk.identity_registry,
748
+ "setApprovalForAll",
749
+ operator,
750
+ True
751
+ )
752
+
753
+ receipt = self.sdk.web3_client.wait_for_transaction(txHash)
754
+
755
+ return {"txHash": txHash, "operator": operator}
756
+
757
+ def removeOperator(self, operator: Address, idem: Optional[IdemKey] = None) -> Dict[str, Any]:
758
+ """Remove operator."""
759
+ if not self.registration_file.agentId:
760
+ raise ValueError("Agent must be registered before removing operators")
761
+
762
+ txHash = self.sdk.web3_client.transact_contract(
763
+ self.sdk.identity_registry,
764
+ "setApprovalForAll",
765
+ operator,
766
+ False
767
+ )
768
+
769
+ receipt = self.sdk.web3_client.wait_for_transaction(txHash)
770
+
771
+ return {"txHash": txHash, "operator": operator}
772
+
773
+ def transfer(self, newOwnerAddress: str) -> Dict[str, Any]:
774
+ """Transfer agent ownership to a new address.
775
+
776
+ Only the current owner can transfer the agent.
777
+
778
+ Args:
779
+ newOwnerAddress: Ethereum address of the new owner
780
+
781
+ Returns:
782
+ Transaction receipt
783
+
784
+ Raises:
785
+ ValueError: If address is invalid or transfer not allowed
786
+ """
787
+ if not self.registration_file.agentId:
788
+ raise ValueError("Agent must be registered before transfer")
789
+
790
+ # Validate new owner address
791
+ if not newOwnerAddress or newOwnerAddress == "0x0000000000000000000000000000000000000000":
792
+ raise ValueError("New owner address cannot be zero address")
793
+
794
+ # Get current owner using SDK utility
795
+ currentOwner = self.sdk.getAgentOwner(self.registration_file.agentId)
796
+
797
+ # Check if caller is the current owner
798
+ callerAddress = self.sdk.web3_client.account.address
799
+ if callerAddress.lower() != currentOwner.lower():
800
+ raise ValueError(f"Only the current owner ({currentOwner}) can transfer the agent")
801
+
802
+ # Prevent self-transfer
803
+ if newOwnerAddress.lower() == currentOwner.lower():
804
+ raise ValueError("Cannot transfer to the same owner")
805
+
806
+ # Validate address format (basic checksum validation)
807
+ try:
808
+ # Convert to checksum format for validation
809
+ checksum_address = self.sdk.web3_client.w3.to_checksum_address(newOwnerAddress)
810
+ except Exception as e:
811
+ raise ValueError(f"Invalid address format: {e}")
812
+
813
+ logger.debug(f"Transferring agent {self.registration_file.agentId} from {currentOwner} to {checksum_address}")
814
+
815
+ # Parse agentId to extract tokenId for contract call
816
+ agent_id_str = str(self.registration_file.agentId)
817
+ if ":" in agent_id_str:
818
+ token_id = int(agent_id_str.split(":")[-1])
819
+ else:
820
+ token_id = int(agent_id_str)
821
+
822
+ # Call transferFrom on the IdentityRegistry contract
823
+ txHash = self.sdk.web3_client.transact_contract(
824
+ self.sdk.identity_registry,
825
+ "transferFrom",
826
+ currentOwner,
827
+ checksum_address,
828
+ token_id
829
+ )
830
+
831
+ receipt = self.sdk.web3_client.wait_for_transaction(txHash)
832
+
833
+ logger.debug(f"Agent {self.registration_file.agentId} successfully transferred to {checksum_address}")
834
+
835
+ return {"txHash": txHash, "from": currentOwner, "to": checksum_address, "agentId": self.registration_file.agentId}
836
+
837
+ def activate(self, idem: Optional[IdemKey] = None) -> RegistrationFile:
838
+ """Activate agent (soft "undelete")."""
839
+ self.registration_file.active = True
840
+ self.registration_file.updatedAt = int(time.time())
841
+ return self.registration_file
842
+
843
+ def deactivate(self, idem: Optional[IdemKey] = None) -> RegistrationFile:
844
+ """Deactivate agent (soft "delete")."""
845
+ self.registration_file.active = False
846
+ self.registration_file.updatedAt = int(time.time())
847
+ return self.registration_file
848
+
849
+ # Utility methods
850
+ def toJson(self) -> str:
851
+ """Convert registration file to JSON."""
852
+ return json.dumps(self.registration_file.to_dict(
853
+ chain_id=self.sdk.chain_id(),
854
+ identity_registry_address=self.sdk.identity_registry.address if self.sdk.identity_registry else None
855
+ ), indent=2)
856
+
857
+ def saveToFile(self, filePath: str) -> None:
858
+ """Save registration file to local file."""
859
+ with open(filePath, 'w') as f:
860
+ f.write(self.to_json())