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,1023 @@
1
+ """
2
+ Feedback management system for Agent0 SDK.
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, Tuple, Union
11
+ from datetime import datetime
12
+
13
+ from .models import (
14
+ AgentId, Address, URI, Timestamp, IdemKey,
15
+ Feedback, TrustModel, SearchFeedbackParams
16
+ )
17
+ from .web3_client import Web3Client
18
+ from .ipfs_client import IPFSClient
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class FeedbackManager:
24
+ """Manages feedback operations for the Agent0 SDK."""
25
+
26
+ def __init__(
27
+ self,
28
+ web3_client: Web3Client,
29
+ ipfs_client: Optional[IPFSClient] = None,
30
+ reputation_registry: Any = None,
31
+ identity_registry: Any = None,
32
+ subgraph_client: Optional[Any] = None,
33
+ indexer: Optional[Any] = None,
34
+ ):
35
+ """Initialize feedback manager."""
36
+ self.web3_client = web3_client
37
+ self.ipfs_client = ipfs_client
38
+ self.reputation_registry = reputation_registry
39
+ self.identity_registry = identity_registry
40
+ self.subgraph_client = subgraph_client
41
+ self.indexer = indexer
42
+
43
+ def signFeedbackAuth(
44
+ self,
45
+ agentId: AgentId,
46
+ clientAddress: Address,
47
+ indexLimit: Optional[int] = None,
48
+ expiryHours: int = 24,
49
+ ) -> bytes:
50
+ """Sign feedback authorization for a client."""
51
+ # Parse agent ID to get token ID
52
+ if ":" in agentId:
53
+ tokenId = int(agentId.split(":")[-1])
54
+ else:
55
+ tokenId = int(agentId)
56
+
57
+ # Get current feedback index if not provided
58
+ if indexLimit is None:
59
+ try:
60
+ lastIndex = self.web3_client.call_contract(
61
+ self.reputation_registry,
62
+ "getLastIndex",
63
+ tokenId,
64
+ clientAddress
65
+ )
66
+ indexLimit = lastIndex + 1
67
+ except Exception as e:
68
+ # If we can't get the index, default to 1 (for first feedback)
69
+ indexLimit = 1
70
+
71
+ # Calculate expiry timestamp
72
+ expiry = int(time.time()) + (expiryHours * 3600)
73
+
74
+ # Encode feedback auth data
75
+ authData = self.web3_client.encodeFeedbackAuth(
76
+ agentId=tokenId,
77
+ clientAddress=clientAddress,
78
+ indexLimit=indexLimit,
79
+ expiry=expiry,
80
+ chainId=self.web3_client.chain_id,
81
+ identityRegistry=self.identity_registry.address if self.identity_registry else "0x0",
82
+ signerAddress=self.web3_client.account.address
83
+ )
84
+
85
+ # Hash the encoded data first (matching contract's keccak256(abi.encode(...)))
86
+ messageHash = self.web3_client.w3.keccak(authData)
87
+
88
+ # Sign the hash with Ethereum signed message prefix (matching contract's .toEthSignedMessageHash())
89
+ from eth_account.messages import encode_defunct
90
+ signableMessage = encode_defunct(primitive=messageHash)
91
+ signedMessage = self.web3_client.account.sign_message(signableMessage)
92
+ signature = signedMessage.signature
93
+
94
+ # Combine auth data and signature
95
+ return authData + signature
96
+
97
+ def prepareFeedback(
98
+ self,
99
+ agentId: AgentId,
100
+ score: Optional[int] = None, # 0-100
101
+ tags: List[str] = None,
102
+ text: Optional[str] = None,
103
+ capability: Optional[str] = None,
104
+ name: Optional[str] = None,
105
+ skill: Optional[str] = None,
106
+ task: Optional[str] = None,
107
+ context: Optional[Dict[str, Any]] = None,
108
+ proofOfPayment: Optional[Dict[str, Any]] = None,
109
+ extra: Optional[Dict[str, Any]] = None,
110
+ ) -> Dict[str, Any]:
111
+ """Prepare feedback file (local file/object) according to spec."""
112
+ if tags is None:
113
+ tags = []
114
+
115
+ # Parse agent ID to get token ID
116
+ if ":" in agentId:
117
+ tokenId = int(agentId.split(":")[-1])
118
+ else:
119
+ tokenId = int(agentId)
120
+
121
+ # Get current timestamp in ISO format
122
+ createdAt = datetime.fromtimestamp(time.time()).isoformat() + "Z"
123
+
124
+ # Build feedback data according to spec
125
+ feedbackData = {
126
+ # MUST FIELDS
127
+ "agentRegistry": f"eip155:{self.web3_client.chain_id}:{self.identity_registry.address if self.identity_registry else '0x0'}",
128
+ "agentId": tokenId,
129
+ "clientAddress": f"eip155:{self.web3_client.chain_id}:{self.web3_client.account.address}",
130
+ "createdAt": createdAt,
131
+ "feedbackAuth": "", # Will be filled when giving feedback
132
+ "score": int(score) if score else 0, # Score as integer (0-100)
133
+
134
+ # MAY FIELDS
135
+ "tag1": tags[0] if tags else None,
136
+ "tag2": tags[1] if len(tags) > 1 else None,
137
+ "skill": skill,
138
+ "context": context,
139
+ "task": task,
140
+ "capability": capability,
141
+ "name": name,
142
+ "proofOfPayment": proofOfPayment,
143
+ }
144
+
145
+ # Remove None values to keep the structure clean
146
+ feedbackData = {k: v for k, v in feedbackData.items() if v is not None}
147
+
148
+ if extra:
149
+ feedbackData.update(extra)
150
+
151
+ return feedbackData
152
+
153
+ def giveFeedback(
154
+ self,
155
+ agentId: AgentId,
156
+ feedbackFile: Dict[str, Any],
157
+ idem: Optional[IdemKey] = None,
158
+ feedbackAuth: Optional[bytes] = None,
159
+ ) -> Feedback:
160
+ """Give feedback (maps 8004 endpoint)."""
161
+ # Parse agent ID
162
+ if ":" in agentId:
163
+ tokenId = int(agentId.split(":")[-1])
164
+ else:
165
+ tokenId = int(agentId)
166
+
167
+ # Get client address (the one giving feedback)
168
+ # Keep in checksum format for blockchain calls (web3.py requirement)
169
+ clientAddress = self.web3_client.account.address
170
+
171
+ # Get current feedback index for this client-agent pair
172
+ try:
173
+ lastIndex = self.web3_client.call_contract(
174
+ self.reputation_registry,
175
+ "getLastIndex",
176
+ tokenId,
177
+ clientAddress
178
+ )
179
+ feedbackIndex = lastIndex + 1
180
+ except Exception as e:
181
+ raise ValueError(f"Failed to get feedback index: {e}")
182
+
183
+ # Prepare feedback auth (use provided auth or create new one)
184
+ if feedbackAuth is None:
185
+ feedbackAuth = self.sign_feedbackAuth(
186
+ agentId=agentId,
187
+ clientAddress=clientAddress,
188
+ indexLimit=feedbackIndex,
189
+ expiryHours=24
190
+ )
191
+
192
+ # Update feedback file with auth
193
+ feedbackFile["feedbackAuth"] = feedbackAuth.hex()
194
+
195
+ # Prepare on-chain data (only basic fields, no capability/endpoint)
196
+ score = feedbackFile.get("score", 0) # Already in 0-100 range
197
+ tag1 = self._stringToBytes32(feedbackFile.get("tag1", ""))
198
+ tag2 = self._stringToBytes32(feedbackFile.get("tag2", ""))
199
+
200
+ # Handle off-chain file storage
201
+ feedbackUri = ""
202
+ feedbackHash = b"\x00" * 32 # Default empty hash
203
+
204
+ if self.ipfs_client:
205
+ # Store feedback file on IPFS using Filecoin Pin
206
+ try:
207
+ logger.debug("Storing feedback file on IPFS")
208
+ cid = self.ipfs_client.add_json(feedbackFile)
209
+ feedbackUri = f"ipfs://{cid}"
210
+ feedbackHash = self.web3_client.keccak256(json.dumps(feedbackFile, sort_keys=True).encode())
211
+ logger.debug(f"Feedback file stored on IPFS: {cid}")
212
+ except Exception as e:
213
+ logger.warning(f"Failed to store feedback on IPFS: {e}")
214
+ # Continue without IPFS storage
215
+ elif feedbackFile.get("context") or feedbackFile.get("capability") or feedbackFile.get("name"):
216
+ # If we have rich data but no IPFS, we need to store it somewhere
217
+ raise ValueError("Rich feedback data requires IPFS client for storage")
218
+
219
+ # Submit to blockchain
220
+ try:
221
+ txHash = self.web3_client.transact_contract(
222
+ self.reputation_registry,
223
+ "giveFeedback",
224
+ tokenId,
225
+ score,
226
+ tag1,
227
+ tag2,
228
+ feedbackUri,
229
+ feedbackHash,
230
+ feedbackAuth
231
+ )
232
+
233
+ # Wait for transaction confirmation
234
+ receipt = self.web3_client.wait_for_transaction(txHash)
235
+
236
+ except Exception as e:
237
+ raise ValueError(f"Failed to submit feedback to blockchain: {e}")
238
+
239
+ # Create feedback object (address normalization happens in Feedback.create_id)
240
+ feedbackId = Feedback.create_id(agentId, clientAddress, feedbackIndex)
241
+
242
+ return Feedback(
243
+ id=feedbackId,
244
+ agentId=agentId,
245
+ reviewer=clientAddress, # create_id normalizes the ID; reviewer field can remain as-is
246
+ score=int(score) if score and score > 0 else None,
247
+ tags=[feedbackFile.get("tag1"), feedbackFile.get("tag2")] if feedbackFile.get("tag1") else [],
248
+ text=feedbackFile.get("text"),
249
+ context=feedbackFile.get("context"),
250
+ proofOfPayment=feedbackFile.get("proofOfPayment"),
251
+ fileURI=feedbackUri if feedbackUri else None,
252
+ createdAt=int(time.time()),
253
+ isRevoked=False,
254
+ # Off-chain only fields
255
+ capability=feedbackFile.get("capability"),
256
+ name=feedbackFile.get("name"),
257
+ skill=feedbackFile.get("skill"),
258
+ task=feedbackFile.get("task")
259
+ )
260
+
261
+ def getFeedback(
262
+ self,
263
+ agentId: AgentId,
264
+ clientAddress: Address,
265
+ feedbackIndex: int,
266
+ ) -> Feedback:
267
+ """Get single feedback with responses from subgraph or blockchain."""
268
+ # Use indexer for subgraph queries (unified search interface)
269
+ if self.indexer and self.subgraph_client:
270
+ # Indexer handles subgraph queries for unified search architecture
271
+ # This enables future semantic search capabilities
272
+ return self.indexer.get_feedback(agentId, clientAddress, feedbackIndex)
273
+
274
+ # Fallback: direct subgraph access (if indexer not available)
275
+ if self.subgraph_client:
276
+ return self._get_feedback_from_subgraph(agentId, clientAddress, feedbackIndex)
277
+
278
+ # Fallback to blockchain (direct contract query)
279
+ return self._get_feedback_from_blockchain(agentId, clientAddress, feedbackIndex)
280
+
281
+ def _get_feedback_from_subgraph(
282
+ self,
283
+ agentId: AgentId,
284
+ clientAddress: Address,
285
+ feedbackIndex: int,
286
+ ) -> Feedback:
287
+ """Get feedback from subgraph."""
288
+ # Normalize addresses to lowercase for consistent storage
289
+ normalized_client_address = self.web3_client.normalize_address(clientAddress)
290
+
291
+ # Build feedback ID in format: chainId:agentId:clientAddress:feedbackIndex
292
+ # If agentId already contains chainId (format: chainId:tokenId), use it as is
293
+ # Otherwise, prepend chainId from web3_client
294
+ if ":" in agentId:
295
+ # agentId already has chainId, so use it directly
296
+ feedback_id = f"{agentId}:{normalized_client_address}:{feedbackIndex}"
297
+ else:
298
+ # No chainId in agentId, prepend it
299
+ chain_id = str(self.web3_client.chain_id)
300
+ feedback_id = f"{chain_id}:{agentId}:{normalized_client_address}:{feedbackIndex}"
301
+
302
+ try:
303
+ feedback_data = self.subgraph_client.get_feedback_by_id(feedback_id)
304
+
305
+ if feedback_data is None:
306
+ raise ValueError(f"Feedback {feedback_id} not found in subgraph")
307
+
308
+ feedback_file = feedback_data.get('feedbackFile') or {}
309
+ if not isinstance(feedback_file, dict):
310
+ feedback_file = {}
311
+
312
+ # Map responses
313
+ responses_data = feedback_data.get('responses', [])
314
+ answers = []
315
+ for resp in responses_data:
316
+ answers.append({
317
+ 'responder': resp.get('responder'),
318
+ 'responseUri': resp.get('responseUri'),
319
+ 'responseHash': resp.get('responseHash'),
320
+ 'createdAt': resp.get('createdAt')
321
+ })
322
+
323
+ # Map tags - check if they're hex bytes32 or plain strings
324
+ tags = []
325
+ tag1 = feedback_data.get('tag1') or feedback_file.get('tag1')
326
+ tag2 = feedback_data.get('tag2') or feedback_file.get('tag2')
327
+
328
+ # Convert hex bytes32 to readable tags
329
+ if tag1 or tag2:
330
+ tags = self._hexBytes32ToTags(
331
+ tag1 if isinstance(tag1, str) else "",
332
+ tag2 if isinstance(tag2, str) else ""
333
+ )
334
+
335
+ # If conversion failed, try as plain strings
336
+ if not tags:
337
+ if tag1 and not tag1.startswith("0x"):
338
+ tags.append(tag1)
339
+ if tag2 and not tag2.startswith("0x"):
340
+ tags.append(tag2)
341
+
342
+ return Feedback(
343
+ id=Feedback.create_id(agentId, clientAddress, feedbackIndex), # create_id now normalizes
344
+ agentId=agentId,
345
+ reviewer=self.web3_client.normalize_address(clientAddress), # Also normalize reviewer field
346
+ score=feedback_data.get('score'),
347
+ tags=tags,
348
+ text=feedback_file.get('text'),
349
+ capability=feedback_file.get('capability'),
350
+ context=feedback_file.get('context'),
351
+ proofOfPayment={
352
+ 'fromAddress': feedback_file.get('proofOfPaymentFromAddress'),
353
+ 'toAddress': feedback_file.get('proofOfPaymentToAddress'),
354
+ 'chainId': feedback_file.get('proofOfPaymentChainId'),
355
+ 'txHash': feedback_file.get('proofOfPaymentTxHash'),
356
+ } if feedback_file.get('proofOfPaymentFromAddress') else None,
357
+ fileURI=feedback_data.get('feedbackUri'),
358
+ createdAt=feedback_data.get('createdAt', int(time.time())),
359
+ answers=answers,
360
+ isRevoked=feedback_data.get('isRevoked', False),
361
+ name=feedback_file.get('name'),
362
+ skill=feedback_file.get('skill'),
363
+ task=feedback_file.get('task'),
364
+ )
365
+
366
+ except Exception as e:
367
+ raise ValueError(f"Failed to get feedback from subgraph: {e}")
368
+
369
+ def _get_feedback_from_blockchain(
370
+ self,
371
+ agentId: AgentId,
372
+ clientAddress: Address,
373
+ feedbackIndex: int,
374
+ ) -> Feedback:
375
+ """Get feedback from blockchain (fallback)."""
376
+ # Parse agent ID
377
+ if ":" in agentId:
378
+ tokenId = int(agentId.split(":")[-1])
379
+ else:
380
+ tokenId = int(agentId)
381
+
382
+ try:
383
+ # Read from blockchain
384
+ result = self.web3_client.call_contract(
385
+ self.reputation_registry,
386
+ "readFeedback",
387
+ tokenId,
388
+ clientAddress,
389
+ feedbackIndex
390
+ )
391
+
392
+ score, tag1, tag2, is_revoked = result
393
+
394
+ # Create feedback object (normalize address for consistency)
395
+ normalized_address = self.web3_client.normalize_address(clientAddress)
396
+ feedbackId = Feedback.create_id(agentId, normalized_address, feedbackIndex)
397
+
398
+ return Feedback(
399
+ id=feedbackId,
400
+ agentId=agentId,
401
+ reviewer=normalized_address,
402
+ score=int(score) if score and score > 0 else None,
403
+ tags=self._bytes32ToTags(tag1, tag2),
404
+ text=None, # Not stored on-chain
405
+ capability=None, # Not stored on-chain
406
+ context=None, # Not stored on-chain
407
+ proofOfPayment=None, # Not stored on-chain
408
+ fileURI=None, # Would need to be retrieved separately
409
+ createdAt=int(time.time()), # Not stored on-chain
410
+ isRevoked=is_revoked
411
+ )
412
+
413
+ except Exception as e:
414
+ raise ValueError(f"Failed to get feedback: {e}")
415
+
416
+ def searchFeedback(
417
+ self,
418
+ agentId: AgentId,
419
+ clientAddresses: Optional[List[Address]] = None,
420
+ tags: Optional[List[str]] = None,
421
+ capabilities: Optional[List[str]] = None,
422
+ skills: Optional[List[str]] = None,
423
+ tasks: Optional[List[str]] = None,
424
+ names: Optional[List[str]] = None,
425
+ minScore: Optional[int] = None,
426
+ maxScore: Optional[int] = None,
427
+ include_revoked: bool = False,
428
+ first: int = 100,
429
+ skip: int = 0,
430
+ ) -> List[Feedback]:
431
+ """Search feedback for an agent - uses subgraph if available."""
432
+ # Use indexer for subgraph queries (unified search interface)
433
+ if self.indexer and self.subgraph_client:
434
+ # Indexer handles subgraph queries for unified search architecture
435
+ # This enables future semantic search capabilities
436
+ return self.indexer.search_feedback(
437
+ agentId, clientAddresses, tags, capabilities, skills, tasks, names,
438
+ minScore, maxScore, include_revoked, first, skip
439
+ )
440
+
441
+ # Fallback: direct subgraph access (if indexer not available)
442
+ if self.subgraph_client:
443
+ return self._search_feedback_subgraph(
444
+ agentId, clientAddresses, tags, capabilities, skills, tasks, names,
445
+ minScore, maxScore, include_revoked, first, skip
446
+ )
447
+
448
+ # Fallback to blockchain
449
+ # Parse agent ID
450
+ if ":" in agentId:
451
+ tokenId = int(agentId.split(":")[-1])
452
+ else:
453
+ tokenId = int(agentId)
454
+
455
+ try:
456
+ # Prepare filter parameters
457
+ client_list = clientAddresses if clientAddresses else []
458
+ tag1_filter = self._stringToBytes32(tags[0] if tags else "")
459
+ tag2_filter = self._stringToBytes32(tags[1] if tags and len(tags) > 1 else "")
460
+
461
+ # Read from blockchain
462
+ result = self.web3_client.call_contract(
463
+ self.reputation_registry,
464
+ "readAllFeedback",
465
+ tokenId,
466
+ client_list,
467
+ tag1_filter,
468
+ tag2_filter,
469
+ include_revoked
470
+ )
471
+
472
+ clients, scores, tag1s, tag2s, revoked_statuses = result
473
+
474
+ # Convert to Feedback objects
475
+ feedbacks = []
476
+ for i in range(len(clients)):
477
+ feedbackId = Feedback.create_id(agentId, clients[i], i + 1) # Assuming 1-indexed
478
+
479
+ feedback = Feedback(
480
+ id=feedbackId,
481
+ agentId=agentId,
482
+ reviewer=clients[i],
483
+ score=int(scores[i]) if scores[i] and scores[i] > 0 else None,
484
+ tags=self._bytes32ToTags(tag1s[i], tag2s[i]),
485
+ text=None,
486
+ capability=None,
487
+ endpoint=None,
488
+ context=None,
489
+ proofOfPayment=None,
490
+ fileURI=None,
491
+ createdAt=int(time.time()),
492
+ isRevoked=revoked_statuses[i]
493
+ )
494
+ feedbacks.append(feedback)
495
+
496
+ return feedbacks
497
+
498
+ except Exception as e:
499
+ raise ValueError(f"Failed to search feedback: {e}")
500
+
501
+ def _search_feedback_subgraph(
502
+ self,
503
+ agentId: AgentId,
504
+ clientAddresses: Optional[List[Address]],
505
+ tags: Optional[List[str]],
506
+ capabilities: Optional[List[str]],
507
+ skills: Optional[List[str]],
508
+ tasks: Optional[List[str]],
509
+ names: Optional[List[str]],
510
+ minScore: Optional[int],
511
+ maxScore: Optional[int],
512
+ include_revoked: bool,
513
+ first: int,
514
+ skip: int,
515
+ ) -> List[Feedback]:
516
+ """Search feedback using subgraph."""
517
+ # Create SearchFeedbackParams
518
+ params = SearchFeedbackParams(
519
+ agents=[agentId],
520
+ reviewers=clientAddresses,
521
+ tags=tags,
522
+ capabilities=capabilities,
523
+ skills=skills,
524
+ tasks=tasks,
525
+ names=names,
526
+ minScore=minScore,
527
+ maxScore=maxScore,
528
+ includeRevoked=include_revoked
529
+ )
530
+
531
+ # Query subgraph
532
+ feedbacks_data = self.subgraph_client.search_feedback(
533
+ params=params,
534
+ first=first,
535
+ skip=skip,
536
+ order_by="createdAt",
537
+ order_direction="desc"
538
+ )
539
+
540
+ # Map to Feedback objects
541
+ feedbacks = []
542
+ for fb_data in feedbacks_data:
543
+ feedback_file = fb_data.get('feedbackFile') or {}
544
+ if not isinstance(feedback_file, dict):
545
+ feedback_file = {}
546
+
547
+ # Map responses
548
+ responses_data = fb_data.get('responses', [])
549
+ answers = []
550
+ for resp in responses_data:
551
+ answers.append({
552
+ 'responder': resp.get('responder'),
553
+ 'responseUri': resp.get('responseUri'),
554
+ 'responseHash': resp.get('responseHash'),
555
+ 'createdAt': resp.get('createdAt')
556
+ })
557
+
558
+ # Map tags - check if they're hex bytes32 or plain strings
559
+ tags_list = []
560
+ tag1 = fb_data.get('tag1') or feedback_file.get('tag1')
561
+ tag2 = fb_data.get('tag2') or feedback_file.get('tag2')
562
+
563
+ # Convert hex bytes32 to readable tags
564
+ if tag1 or tag2:
565
+ tags_list = self._hexBytes32ToTags(
566
+ tag1 if isinstance(tag1, str) else "",
567
+ tag2 if isinstance(tag2, str) else ""
568
+ )
569
+
570
+ # If conversion failed, try as plain strings
571
+ if not tags_list:
572
+ if tag1 and not tag1.startswith("0x"):
573
+ tags_list.append(tag1)
574
+ if tag2 and not tag2.startswith("0x"):
575
+ tags_list.append(tag2)
576
+
577
+ # Parse agentId from feedback ID
578
+ feedback_id = fb_data['id']
579
+ parts = feedback_id.split(':')
580
+ if len(parts) >= 2:
581
+ agent_id_str = f"{parts[0]}:{parts[1]}"
582
+ client_addr = parts[2] if len(parts) > 2 else ""
583
+ feedback_idx = int(parts[3]) if len(parts) > 3 else 1
584
+ else:
585
+ agent_id_str = feedback_id
586
+ client_addr = ""
587
+ feedback_idx = 1
588
+
589
+ feedback = Feedback(
590
+ id=Feedback.create_id(agent_id_str, client_addr, feedback_idx),
591
+ agentId=agent_id_str,
592
+ reviewer=client_addr,
593
+ score=fb_data.get('score'),
594
+ tags=tags_list,
595
+ text=feedback_file.get('text'),
596
+ capability=feedback_file.get('capability'),
597
+ context=feedback_file.get('context'),
598
+ proofOfPayment={
599
+ 'fromAddress': feedback_file.get('proofOfPaymentFromAddress'),
600
+ 'toAddress': feedback_file.get('proofOfPaymentToAddress'),
601
+ 'chainId': feedback_file.get('proofOfPaymentChainId'),
602
+ 'txHash': feedback_file.get('proofOfPaymentTxHash'),
603
+ } if feedback_file.get('proofOfPaymentFromAddress') else None,
604
+ fileURI=fb_data.get('feedbackUri'),
605
+ createdAt=fb_data.get('createdAt', int(time.time())),
606
+ answers=answers,
607
+ isRevoked=fb_data.get('isRevoked', False),
608
+ name=feedback_file.get('name'),
609
+ skill=feedback_file.get('skill'),
610
+ task=feedback_file.get('task'),
611
+ )
612
+ feedbacks.append(feedback)
613
+
614
+ return feedbacks
615
+
616
+ def revokeFeedback(
617
+ self,
618
+ agentId: AgentId,
619
+ feedbackIndex: int,
620
+ ) -> Dict[str, Any]:
621
+ """Revoke feedback."""
622
+ # Parse agent ID
623
+ if ":" in agentId:
624
+ tokenId = int(agentId.split(":")[-1])
625
+ else:
626
+ tokenId = int(agentId)
627
+
628
+ clientAddress = self.web3_client.account.address
629
+
630
+ try:
631
+ txHash = self.web3_client.transact_contract(
632
+ self.reputation_registry,
633
+ "revokeFeedback",
634
+ tokenId,
635
+ feedbackIndex
636
+ )
637
+
638
+ receipt = self.web3_client.wait_for_transaction(txHash)
639
+
640
+ return {
641
+ "txHash": txHash,
642
+ "agentId": agentId,
643
+ "clientAddress": clientAddress,
644
+ "feedbackIndex": feedbackIndex,
645
+ "status": "revoked"
646
+ }
647
+
648
+ except Exception as e:
649
+ raise ValueError(f"Failed to revoke feedback: {e}")
650
+
651
+ def appendResponse(
652
+ self,
653
+ agentId: AgentId,
654
+ clientAddress: Address,
655
+ feedbackIndex: int,
656
+ response: Dict[str, Any],
657
+ ) -> Feedback:
658
+ """Append a response/follow-up to existing feedback."""
659
+ # Parse agent ID
660
+ if ":" in agentId:
661
+ tokenId = int(agentId.split(":")[-1])
662
+ else:
663
+ tokenId = int(agentId)
664
+
665
+ # Prepare response data
666
+ responseText = response.get("text", "")
667
+ responseUri = ""
668
+ responseHash = b"\x00" * 32
669
+
670
+ if self.ipfs_client and (response.get("text") or response.get("attachments")):
671
+ try:
672
+ cid = self.ipfs_client.add_json(response)
673
+ responseUri = f"ipfs://{cid}"
674
+ responseHash = self.web3_client.keccak256(json.dumps(response, sort_keys=True).encode())
675
+ except Exception as e:
676
+ logger.warning(f"Failed to store response on IPFS: {e}")
677
+
678
+ try:
679
+ txHash = self.web3_client.transact_contract(
680
+ self.reputation_registry,
681
+ "appendResponse",
682
+ tokenId,
683
+ clientAddress,
684
+ feedbackIndex,
685
+ responseUri,
686
+ responseHash
687
+ )
688
+
689
+ receipt = self.web3_client.wait_for_transaction(txHash)
690
+
691
+ # Read updated feedback
692
+ return self.getFeedback(agentId, clientAddress, feedbackIndex)
693
+
694
+ except Exception as e:
695
+ raise ValueError(f"Failed to append response: {e}")
696
+
697
+ def getReputationSummary(
698
+ self,
699
+ agentId: AgentId,
700
+ clientAddresses: Optional[List[Address]] = None,
701
+ tag1: Optional[str] = None,
702
+ tag2: Optional[str] = None,
703
+ groupBy: Optional[List[str]] = None,
704
+ ) -> Dict[str, Any]:
705
+ """Get reputation summary for an agent with optional grouping."""
706
+ # Parse chainId from agentId
707
+ chain_id = None
708
+ if ":" in agentId:
709
+ try:
710
+ chain_id = int(agentId.split(":", 1)[0])
711
+ except ValueError:
712
+ chain_id = None
713
+
714
+ # Try subgraph first (if available and indexer supports it)
715
+ if self.indexer and self.subgraph_client:
716
+ # Get correct subgraph client for the chain
717
+ subgraph_client = None
718
+ full_agent_id = agentId
719
+
720
+ if chain_id is not None:
721
+ subgraph_client = self.indexer._get_subgraph_client_for_chain(chain_id)
722
+ else:
723
+ # No chainId in agentId, use SDK's default
724
+ # Construct full agentId format for subgraph query
725
+ default_chain_id = self.web3_client.chain_id
726
+ token_id = agentId.split(":")[-1] if ":" in agentId else agentId
727
+ full_agent_id = f"{default_chain_id}:{token_id}"
728
+ subgraph_client = self.subgraph_client
729
+
730
+ if subgraph_client:
731
+ # Use subgraph to calculate reputation
732
+ return self._get_reputation_summary_from_subgraph(
733
+ full_agent_id, clientAddresses, tag1, tag2, groupBy
734
+ )
735
+
736
+ # Fallback to blockchain (requires chain-specific web3 client)
737
+ # For now, only works if chain matches SDK's default
738
+ if chain_id is not None and chain_id != self.web3_client.chain_id:
739
+ raise ValueError(
740
+ f"Blockchain reputation summary not supported for chain {chain_id}. "
741
+ f"SDK is configured for chain {self.web3_client.chain_id}. "
742
+ f"Use subgraph-based summary instead."
743
+ )
744
+
745
+ # Parse agent ID for blockchain call
746
+ if ":" in agentId:
747
+ tokenId = int(agentId.split(":")[-1])
748
+ else:
749
+ tokenId = int(agentId)
750
+
751
+ try:
752
+ client_list = clientAddresses if clientAddresses else []
753
+ tag1_bytes = self._stringToBytes32(tag1) if tag1 else b"\x00" * 32
754
+ tag2_bytes = self._stringToBytes32(tag2) if tag2 else b"\x00" * 32
755
+
756
+ result = self.web3_client.call_contract(
757
+ self.reputation_registry,
758
+ "getSummary",
759
+ tokenId,
760
+ client_list,
761
+ tag1_bytes,
762
+ tag2_bytes
763
+ )
764
+
765
+ count, average_score = result
766
+
767
+ # If no grouping requested, return simple summary
768
+ if not groupBy:
769
+ return {
770
+ "agentId": agentId,
771
+ "count": count,
772
+ "averageScore": float(average_score) / 100.0 if average_score > 0 else 0.0,
773
+ "filters": {
774
+ "clientAddresses": clientAddresses,
775
+ "tag1": tag1,
776
+ "tag2": tag2
777
+ }
778
+ }
779
+
780
+ # Get detailed feedback data for grouping
781
+ all_feedback = self.read_all_feedback(
782
+ agentId=agentId,
783
+ clientAddresses=clientAddresses,
784
+ tags=[tag1, tag2] if tag1 or tag2 else None,
785
+ include_revoked=False
786
+ )
787
+
788
+ # Group feedback by requested dimensions
789
+ grouped_data = self._groupFeedback(all_feedback, groupBy)
790
+
791
+ return {
792
+ "agentId": agentId,
793
+ "totalCount": count,
794
+ "totalAverageScore": float(average_score) / 100.0 if average_score > 0 else 0.0,
795
+ "groupedData": grouped_data,
796
+ "filters": {
797
+ "clientAddresses": clientAddresses,
798
+ "tag1": tag1,
799
+ "tag2": tag2
800
+ },
801
+ "groupBy": groupBy
802
+ }
803
+
804
+ except Exception as e:
805
+ raise ValueError(f"Failed to get reputation summary: {e}")
806
+
807
+ def _get_reputation_summary_from_subgraph(
808
+ self,
809
+ agentId: AgentId,
810
+ clientAddresses: Optional[List[Address]] = None,
811
+ tag1: Optional[str] = None,
812
+ tag2: Optional[str] = None,
813
+ groupBy: Optional[List[str]] = None,
814
+ ) -> Dict[str, Any]:
815
+ """Get reputation summary from subgraph."""
816
+ # Build tags list
817
+ tags = []
818
+ if tag1:
819
+ tags.append(tag1)
820
+ if tag2:
821
+ tags.append(tag2)
822
+
823
+ # Get all feedback for the agent using indexer (which handles multi-chain)
824
+ # Use searchFeedback with a large limit to get all feedback
825
+ all_feedback = self.searchFeedback(
826
+ agentId=agentId,
827
+ clientAddresses=clientAddresses,
828
+ tags=tags if tags else None,
829
+ include_revoked=False,
830
+ first=1000, # Large limit to get all feedback
831
+ skip=0
832
+ )
833
+
834
+ # Calculate summary statistics
835
+ count = len(all_feedback)
836
+ scores = [fb.score for fb in all_feedback if fb.score is not None]
837
+ average_score = sum(scores) / len(scores) if scores else 0.0
838
+
839
+ # If no grouping requested, return simple summary
840
+ if not groupBy:
841
+ return {
842
+ "agentId": agentId,
843
+ "count": count,
844
+ "averageScore": average_score,
845
+ "filters": {
846
+ "clientAddresses": clientAddresses,
847
+ "tag1": tag1,
848
+ "tag2": tag2
849
+ }
850
+ }
851
+
852
+ # Group feedback by requested dimensions
853
+ grouped_data = self._groupFeedback(all_feedback, groupBy)
854
+
855
+ return {
856
+ "agentId": agentId,
857
+ "totalCount": count,
858
+ "totalAverageScore": average_score,
859
+ "groupedData": grouped_data,
860
+ "filters": {
861
+ "clientAddresses": clientAddresses,
862
+ "tag1": tag1,
863
+ "tag2": tag2
864
+ },
865
+ "groupBy": groupBy
866
+ }
867
+
868
+ def _groupFeedback(self, feedbackList: List[Feedback], groupBy: List[str]) -> Dict[str, Any]:
869
+ """Group feedback by specified dimensions."""
870
+ grouped = {}
871
+
872
+ for feedback in feedbackList:
873
+ # Create group key based on requested dimensions
874
+ group_key = self._createGroupKey(feedback, groupBy)
875
+
876
+ if group_key not in grouped:
877
+ grouped[group_key] = {
878
+ "count": 0,
879
+ "totalScore": 0.0,
880
+ "averageScore": 0.0,
881
+ "scores": [],
882
+ "feedback": []
883
+ }
884
+
885
+ # Add feedback to group
886
+ grouped[group_key]["count"] += 1
887
+ if feedback.score is not None:
888
+ grouped[group_key]["totalScore"] += feedback.score
889
+ grouped[group_key]["scores"].append(feedback.score)
890
+ grouped[group_key]["feedback"].append(feedback)
891
+
892
+ # Calculate averages for each group
893
+ for group_data in grouped.values():
894
+ if group_data["count"] > 0:
895
+ group_data["averageScore"] = group_data["totalScore"] / group_data["count"]
896
+
897
+ return grouped
898
+
899
+ def _createGroupKey(self, feedback: Feedback, groupBy: List[str]) -> str:
900
+ """Create a group key for feedback based on grouping dimensions."""
901
+ key_parts = []
902
+
903
+ for dimension in groupBy:
904
+ if dimension == "tag":
905
+ # Group by tags
906
+ if feedback.tags:
907
+ key_parts.append(f"tags:{','.join(feedback.tags)}")
908
+ else:
909
+ key_parts.append("tags:none")
910
+ elif dimension == "capability":
911
+ # Group by MCP capability
912
+ if feedback.capability:
913
+ key_parts.append(f"capability:{feedback.capability}")
914
+ else:
915
+ key_parts.append("capability:none")
916
+ elif dimension == "skill":
917
+ # Group by A2A skill
918
+ if feedback.skill:
919
+ key_parts.append(f"skill:{feedback.skill}")
920
+ else:
921
+ key_parts.append("skill:none")
922
+ elif dimension == "task":
923
+ # Group by A2A task
924
+ if feedback.task:
925
+ key_parts.append(f"task:{feedback.task}")
926
+ else:
927
+ key_parts.append("task:none")
928
+ elif dimension == "endpoint":
929
+ # Group by endpoint (from context or capability)
930
+ endpoint = None
931
+ if feedback.context and "endpoint" in feedback.context:
932
+ endpoint = feedback.context["endpoint"]
933
+ elif feedback.capability:
934
+ endpoint = f"mcp:{feedback.capability}"
935
+
936
+ if endpoint:
937
+ key_parts.append(f"endpoint:{endpoint}")
938
+ else:
939
+ key_parts.append("endpoint:none")
940
+ elif dimension == "time":
941
+ # Group by time periods (daily, weekly, monthly)
942
+ from datetime import datetime
943
+ createdAt = datetime.fromtimestamp(feedback.createdAt)
944
+ key_parts.append(f"time:{createdAt.strftime('%Y-%m')}") # Monthly grouping
945
+ else:
946
+ # Unknown dimension, use as-is
947
+ key_parts.append(f"{dimension}:unknown")
948
+
949
+ return "|".join(key_parts)
950
+
951
+ def _stringToBytes32(self, text: str) -> bytes:
952
+ """Convert string to bytes32 for blockchain storage."""
953
+ if not text:
954
+ return b"\x00" * 32
955
+
956
+ # Encode as UTF-8 and pad/truncate to 32 bytes
957
+ encoded = text.encode('utf-8')
958
+ if len(encoded) > 32:
959
+ encoded = encoded[:32]
960
+ else:
961
+ encoded = encoded.ljust(32, b'\x00')
962
+
963
+ return encoded
964
+
965
+ def _bytes32ToTags(self, tag1: bytes, tag2: bytes) -> List[str]:
966
+ """Convert bytes32 tags back to strings."""
967
+ tags = []
968
+
969
+ if tag1 and tag1 != b"\x00" * 32:
970
+ tag1_str = tag1.rstrip(b'\x00').decode('utf-8', errors='ignore')
971
+ if tag1_str:
972
+ tags.append(tag1_str)
973
+
974
+ if tag2 and tag2 != b"\x00" * 32:
975
+ tag2_str = tag2.rstrip(b'\x00').decode('utf-8', errors='ignore')
976
+ if tag2_str:
977
+ tags.append(tag2_str)
978
+
979
+ return tags
980
+
981
+ def _hexBytes32ToTags(self, tag1: str, tag2: str) -> List[str]:
982
+ """Convert hex bytes32 tags back to strings, or return plain strings as-is.
983
+
984
+ The subgraph now stores tags as human-readable strings (not hex),
985
+ so this method handles both formats for backwards compatibility.
986
+ """
987
+ tags = []
988
+
989
+ if tag1 and tag1 != "0x" + "00" * 32:
990
+ # If it's already a plain string (from subgraph), use it directly
991
+ if not tag1.startswith("0x"):
992
+ if tag1:
993
+ tags.append(tag1)
994
+ else:
995
+ # Try to convert from hex bytes32 (on-chain format)
996
+ try:
997
+ # Remove 0x prefix if present
998
+ hex_bytes = bytes.fromhex(tag1[2:])
999
+ tag1_str = hex_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
1000
+ if tag1_str:
1001
+ tags.append(tag1_str)
1002
+ except Exception as e:
1003
+ pass # Ignore invalid hex strings
1004
+
1005
+ if tag2 and tag2 != "0x" + "00" * 32:
1006
+ # If it's already a plain string (from subgraph), use it directly
1007
+ if not tag2.startswith("0x"):
1008
+ if tag2:
1009
+ tags.append(tag2)
1010
+ else:
1011
+ # Try to convert from hex bytes32 (on-chain format)
1012
+ try:
1013
+ if tag2.startswith("0x"):
1014
+ hex_bytes = bytes.fromhex(tag2[2:])
1015
+ else:
1016
+ hex_bytes = bytes.fromhex(tag2)
1017
+ tag2_str = hex_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
1018
+ if tag2_str:
1019
+ tags.append(tag2_str)
1020
+ except Exception as e:
1021
+ pass # Ignore invalid hex strings
1022
+
1023
+ return tags