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