agent0-sdk 1.4.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,1187 @@
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
+ from .transaction_handle import TransactionHandle
27
+
28
+
29
+ class Agent:
30
+ """Represents an individual agent with its registration data."""
31
+
32
+ def __init__(self, sdk: "SDK", registration_file: RegistrationFile):
33
+ """Initialize agent with SDK and registration file."""
34
+ self.sdk = sdk
35
+ self.registration_file = registration_file
36
+ # Track which metadata has changed since last registration to avoid sending unchanged data
37
+ self._dirty_metadata = set()
38
+ self._last_registered_wallet = None
39
+ self._last_registered_ens = None
40
+ # Initialize endpoint crawler for fetching capabilities
41
+ self._endpoint_crawler = EndpointCrawler(timeout=5)
42
+
43
+ # Read-only properties for direct access
44
+ @property
45
+ def agentId(self) -> Optional[AgentId]:
46
+ """Get agent ID (read-only)."""
47
+ return self.registration_file.agentId
48
+
49
+ @property
50
+ def agentURI(self) -> Optional[URI]:
51
+ """Get agent URI (read-only)."""
52
+ return self.registration_file.agentURI
53
+
54
+ @property
55
+ def name(self) -> str:
56
+ """Get agent name (read-only)."""
57
+ return self.registration_file.name
58
+
59
+ @property
60
+ def description(self) -> str:
61
+ """Get agent description (read-only)."""
62
+ return self.registration_file.description
63
+
64
+ @property
65
+ def image(self) -> Optional[URI]:
66
+ """Get agent image URI (read-only)."""
67
+ return self.registration_file.image
68
+
69
+ @property
70
+ def active(self) -> bool:
71
+ """Get agent active status (read-only)."""
72
+ return self.registration_file.active
73
+
74
+ @property
75
+ def x402support(self) -> bool:
76
+ """Get agent x402 support status (read-only)."""
77
+ return self.registration_file.x402support
78
+
79
+ @property
80
+ def walletAddress(self) -> Optional[Address]:
81
+ """Get agent wallet address (read-only)."""
82
+ return self.registration_file.walletAddress
83
+
84
+ def getWallet(self) -> Optional[Address]:
85
+ """Read the verified agent wallet from the Identity Registry (on-chain).
86
+
87
+ This calls the contract function `getAgentWallet(agentId)` and returns:
88
+ - the wallet address if set and non-zero
89
+ - None if unset/cleared (zero address)
90
+ """
91
+ if not self.agentId:
92
+ raise ValueError("Agent must be registered before reading wallet from chain.")
93
+
94
+ agent_id_int = int(self.agentId.split(":")[-1]) if ":" in self.agentId else int(self.agentId)
95
+ wallet = self.sdk.web3_client.call_contract(self.sdk.identity_registry, "getAgentWallet", agent_id_int)
96
+
97
+ if not wallet or not isinstance(wallet, str):
98
+ return None
99
+
100
+ if wallet.lower() == "0x0000000000000000000000000000000000000000":
101
+ return None
102
+
103
+ return wallet
104
+
105
+ @property
106
+ def walletChainId(self) -> Optional[int]:
107
+ """Get agent wallet chain ID (read-only)."""
108
+ return self.registration_file.walletChainId
109
+
110
+ @property
111
+ def endpoints(self) -> List[Endpoint]:
112
+ """Get agent endpoints list (read-only - use setter methods to modify)."""
113
+ return self.registration_file.endpoints
114
+
115
+ @property
116
+ def trustModels(self) -> List[Union[TrustModel, str]]:
117
+ """Get agent trust models list (read-only - use setter methods to modify)."""
118
+ return self.registration_file.trustModels
119
+
120
+ @property
121
+ def metadata(self) -> Dict[str, Any]:
122
+ """Get agent metadata dict (read-only - use setter methods to modify)."""
123
+ return self.registration_file.metadata
124
+
125
+ @property
126
+ def updatedAt(self) -> Timestamp:
127
+ """Get last update timestamp (read-only)."""
128
+ return self.registration_file.updatedAt
129
+
130
+ @property
131
+ def owners(self) -> List[Address]:
132
+ """Get agent owners list (read-only)."""
133
+ return self.registration_file.owners
134
+
135
+ @property
136
+ def operators(self) -> List[Address]:
137
+ """Get agent operators list (read-only)."""
138
+ return self.registration_file.operators
139
+
140
+ # Derived endpoint properties (convenience)
141
+ @property
142
+ def mcpEndpoint(self) -> Optional[str]:
143
+ """Get MCP endpoint value (read-only)."""
144
+ for endpoint in self.registration_file.endpoints:
145
+ if endpoint.type == EndpointType.MCP:
146
+ return endpoint.value
147
+ return None
148
+
149
+ @property
150
+ def a2aEndpoint(self) -> Optional[str]:
151
+ """Get A2A endpoint value (read-only)."""
152
+ for endpoint in self.registration_file.endpoints:
153
+ if endpoint.type == EndpointType.A2A:
154
+ return endpoint.value
155
+ return None
156
+
157
+ @property
158
+ def ensEndpoint(self) -> Optional[str]:
159
+ """Get ENS endpoint value (read-only)."""
160
+ for endpoint in self.registration_file.endpoints:
161
+ if endpoint.type == EndpointType.ENS:
162
+ return endpoint.value
163
+ return None
164
+
165
+ @property
166
+ def mcpTools(self) -> Optional[List[str]]:
167
+ """Get MCP tools list (read-only)."""
168
+ for endpoint in self.registration_file.endpoints:
169
+ if endpoint.type == EndpointType.MCP:
170
+ return endpoint.meta.get('mcpTools')
171
+ return None
172
+
173
+ @property
174
+ def mcpPrompts(self) -> Optional[List[str]]:
175
+ """Get MCP prompts list (read-only)."""
176
+ for endpoint in self.registration_file.endpoints:
177
+ if endpoint.type == EndpointType.MCP:
178
+ return endpoint.meta.get('mcpPrompts')
179
+ return None
180
+
181
+ @property
182
+ def mcpResources(self) -> Optional[List[str]]:
183
+ """Get MCP resources list (read-only)."""
184
+ for endpoint in self.registration_file.endpoints:
185
+ if endpoint.type == EndpointType.MCP:
186
+ return endpoint.meta.get('mcpResources')
187
+ return None
188
+
189
+ @property
190
+ def a2aSkills(self) -> Optional[List[str]]:
191
+ """Get A2A skills list (read-only)."""
192
+ for endpoint in self.registration_file.endpoints:
193
+ if endpoint.type == EndpointType.A2A:
194
+ return endpoint.meta.get('a2aSkills')
195
+ return None
196
+
197
+ def registrationFile(self) -> RegistrationFile:
198
+ """Get the compiled registration file."""
199
+ return self.registration_file
200
+
201
+ def _collectMetadataForRegistration(self) -> List[Dict[str, Any]]:
202
+ """Collect all metadata entries for registration.
203
+
204
+ Note: agentWallet is now a reserved metadata key and cannot be set via setMetadata().
205
+ It must be set separately using setWallet() with signature verification.
206
+ """
207
+ metadata_entries = []
208
+
209
+ # Note: agentWallet is no longer set via metadata - it's now reserved and managed via setWallet()
210
+
211
+ # Add ENS name metadata
212
+ if self.ensEndpoint:
213
+ name_bytes = self.ensEndpoint.encode('utf-8')
214
+ metadata_entries.append({
215
+ "key": "agentName",
216
+ "value": name_bytes
217
+ })
218
+
219
+ # Add custom metadata
220
+ for key, value in self.metadata.items():
221
+ if isinstance(value, str):
222
+ value_bytes = value.encode('utf-8')
223
+ elif isinstance(value, (int, float)):
224
+ value_bytes = str(value).encode('utf-8')
225
+ else:
226
+ value_bytes = str(value).encode('utf-8')
227
+
228
+ metadata_entries.append({
229
+ "key": key,
230
+ "value": value_bytes
231
+ })
232
+
233
+ return metadata_entries
234
+
235
+ # Endpoint management
236
+ def setMCP(self, endpoint: str, version: str = "2025-06-18", auto_fetch: bool = True) -> 'Agent':
237
+ """
238
+ Set MCP endpoint with version.
239
+
240
+ Args:
241
+ endpoint: MCP endpoint URL
242
+ version: MCP version
243
+ auto_fetch: If True, automatically fetch capabilities from the endpoint (default: True)
244
+ """
245
+ # Remove existing MCP endpoint if any
246
+ self.registration_file.endpoints = [
247
+ ep for ep in self.registration_file.endpoints
248
+ if ep.type != EndpointType.MCP
249
+ ]
250
+
251
+ # Try to fetch capabilities from the endpoint (soft fail)
252
+ meta = {"version": version}
253
+ if auto_fetch:
254
+ try:
255
+ capabilities = self._endpoint_crawler.fetch_mcp_capabilities(endpoint)
256
+ if capabilities:
257
+ meta.update(capabilities)
258
+ logger.debug(
259
+ f"Fetched MCP capabilities: {len(capabilities.get('mcpTools', []))} tools, "
260
+ f"{len(capabilities.get('mcpPrompts', []))} prompts, "
261
+ f"{len(capabilities.get('mcpResources', []))} resources"
262
+ )
263
+ except Exception as e:
264
+ # Soft fail - continue without capabilities
265
+ logger.debug(f"Could not fetch MCP capabilities (non-blocking): {e}")
266
+
267
+ # Add new MCP endpoint
268
+ mcp_endpoint = Endpoint(
269
+ type=EndpointType.MCP,
270
+ value=endpoint,
271
+ meta=meta
272
+ )
273
+ self.registration_file.endpoints.append(mcp_endpoint)
274
+ self.registration_file.updatedAt = int(time.time())
275
+ return self
276
+
277
+ def setA2A(self, agentcard: str, version: str = "0.30", auto_fetch: bool = True) -> 'Agent':
278
+ """
279
+ Set A2A endpoint with version.
280
+
281
+ Args:
282
+ agentcard: A2A endpoint URL
283
+ version: A2A version
284
+ auto_fetch: If True, automatically fetch skills from the endpoint (default: True)
285
+ """
286
+ # Remove existing A2A endpoint if any
287
+ self.registration_file.endpoints = [
288
+ ep for ep in self.registration_file.endpoints
289
+ if ep.type != EndpointType.A2A
290
+ ]
291
+
292
+ # Try to fetch capabilities from the endpoint (soft fail)
293
+ meta = {"version": version}
294
+ if auto_fetch:
295
+ try:
296
+ capabilities = self._endpoint_crawler.fetch_a2a_capabilities(agentcard)
297
+ if capabilities:
298
+ meta.update(capabilities)
299
+ skills_count = len(capabilities.get('a2aSkills', []))
300
+ logger.debug(f"Fetched A2A capabilities: {skills_count} skills")
301
+ except Exception as e:
302
+ # Soft fail - continue without capabilities
303
+ logger.debug(f"Could not fetch A2A capabilities (non-blocking): {e}")
304
+
305
+ # Add new A2A endpoint
306
+ a2a_endpoint = Endpoint(
307
+ type=EndpointType.A2A,
308
+ value=agentcard,
309
+ meta=meta
310
+ )
311
+ self.registration_file.endpoints.append(a2a_endpoint)
312
+ self.registration_file.updatedAt = int(time.time())
313
+ return self
314
+
315
+ def removeEndpoint(
316
+ self,
317
+ type: Optional[EndpointType] = None,
318
+ value: Optional[str] = None
319
+ ) -> 'Agent':
320
+ """Remove endpoint(s) with wildcard semantics."""
321
+ if type is None and value is None:
322
+ # Remove all endpoints
323
+ self.registration_file.endpoints.clear()
324
+ else:
325
+ # Remove matching endpoints
326
+ self.registration_file.endpoints = [
327
+ ep for ep in self.registration_file.endpoints
328
+ if not (
329
+ (type is None or ep.type == type) and
330
+ (value is None or ep.value == value)
331
+ )
332
+ ]
333
+
334
+ self.registration_file.updatedAt = int(time.time())
335
+ return self
336
+
337
+ def removeEndpoints(self) -> 'Agent':
338
+ """Remove all endpoints."""
339
+ return self.removeEndpoint()
340
+
341
+ # OASF endpoint management
342
+ def _get_or_create_oasf_endpoint(self) -> Endpoint:
343
+ """Get existing OASF endpoint or create a new one with default values."""
344
+ # Find existing OASF endpoint
345
+ for ep in self.registration_file.endpoints:
346
+ if ep.type == EndpointType.OASF:
347
+ return ep
348
+
349
+ # Create new OASF endpoint with default values
350
+ oasf_endpoint = Endpoint(
351
+ type=EndpointType.OASF,
352
+ value="https://github.com/agntcy/oasf/",
353
+ # Version string follows ERC-8004 spec example ("0.8")
354
+ meta={"version": "0.8", "skills": [], "domains": []}
355
+ )
356
+ self.registration_file.endpoints.append(oasf_endpoint)
357
+ return oasf_endpoint
358
+
359
+ def addSkill(self, slug: str, validate_oasf: bool = False) -> 'Agent':
360
+ """
361
+ Add a skill to the OASF endpoint.
362
+
363
+ Args:
364
+ slug: The skill slug to add (e.g., "natural_language_processing/natural_language_generation/summarization")
365
+ validate_oasf: If True, validate the slug against the OASF taxonomy (default: False)
366
+
367
+ Returns:
368
+ self for method chaining
369
+
370
+ Raises:
371
+ ValueError: If validate_oasf=True and the slug is not valid
372
+ """
373
+ if validate_oasf:
374
+ if not validate_skill(slug):
375
+ raise ValueError(
376
+ f"Invalid OASF skill slug: {slug}. "
377
+ "Use validate_oasf=False to skip validation."
378
+ )
379
+
380
+ oasf_endpoint = self._get_or_create_oasf_endpoint()
381
+
382
+ # Initialize skills array if missing
383
+ if "skills" not in oasf_endpoint.meta:
384
+ oasf_endpoint.meta["skills"] = []
385
+
386
+ # Add slug if not already present (avoid duplicates)
387
+ skills = oasf_endpoint.meta["skills"]
388
+ if slug not in skills:
389
+ skills.append(slug)
390
+
391
+ self.registration_file.updatedAt = int(time.time())
392
+ return self
393
+
394
+ def removeSkill(self, slug: str) -> 'Agent':
395
+ """
396
+ Remove a skill from the OASF endpoint.
397
+
398
+ Args:
399
+ slug: The skill slug to remove
400
+
401
+ Returns:
402
+ self for method chaining
403
+ """
404
+ # Find OASF endpoint
405
+ for ep in self.registration_file.endpoints:
406
+ if ep.type == EndpointType.OASF:
407
+ if "skills" in ep.meta and isinstance(ep.meta["skills"], list):
408
+ skills = ep.meta["skills"]
409
+ if slug in skills:
410
+ skills.remove(slug)
411
+ self.registration_file.updatedAt = int(time.time())
412
+ break
413
+
414
+ return self
415
+
416
+ def addDomain(self, slug: str, validate_oasf: bool = False) -> 'Agent':
417
+ """
418
+ Add a domain to the OASF endpoint.
419
+
420
+ Args:
421
+ slug: The domain slug to add (e.g., "finance_and_business/investment_services")
422
+ validate_oasf: If True, validate the slug against the OASF taxonomy (default: False)
423
+
424
+ Returns:
425
+ self for method chaining
426
+
427
+ Raises:
428
+ ValueError: If validate_oasf=True and the slug is not valid
429
+ """
430
+ if validate_oasf:
431
+ if not validate_domain(slug):
432
+ raise ValueError(
433
+ f"Invalid OASF domain slug: {slug}. "
434
+ "Use validate_oasf=False to skip validation."
435
+ )
436
+
437
+ oasf_endpoint = self._get_or_create_oasf_endpoint()
438
+
439
+ # Initialize domains array if missing
440
+ if "domains" not in oasf_endpoint.meta:
441
+ oasf_endpoint.meta["domains"] = []
442
+
443
+ # Add slug if not already present (avoid duplicates)
444
+ domains = oasf_endpoint.meta["domains"]
445
+ if slug not in domains:
446
+ domains.append(slug)
447
+
448
+ self.registration_file.updatedAt = int(time.time())
449
+ return self
450
+
451
+ def removeDomain(self, slug: str) -> 'Agent':
452
+ """
453
+ Remove a domain from the OASF endpoint.
454
+
455
+ Args:
456
+ slug: The domain slug to remove
457
+
458
+ Returns:
459
+ self for method chaining
460
+ """
461
+ # Find OASF endpoint
462
+ for ep in self.registration_file.endpoints:
463
+ if ep.type == EndpointType.OASF:
464
+ if "domains" in ep.meta and isinstance(ep.meta["domains"], list):
465
+ domains = ep.meta["domains"]
466
+ if slug in domains:
467
+ domains.remove(slug)
468
+ self.registration_file.updatedAt = int(time.time())
469
+ break
470
+
471
+ return self
472
+
473
+ # Trust models
474
+ def setTrust(
475
+ self,
476
+ reputation: bool = False,
477
+ cryptoEconomic: bool = False,
478
+ teeAttestation: bool = False
479
+ ) -> 'Agent':
480
+ """Set trust models using keyword arguments."""
481
+ trust_models = []
482
+ if reputation:
483
+ trust_models.append(TrustModel.REPUTATION)
484
+ if cryptoEconomic:
485
+ trust_models.append(TrustModel.CRYPTO_ECONOMIC)
486
+ if teeAttestation:
487
+ trust_models.append(TrustModel.TEE_ATTESTATION)
488
+
489
+ self.registration_file.trustModels = trust_models
490
+ self.registration_file.updatedAt = int(time.time())
491
+ return self
492
+
493
+ def trustModels(self, models: List[Union[TrustModel, str]]) -> 'Agent':
494
+ """Set trust models (replace set)."""
495
+ self.registration_file.trustModels = models
496
+ self.registration_file.updatedAt = int(time.time())
497
+ return self
498
+
499
+ # Basic info
500
+ def updateInfo(
501
+ self,
502
+ name: Optional[str] = None,
503
+ description: Optional[str] = None,
504
+ image: Optional[URI] = None
505
+ ) -> 'Agent':
506
+ """Update basic agent information."""
507
+ if name is not None:
508
+ self.registration_file.name = name
509
+ if description is not None:
510
+ self.registration_file.description = description
511
+ if image is not None:
512
+ self.registration_file.image = image
513
+
514
+ self.registration_file.updatedAt = int(time.time())
515
+ return self
516
+
517
+ def setWallet(
518
+ self,
519
+ new_wallet: Address,
520
+ chainId: Optional[int] = None,
521
+ *,
522
+ new_wallet_signer: Optional[Union[str, Any]] = None,
523
+ deadline: Optional[int] = None,
524
+ signature: Optional[bytes] = None,
525
+ ) -> Optional[TransactionHandle["Agent"]]:
526
+ """Set agent wallet address on-chain (verified agentWallet).
527
+
528
+ This method is **on-chain only**. The `agentWallet` is a verified attribute.
529
+
530
+ EOAs: provide `new_wallet_signer` (private key string or eth-account account) OR ensure the SDK
531
+ signer address matches `new_wallet` so the SDK can auto-sign.\n
532
+ Contract wallets (ERC-1271): provide `signature` bytes produced by the wallet’s signing mechanism.
533
+ The SDK will build the correct EIP-712 typed data internally, but cannot produce the wallet signature.
534
+
535
+ Args:
536
+ new_wallet: New wallet address (must be controlled by the signer that produces the signature)
537
+ chainId: Optional local bookkeeping for registration file (walletChainId). Defaults to agent chain.
538
+ new_wallet_signer: EOA signer used to sign the EIP-712 message (private key string or eth-account account)
539
+ deadline: Signature deadline timestamp. Defaults to now+60s (must be <= now+5min per contract).
540
+ signature: Raw signature bytes (intended for ERC-1271 / external signing only)
541
+ """
542
+ # This API is only meaningful for already-registered agents.
543
+ if not self.agentId:
544
+ raise ValueError(
545
+ "Cannot set agent wallet before the agent is registered on-chain. "
546
+ "Call agent.register(...) / agent.registerIPFS() first to obtain agentId."
547
+ )
548
+
549
+ addr = new_wallet
550
+
551
+ if not addr:
552
+ raise ValueError("Wallet address cannot be empty. Use a non-zero address.")
553
+
554
+ # Validate address format
555
+ if not addr.startswith("0x") or len(addr) != 42:
556
+ raise ValueError(f"Invalid Ethereum address format: {addr}. Must be 42 characters starting with '0x'")
557
+
558
+ # Validate hexadecimal characters
559
+ try:
560
+ int(addr[2:], 16)
561
+ except ValueError:
562
+ raise ValueError(f"Invalid hexadecimal characters in address: {addr}")
563
+
564
+ # Determine chain ID to use (local bookkeeping)
565
+ if chainId is None:
566
+ # Extract chain ID from agentId if available, otherwise use SDK's chain ID
567
+ if self.agentId and ":" in self.agentId:
568
+ try:
569
+ chainId = int(self.agentId.split(":")[0]) # First part is chainId
570
+ except (ValueError, IndexError):
571
+ chainId = self.sdk.chainId # Use SDK's chain ID as fallback
572
+ else:
573
+ chainId = self.sdk.chainId # Use SDK's chain ID as fallback
574
+
575
+ # Parse agent ID
576
+ agent_id_int = int(self.agentId.split(":")[-1]) if ":" in self.agentId else int(self.agentId)
577
+
578
+ # Check if wallet is already set to this address (skip if same)
579
+ try:
580
+ current_wallet = self.getWallet()
581
+ if current_wallet and current_wallet.lower() == addr.lower():
582
+ logger.debug(f"Agent wallet is already set to {addr}, skipping on-chain update")
583
+ # Still update local registration file
584
+ self.registration_file.walletAddress = addr
585
+ self.registration_file.walletChainId = chainId
586
+ self.registration_file.updatedAt = int(time.time())
587
+ return None
588
+ except Exception as e:
589
+ logger.debug(f"Could not check current agent wallet: {e}, proceeding with update")
590
+
591
+ # Set deadline (default to 60 seconds from now; contract max is now+5min)
592
+ if deadline is None:
593
+ deadline = int(time.time()) + 60
594
+
595
+ # Resolve typed data + signature
596
+ identity_registry_address = self.sdk.identity_registry.address
597
+ owner_address = self.sdk.web3_client.call_contract(self.sdk.identity_registry, "ownerOf", agent_id_int)
598
+
599
+ full_message = self.sdk.web3_client.build_agent_wallet_set_typed_data(
600
+ agent_id=agent_id_int,
601
+ new_wallet=addr,
602
+ owner=owner_address,
603
+ deadline=deadline,
604
+ verifying_contract=identity_registry_address,
605
+ chain_id=self.sdk.web3_client.chain_id,
606
+ )
607
+
608
+ if signature is None:
609
+ # EOA signing paths
610
+ if new_wallet_signer is not None:
611
+ # Validate signer address matches addr (fail fast)
612
+ try:
613
+ from eth_account import Account as _Account
614
+ if isinstance(new_wallet_signer, str):
615
+ signer_addr = _Account.from_key(new_wallet_signer).address
616
+ else:
617
+ signer_addr = getattr(new_wallet_signer, "address", None)
618
+ except Exception:
619
+ signer_addr = getattr(new_wallet_signer, "address", None)
620
+
621
+ if not signer_addr or signer_addr.lower() != addr.lower():
622
+ raise ValueError(
623
+ f"new_wallet_signer address ({signer_addr}) does not match new_wallet ({addr})."
624
+ )
625
+
626
+ signature = self.sdk.web3_client.sign_typed_data(full_message, new_wallet_signer) # type: ignore[arg-type]
627
+ else:
628
+ # Auto-sign only if SDK signer == new wallet
629
+ current_address = self.sdk.web3_client.account.address if self.sdk.web3_client.account else None
630
+ if current_address and current_address.lower() == addr.lower():
631
+ signature = self.sdk.web3_client.sign_typed_data(full_message, self.sdk.web3_client.account)
632
+ else:
633
+ raise ValueError(
634
+ f"New wallet must sign. Provide new_wallet_signer (EOA) or signature (ERC-1271/external). "
635
+ f"SDK signer is {current_address}, new_wallet is {addr}."
636
+ )
637
+
638
+ # Optional: verify recover matches addr for EOA signatures
639
+ recovered = self.sdk.web3_client.w3.eth.account.recover_message(
640
+ __import__("eth_account.messages").messages.encode_typed_data(full_message=full_message),
641
+ signature=signature,
642
+ )
643
+ if recovered.lower() != addr.lower():
644
+ raise ValueError(f"Signature verification failed: recovered {recovered} but expected {addr}")
645
+
646
+ # Submit on-chain tx (tx sender is SDK signer: owner/operator)
647
+ try:
648
+ txHash = self.sdk.web3_client.transact_contract(
649
+ self.sdk.identity_registry,
650
+ "setAgentWallet",
651
+ agent_id_int,
652
+ addr,
653
+ deadline,
654
+ signature
655
+ )
656
+ except Exception as e:
657
+ raise ValueError(f"Failed to set agent wallet on-chain: {e}")
658
+
659
+ def _apply(_receipt: Dict[str, Any]) -> "Agent":
660
+ self.registration_file.walletAddress = addr
661
+ self.registration_file.walletChainId = chainId
662
+ self.registration_file.updatedAt = int(time.time())
663
+ self._last_registered_wallet = addr
664
+ return self
665
+
666
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
667
+
668
+ def unsetWallet(self) -> Optional[TransactionHandle["Agent"]]:
669
+ """Unset agent wallet address on-chain (verified agentWallet).
670
+
671
+ This method is **on-chain only** and requires the agent to be registered.
672
+ It unsets the on-chain value and clears the local
673
+ `walletAddress` / `walletChainId` fields.
674
+ """
675
+ if not self.agentId:
676
+ raise ValueError(
677
+ "Cannot unset agent wallet before the agent is registered on-chain. "
678
+ "Call agent.register(...) / agent.registerIPFS() first to obtain agentId."
679
+ )
680
+
681
+ # Parse agent ID (tokenId is always the last segment)
682
+ agent_id_int = int(self.agentId.split(":")[-1]) if ":" in self.agentId else int(self.agentId)
683
+
684
+ # Optional short-circuit if already unset (best-effort).
685
+ try:
686
+ current_wallet = self.getWallet()
687
+ if current_wallet is None:
688
+ self.registration_file.walletAddress = None
689
+ self.registration_file.walletChainId = None
690
+ self.registration_file.updatedAt = int(time.time())
691
+ return None
692
+ except Exception:
693
+ pass
694
+
695
+ try:
696
+ txHash = self.sdk.web3_client.transact_contract(
697
+ self.sdk.identity_registry,
698
+ "unsetAgentWallet",
699
+ agent_id_int
700
+ )
701
+ except Exception as e:
702
+ raise ValueError(f"Failed to unset agent wallet on-chain: {e}")
703
+
704
+ def _apply(_receipt: Dict[str, Any]) -> "Agent":
705
+ self.registration_file.walletAddress = None
706
+ self.registration_file.walletChainId = None
707
+ self.registration_file.updatedAt = int(time.time())
708
+ return self
709
+
710
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
711
+
712
+ def setENS(self, name: str, version: str = "1.0") -> 'Agent':
713
+ """Set ENS name both on-chain and in registration file."""
714
+ # Remove existing ENS endpoints
715
+ self.registration_file.endpoints = [
716
+ ep for ep in self.registration_file.endpoints
717
+ if ep.type != EndpointType.ENS
718
+ ]
719
+
720
+ # Check if ENS changed
721
+ if name != self._last_registered_ens:
722
+ self._dirty_metadata.add("agentName")
723
+
724
+ # Add new ENS endpoint
725
+ ens_endpoint = Endpoint(
726
+ type=EndpointType.ENS,
727
+ value=name,
728
+ meta={"version": version}
729
+ )
730
+ self.registration_file.endpoints.append(ens_endpoint)
731
+ self.registration_file.updatedAt = int(time.time())
732
+
733
+ return self
734
+
735
+ def setActive(self, active: bool) -> 'Agent':
736
+ """Set agent active status."""
737
+ self.registration_file.active = active
738
+ self.registration_file.updatedAt = int(time.time())
739
+ return self
740
+
741
+ def setX402Support(self, x402Support: bool) -> 'Agent':
742
+ """Set agent x402 payment support."""
743
+ self.registration_file.x402support = x402Support
744
+ self.registration_file.updatedAt = int(time.time())
745
+ return self
746
+
747
+ # Metadata management
748
+ def setMetadata(self, kv: Dict[str, Any]) -> 'Agent':
749
+ """Set metadata (SDK-managed bag)."""
750
+ # Mark all provided keys as dirty
751
+ for key in kv.keys():
752
+ self._dirty_metadata.add(key)
753
+
754
+ self.registration_file.metadata.update(kv)
755
+ self.registration_file.updatedAt = int(time.time())
756
+ return self
757
+
758
+ def getMetadata(self) -> Dict[str, Any]:
759
+ """Get metadata."""
760
+ return self.registration_file.metadata.copy()
761
+
762
+ def delMetadata(self, key: str) -> 'Agent':
763
+ """Delete a metadata key."""
764
+ if key in self.registration_file.metadata:
765
+ del self.registration_file.metadata[key]
766
+ # Mark this key as dirty for tracking
767
+ self._dirty_metadata.discard(key) # Remove from dirty set since it's being deleted
768
+ self.registration_file.updatedAt = int(time.time())
769
+ return self
770
+
771
+ # Local inspection
772
+ def getRegistrationFile(self) -> RegistrationFile:
773
+ """Get current in-memory file (not necessarily published yet)."""
774
+ return self.registration_file
775
+
776
+ # Registration (on-chain)
777
+ def registerIPFS(self) -> TransactionHandle[RegistrationFile]:
778
+ """Register agent on-chain with IPFS flow (mint -> pin -> set URI) or update existing registration.
779
+
780
+ Submitted-by-default: returns a TransactionHandle immediately after the first tx is submitted.
781
+ """
782
+ # Validate basic info
783
+ if not self.registration_file.name or not self.registration_file.description:
784
+ raise ValueError("Agent must have name and description before registration")
785
+
786
+ if self.registration_file.agentId:
787
+ # Agent already registered: upload -> submit setAgentURI; do metadata best-effort after confirmation.
788
+ ipfsCid = self.sdk.ipfs_client.addRegistrationFile(
789
+ self.registration_file,
790
+ chainId=self.sdk.chain_id(),
791
+ identityRegistryAddress=self.sdk.identity_registry.address,
792
+ )
793
+
794
+ agentId_int = int(self.agentId.split(":")[-1])
795
+ txHash = self.sdk.web3_client.transact_contract(
796
+ self.sdk.identity_registry,
797
+ "setAgentURI",
798
+ agentId_int,
799
+ f"ipfs://{ipfsCid}",
800
+ )
801
+
802
+ def _apply(_receipt: Dict[str, Any]) -> RegistrationFile:
803
+ # Best-effort metadata updates (may involve additional txs)
804
+ if self._dirty_metadata:
805
+ metadata_entries = self._collectMetadataForRegistration()
806
+ for entry in metadata_entries:
807
+ if entry["key"] in self._dirty_metadata:
808
+ try:
809
+ h = self.sdk.web3_client.transact_contract(
810
+ self.sdk.identity_registry,
811
+ "setMetadata",
812
+ agentId_int,
813
+ entry["key"],
814
+ entry["value"],
815
+ )
816
+ self.sdk.web3_client.wait_for_transaction(h, timeout=30)
817
+ except Exception as e:
818
+ logger.warning(f"Metadata update failed or timed out for {entry['key']} (tx sent): {e}")
819
+
820
+ self.registration_file.agentURI = f"ipfs://{ipfsCid}"
821
+ self.registration_file.updatedAt = int(time.time())
822
+ self._last_registered_wallet = self.walletAddress
823
+ self._last_registered_ens = self.ensEndpoint
824
+ self._dirty_metadata.clear()
825
+ return self.registration_file
826
+
827
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
828
+
829
+ # First time registration: tx1=register(no URI) -> wait -> upload -> tx2=setAgentURI -> wait
830
+ metadata_entries = self._collectMetadataForRegistration()
831
+ txHash = self.sdk.web3_client.transact_contract(
832
+ self.sdk.identity_registry,
833
+ "register",
834
+ "",
835
+ metadata_entries,
836
+ )
837
+
838
+ def _apply_first(receipt: Dict[str, Any]) -> RegistrationFile:
839
+ agentId_minted = self._extractAgentIdFromReceipt(receipt)
840
+ self.registration_file.agentId = f"{self.sdk.chain_id()}:{agentId_minted}"
841
+ self.registration_file.updatedAt = int(time.time())
842
+
843
+ ipfsCid = self.sdk.ipfs_client.addRegistrationFile(
844
+ self.registration_file,
845
+ chainId=self.sdk.chain_id(),
846
+ identityRegistryAddress=self.sdk.identity_registry.address,
847
+ )
848
+
849
+ txHash2 = self.sdk.web3_client.transact_contract(
850
+ self.sdk.identity_registry,
851
+ "setAgentURI",
852
+ agentId_minted,
853
+ f"ipfs://{ipfsCid}",
854
+ )
855
+ self.sdk.web3_client.wait_for_transaction(txHash2, timeout=30)
856
+
857
+ self.registration_file.agentURI = f"ipfs://{ipfsCid}"
858
+ self.registration_file.updatedAt = int(time.time())
859
+ self._last_registered_wallet = self.walletAddress
860
+ self._last_registered_ens = self.ensEndpoint
861
+ self._dirty_metadata.clear()
862
+ return self.registration_file
863
+
864
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply_first)
865
+
866
+ def register(self, agentUri: str) -> TransactionHandle[RegistrationFile]:
867
+ """Register agent on-chain with direct URI (submitted-by-default)."""
868
+ # Validate basic info
869
+ if not self.registration_file.name or not self.registration_file.description:
870
+ raise ValueError("Agent must have name and description before registration")
871
+
872
+ if self.registration_file.agentId:
873
+ # Update URI on-chain for existing agent
874
+ updated = self.updateRegistration(agentURI=agentUri)
875
+ if isinstance(updated, TransactionHandle):
876
+ return updated
877
+ # Should not happen (agentURI was provided), but keep a safe fallback.
878
+ raise RuntimeError("Expected updateRegistration to return a TransactionHandle when agentURI is provided")
879
+
880
+ return self._registerWithUri(agentUri)
881
+
882
+ def _registerWithoutUri(self, idem: Optional[IdemKey] = None) -> TransactionHandle[RegistrationFile]:
883
+ """Register without URI (IPFS flow step 1) with metadata."""
884
+ # Collect metadata for registration
885
+ metadata_entries = self._collectMetadataForRegistration()
886
+
887
+ # Mint agent with metadata
888
+ txHash = self.sdk.web3_client.transact_contract(
889
+ self.sdk.identity_registry,
890
+ "register",
891
+ "", # Empty agentURI for now
892
+ metadata_entries
893
+ )
894
+
895
+ def _apply(receipt: Dict[str, Any]) -> RegistrationFile:
896
+ agentId = self._extractAgentIdFromReceipt(receipt)
897
+ self.registration_file.agentId = f"{self.sdk.chain_id()}:{agentId}"
898
+ self.registration_file.updatedAt = int(time.time())
899
+ return self.registration_file
900
+
901
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
902
+
903
+ def _registerWithUri(self, agentURI: URI, idem: Optional[IdemKey] = None) -> TransactionHandle[RegistrationFile]:
904
+ """Register with direct URI and metadata."""
905
+ # Update registration file
906
+ self.registration_file.agentURI = agentURI
907
+ self.registration_file.updatedAt = int(time.time())
908
+
909
+ # Collect metadata for registration
910
+ metadata_entries = self._collectMetadataForRegistration()
911
+
912
+ # Mint agent with URI and metadata
913
+ txHash = self.sdk.web3_client.transact_contract(
914
+ self.sdk.identity_registry,
915
+ "register",
916
+ agentURI,
917
+ metadata_entries
918
+ )
919
+
920
+ def _apply(receipt: Dict[str, Any]) -> RegistrationFile:
921
+ agentId = self._extractAgentIdFromReceipt(receipt)
922
+ self.registration_file.agentId = f"{self.sdk.chain_id()}:{agentId}"
923
+ self.registration_file.updatedAt = int(time.time())
924
+ return self.registration_file
925
+
926
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
927
+
928
+ def _extractAgentIdFromReceipt(self, receipt: Dict[str, Any]) -> int:
929
+ """Extract agent ID from transaction receipt."""
930
+ # Look for Transfer event (ERC-721)
931
+ for i, log in enumerate(receipt.get('logs', [])):
932
+ try:
933
+ topics = log.get('topics', [])
934
+ if len(topics) >= 4:
935
+ topic0 = topics[0].hex()
936
+ # Check if this is a Transfer event (ERC-721) by looking at the topic
937
+ if topic0 == 'ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef':
938
+ # The fourth topic should contain the token ID
939
+ agentId_hex = topics[3].hex()
940
+ agentId = int(agentId_hex, 16)
941
+ return agentId
942
+ except Exception:
943
+ continue
944
+
945
+ # If no Transfer event found, try to get the token ID from the transaction
946
+ # This is a fallback for cases where the event might not be properly indexed
947
+ try:
948
+ # Get the transaction details
949
+ tx = self.sdk.web3_client.w3.eth.get_transaction(receipt['transactionHash'])
950
+
951
+ # Try to call the contract to get the latest token ID
952
+ # This assumes the contract has a method to get the total supply or latest ID
953
+ try:
954
+ total_supply = self.sdk.identity_registry.functions.totalSupply().call()
955
+ if total_supply > 0:
956
+ # Return the latest token ID (total supply - 1, since it's 0-indexed)
957
+ agentId = total_supply - 1
958
+ return agentId
959
+ except Exception:
960
+ pass
961
+
962
+ except Exception:
963
+ pass
964
+
965
+ raise ValueError("Could not extract agent ID from transaction receipt")
966
+
967
+ def updateRegistration(
968
+ self,
969
+ agentURI: Optional[URI] = None,
970
+ idem: Optional[IdemKey] = None,
971
+ ) -> Union[RegistrationFile, TransactionHandle[RegistrationFile]]:
972
+ """Update registration after edits."""
973
+ if not self.registration_file.agentId:
974
+ raise ValueError("Agent must be registered before updating")
975
+
976
+ # Update URI if provided
977
+ if agentURI is not None:
978
+ self.registration_file.agentURI = agentURI
979
+
980
+ # Update timestamp
981
+ self.registration_file.updatedAt = int(time.time())
982
+
983
+ # Update on-chain URI if needed
984
+ if agentURI is not None:
985
+ agentId_int = int(self.registration_file.agentId.split(":")[-1])
986
+ txHash = self.sdk.web3_client.transact_contract(
987
+ self.sdk.identity_registry,
988
+ "setAgentURI",
989
+ agentId_int,
990
+ agentURI,
991
+ )
992
+
993
+ def _apply(_receipt: Dict[str, Any]) -> RegistrationFile:
994
+ return self.registration_file
995
+
996
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
997
+
998
+ return self.registration_file
999
+
1000
+ def setAgentUri(self, uri: str) -> 'Agent':
1001
+ """Set the agent URI in registration file (will be saved on-chain during next register call)."""
1002
+ if not self.registration_file.agentId:
1003
+ raise ValueError("Agent must be registered before setting URI")
1004
+
1005
+ # Update local registration file
1006
+ self.registration_file.agentURI = uri
1007
+ self.registration_file.updatedAt = int(time.time())
1008
+
1009
+ return self
1010
+
1011
+ # Ownership and lifecycle controls
1012
+ def transfer(
1013
+ self,
1014
+ to: Address,
1015
+ approve_operator: bool = False,
1016
+ idem: Optional[IdemKey] = None,
1017
+ ) -> TransactionHandle[Dict[str, Any]]:
1018
+ """Transfer agent ownership.
1019
+
1020
+ Note: When an agent is transferred, the agentWallet is automatically reset
1021
+ to the zero address on-chain. The new owner must call setWallet() to
1022
+ set a new wallet address with EIP-712 signature verification.
1023
+ """
1024
+ if not self.registration_file.agentId:
1025
+ raise ValueError("Agent must be registered before transferring")
1026
+
1027
+ agentId = int(self.registration_file.agentId.split(":")[-1])
1028
+
1029
+ # Transfer ownership
1030
+ txHash = self.sdk.web3_client.transact_contract(
1031
+ self.sdk.identity_registry,
1032
+ "transferFrom",
1033
+ self.sdk.web3_client.account.address,
1034
+ to,
1035
+ agentId
1036
+ )
1037
+
1038
+ def _apply(_receipt: Dict[str, Any]) -> Dict[str, Any]:
1039
+ # Note: agentWallet will be reset to zero address by the contract
1040
+ self.registration_file.walletAddress = None
1041
+ self._last_registered_wallet = None
1042
+ self.registration_file.updatedAt = int(time.time())
1043
+ return {
1044
+ "txHash": txHash,
1045
+ "agentId": self.registration_file.agentId,
1046
+ "from": self.sdk.web3_client.account.address,
1047
+ "to": to,
1048
+ }
1049
+
1050
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
1051
+
1052
+ def addOperator(self, operator: Address, idem: Optional[IdemKey] = None) -> TransactionHandle[Dict[str, Any]]:
1053
+ """Add operator (setApprovalForAll)."""
1054
+ if not self.registration_file.agentId:
1055
+ raise ValueError("Agent must be registered before adding operators")
1056
+
1057
+ txHash = self.sdk.web3_client.transact_contract(
1058
+ self.sdk.identity_registry,
1059
+ "setApprovalForAll",
1060
+ operator,
1061
+ True
1062
+ )
1063
+
1064
+ return TransactionHandle(
1065
+ web3_client=self.sdk.web3_client,
1066
+ tx_hash=txHash,
1067
+ compute_result=lambda _receipt: {"txHash": txHash, "operator": operator},
1068
+ )
1069
+
1070
+ def removeOperator(self, operator: Address, idem: Optional[IdemKey] = None) -> TransactionHandle[Dict[str, Any]]:
1071
+ """Remove operator."""
1072
+ if not self.registration_file.agentId:
1073
+ raise ValueError("Agent must be registered before removing operators")
1074
+
1075
+ txHash = self.sdk.web3_client.transact_contract(
1076
+ self.sdk.identity_registry,
1077
+ "setApprovalForAll",
1078
+ operator,
1079
+ False
1080
+ )
1081
+
1082
+ return TransactionHandle(
1083
+ web3_client=self.sdk.web3_client,
1084
+ tx_hash=txHash,
1085
+ compute_result=lambda _receipt: {"txHash": txHash, "operator": operator},
1086
+ )
1087
+
1088
+ def transfer(self, newOwnerAddress: str) -> TransactionHandle[Dict[str, Any]]:
1089
+ """Transfer agent ownership to a new address.
1090
+
1091
+ Only the current owner can transfer the agent.
1092
+
1093
+ Note: When an agent is transferred, the agentWallet is automatically reset
1094
+ to the zero address on-chain. The new owner must call setWallet() to
1095
+ set a new wallet address with EIP-712 signature verification.
1096
+
1097
+ Args:
1098
+ newOwnerAddress: Ethereum address of the new owner
1099
+
1100
+ Returns:
1101
+ Transaction receipt
1102
+
1103
+ Raises:
1104
+ ValueError: If address is invalid or transfer not allowed
1105
+ """
1106
+ if not self.registration_file.agentId:
1107
+ raise ValueError("Agent must be registered before transfer")
1108
+
1109
+ # Validate new owner address
1110
+ if not newOwnerAddress or newOwnerAddress == "0x0000000000000000000000000000000000000000":
1111
+ raise ValueError("New owner address cannot be zero address")
1112
+
1113
+ # Get current owner using SDK utility
1114
+ currentOwner = self.sdk.getAgentOwner(self.registration_file.agentId)
1115
+
1116
+ # Check if caller is the current owner
1117
+ callerAddress = self.sdk.web3_client.account.address
1118
+ if callerAddress.lower() != currentOwner.lower():
1119
+ raise ValueError(f"Only the current owner ({currentOwner}) can transfer the agent")
1120
+
1121
+ # Prevent self-transfer
1122
+ if newOwnerAddress.lower() == currentOwner.lower():
1123
+ raise ValueError("Cannot transfer to the same owner")
1124
+
1125
+ # Validate address format (basic checksum validation)
1126
+ try:
1127
+ # Convert to checksum format for validation
1128
+ checksum_address = self.sdk.web3_client.w3.to_checksum_address(newOwnerAddress)
1129
+ except Exception as e:
1130
+ raise ValueError(f"Invalid address format: {e}")
1131
+
1132
+ logger.debug(f"Transferring agent {self.registration_file.agentId} from {currentOwner} to {checksum_address}")
1133
+
1134
+ # Parse agentId to extract tokenId for contract call
1135
+ agent_id_str = str(self.registration_file.agentId)
1136
+ if ":" in agent_id_str:
1137
+ token_id = int(agent_id_str.split(":")[-1])
1138
+ else:
1139
+ token_id = int(agent_id_str)
1140
+
1141
+ # Call transferFrom on the IdentityRegistry contract
1142
+ txHash = self.sdk.web3_client.transact_contract(
1143
+ self.sdk.identity_registry,
1144
+ "transferFrom",
1145
+ currentOwner,
1146
+ checksum_address,
1147
+ token_id
1148
+ )
1149
+
1150
+ def _apply(_receipt: Dict[str, Any]) -> Dict[str, Any]:
1151
+ logger.debug(f"Agent {self.registration_file.agentId} successfully transferred to {checksum_address}")
1152
+ self.registration_file.walletAddress = None
1153
+ self._last_registered_wallet = None
1154
+ self.registration_file.updatedAt = int(time.time())
1155
+ return {
1156
+ "txHash": txHash,
1157
+ "from": currentOwner,
1158
+ "to": checksum_address,
1159
+ "agentId": self.registration_file.agentId,
1160
+ }
1161
+
1162
+ return TransactionHandle(web3_client=self.sdk.web3_client, tx_hash=txHash, compute_result=_apply)
1163
+
1164
+ def activate(self, idem: Optional[IdemKey] = None) -> RegistrationFile:
1165
+ """Activate agent (soft "undelete")."""
1166
+ self.registration_file.active = True
1167
+ self.registration_file.updatedAt = int(time.time())
1168
+ return self.registration_file
1169
+
1170
+ def deactivate(self, idem: Optional[IdemKey] = None) -> RegistrationFile:
1171
+ """Deactivate agent (soft "delete")."""
1172
+ self.registration_file.active = False
1173
+ self.registration_file.updatedAt = int(time.time())
1174
+ return self.registration_file
1175
+
1176
+ # Utility methods
1177
+ def toJson(self) -> str:
1178
+ """Convert registration file to JSON."""
1179
+ return json.dumps(self.registration_file.to_dict(
1180
+ chain_id=self.sdk.chain_id(),
1181
+ identity_registry_address=self.sdk.identity_registry.address if self.sdk.identity_registry else None
1182
+ ), indent=2)
1183
+
1184
+ def saveToFile(self, filePath: str) -> None:
1185
+ """Save registration file to local file."""
1186
+ with open(filePath, 'w') as f:
1187
+ f.write(self.to_json())