nosocial-langgraph 0.1.0__tar.gz
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.
- nosocial_langgraph-0.1.0/PKG-INFO +93 -0
- nosocial_langgraph-0.1.0/README.md +65 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph/__init__.py +4 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph/handler.py +230 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph/identity.py +90 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph/mapping.py +48 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph.egg-info/PKG-INFO +93 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph.egg-info/SOURCES.txt +14 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph.egg-info/dependency_links.txt +1 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph.egg-info/requires.txt +8 -0
- nosocial_langgraph-0.1.0/nosocial_langgraph.egg-info/top_level.txt +1 -0
- nosocial_langgraph-0.1.0/pyproject.toml +42 -0
- nosocial_langgraph-0.1.0/setup.cfg +4 -0
- nosocial_langgraph-0.1.0/tests/test_handler.py +206 -0
- nosocial_langgraph-0.1.0/tests/test_identity.py +50 -0
- nosocial_langgraph-0.1.0/tests/test_mapping.py +70 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nosocial-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NoSocial reputation reporting for LangGraph/LangChain — auto-reports agent interactions to the NoSocial oracle
|
|
5
|
+
Author-email: NoSocial <hello@nosocial.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nosocial.me
|
|
8
|
+
Project-URL: Repository, https://github.com/pcdkd/nosocial-protocol
|
|
9
|
+
Project-URL: Specification, https://nosocial.me/extensions/agent-profile
|
|
10
|
+
Keywords: nosocial,langgraph,langchain,agent,reputation,a2a
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: langgraph>=0.2.0
|
|
22
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: cryptography>=42.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# nosocial-langgraph
|
|
30
|
+
|
|
31
|
+
NoSocial reputation reporting for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com). Add a callback handler — your graph nodes and tools build reputation automatically.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install nosocial-langgraph
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from nosocial_langgraph import NoSocialCallbackHandler
|
|
43
|
+
|
|
44
|
+
handler = NoSocialCallbackHandler(oracle_url="https://api.nosocial.me")
|
|
45
|
+
|
|
46
|
+
# Pass handler to any LangGraph graph invocation
|
|
47
|
+
result = graph.invoke({"messages": []}, config={"callbacks": [handler]})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Note:** Reports are only generated for node-level events (where `parent_run_id` is set). This works with LangGraph graphs where nodes execute as sub-runs. Standalone LangChain chain calls may not trigger reports since they run as top-level invocations.
|
|
51
|
+
|
|
52
|
+
## What it does
|
|
53
|
+
|
|
54
|
+
The callback handler intercepts LangChain/LangGraph events and submits signed interaction reports:
|
|
55
|
+
|
|
56
|
+
| Event | Domain | Score |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `on_chain_end` (node completes) | `task_completion` | 0.8 if output non-empty, -0.5 if empty |
|
|
59
|
+
| `on_chain_error` (node fails) | `reliability` | -0.8 |
|
|
60
|
+
| `on_tool_end` (tool completes) | `task_completion` | 0.8 if output non-empty, -0.5 if empty |
|
|
61
|
+
| `on_retriever_end` (retriever returns) | `information_quality` | 0.7 if docs returned, -0.3 if empty |
|
|
62
|
+
|
|
63
|
+
Only node-level events are reported — top-level graph runs are skipped to avoid double-counting.
|
|
64
|
+
|
|
65
|
+
## Identity mapping
|
|
66
|
+
|
|
67
|
+
- **Reporter:** The graph itself, identified by `graph_name`
|
|
68
|
+
- **Subject:** Each node/tool in the graph, namespaced as `{graph_name}:{node_name}`
|
|
69
|
+
|
|
70
|
+
Each identity gets a persistent Ed25519 keypair stored in `.nosocial/keys/`.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
handler = NoSocialCallbackHandler(
|
|
76
|
+
oracle_url="https://api.nosocial.me", # Oracle endpoint
|
|
77
|
+
keys_dir=".nosocial/keys", # Where to store agent keypairs
|
|
78
|
+
graph_name="my-graph", # Name for the graph's identity
|
|
79
|
+
auto_register=True, # Auto-register agents with oracle
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Key storage
|
|
84
|
+
|
|
85
|
+
Agent keypairs are stored as PEM files in `.nosocial/keys/` with `0600` permissions. Add this to your `.gitignore`:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
.nosocial/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# nosocial-langgraph
|
|
2
|
+
|
|
3
|
+
NoSocial reputation reporting for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com). Add a callback handler — your graph nodes and tools build reputation automatically.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nosocial-langgraph
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from nosocial_langgraph import NoSocialCallbackHandler
|
|
15
|
+
|
|
16
|
+
handler = NoSocialCallbackHandler(oracle_url="https://api.nosocial.me")
|
|
17
|
+
|
|
18
|
+
# Pass handler to any LangGraph graph invocation
|
|
19
|
+
result = graph.invoke({"messages": []}, config={"callbacks": [handler]})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Note:** Reports are only generated for node-level events (where `parent_run_id` is set). This works with LangGraph graphs where nodes execute as sub-runs. Standalone LangChain chain calls may not trigger reports since they run as top-level invocations.
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
The callback handler intercepts LangChain/LangGraph events and submits signed interaction reports:
|
|
27
|
+
|
|
28
|
+
| Event | Domain | Score |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `on_chain_end` (node completes) | `task_completion` | 0.8 if output non-empty, -0.5 if empty |
|
|
31
|
+
| `on_chain_error` (node fails) | `reliability` | -0.8 |
|
|
32
|
+
| `on_tool_end` (tool completes) | `task_completion` | 0.8 if output non-empty, -0.5 if empty |
|
|
33
|
+
| `on_retriever_end` (retriever returns) | `information_quality` | 0.7 if docs returned, -0.3 if empty |
|
|
34
|
+
|
|
35
|
+
Only node-level events are reported — top-level graph runs are skipped to avoid double-counting.
|
|
36
|
+
|
|
37
|
+
## Identity mapping
|
|
38
|
+
|
|
39
|
+
- **Reporter:** The graph itself, identified by `graph_name`
|
|
40
|
+
- **Subject:** Each node/tool in the graph, namespaced as `{graph_name}:{node_name}`
|
|
41
|
+
|
|
42
|
+
Each identity gets a persistent Ed25519 keypair stored in `.nosocial/keys/`.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
handler = NoSocialCallbackHandler(
|
|
48
|
+
oracle_url="https://api.nosocial.me", # Oracle endpoint
|
|
49
|
+
keys_dir=".nosocial/keys", # Where to store agent keypairs
|
|
50
|
+
graph_name="my-graph", # Name for the graph's identity
|
|
51
|
+
auto_register=True, # Auto-register agents with oracle
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Key storage
|
|
56
|
+
|
|
57
|
+
Agent keypairs are stored as PEM files in `.nosocial/keys/` with `0600` permissions. Add this to your `.gitignore`:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
.nosocial/
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NoSocial callback handler for LangGraph/LangChain.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from nosocial_langgraph import NoSocialCallbackHandler
|
|
6
|
+
|
|
7
|
+
handler = NoSocialCallbackHandler(oracle_url="https://api.nosocial.me")
|
|
8
|
+
result = graph.invoke(input, config={"callbacks": [handler]})
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
18
|
+
from langchain_core.documents import Document
|
|
19
|
+
|
|
20
|
+
from nosocial_langgraph.identity import AgentIdentity
|
|
21
|
+
from nosocial_langgraph.mapping import (
|
|
22
|
+
map_chain_end,
|
|
23
|
+
map_chain_error,
|
|
24
|
+
map_retriever_end,
|
|
25
|
+
map_tool_end,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("nosocial")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NoSocialCallbackHandler(BaseCallbackHandler):
|
|
32
|
+
"""Reports LangGraph/LangChain events as NoSocial interaction reports.
|
|
33
|
+
|
|
34
|
+
The graph itself is the reporter. Each node/tool is a subject.
|
|
35
|
+
Only reports on node-level completions (where parent_run_id is set)
|
|
36
|
+
to avoid double-counting top-level graph runs.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
oracle_url: str = "https://api.nosocial.me",
|
|
42
|
+
keys_dir: str = ".nosocial/keys",
|
|
43
|
+
graph_name: str = "default-graph",
|
|
44
|
+
auto_register: bool = True,
|
|
45
|
+
):
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.oracle_url = oracle_url.rstrip("/")
|
|
48
|
+
self.keys_dir = keys_dir
|
|
49
|
+
self.graph_name = graph_name
|
|
50
|
+
self.auto_register = auto_register
|
|
51
|
+
self._identities: dict[str, AgentIdentity] = {}
|
|
52
|
+
self._registered: set[str] = set()
|
|
53
|
+
self._graph_identity = self._get_or_create_identity(f"graph:{graph_name}")
|
|
54
|
+
|
|
55
|
+
def _get_or_create_identity(self, name: str) -> AgentIdentity:
|
|
56
|
+
if name not in self._identities:
|
|
57
|
+
self._identities[name] = AgentIdentity.load_or_create(name, self.keys_dir)
|
|
58
|
+
return self._identities[name]
|
|
59
|
+
|
|
60
|
+
def _ensure_registered(self, identity: AgentIdentity, name: str) -> bool:
|
|
61
|
+
if identity.did in self._registered:
|
|
62
|
+
return True
|
|
63
|
+
if not self.auto_register:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
resp = requests.post(
|
|
68
|
+
f"{self.oracle_url}/v1/agents/challenge",
|
|
69
|
+
json={"publicKey": identity.public_key_str},
|
|
70
|
+
timeout=10,
|
|
71
|
+
)
|
|
72
|
+
if resp.status_code == 409:
|
|
73
|
+
# Check if this is genuinely "already registered" vs another 409 error
|
|
74
|
+
error_msg = ""
|
|
75
|
+
try:
|
|
76
|
+
error_msg = (resp.json().get("error", "") or "").lower()
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
if "already registered" in error_msg or "already exists" in error_msg:
|
|
80
|
+
self._registered.add(identity.did)
|
|
81
|
+
return True
|
|
82
|
+
logger.warning(f"Oracle 409 during challenge for '{name}': {error_msg}")
|
|
83
|
+
return False
|
|
84
|
+
resp.raise_for_status()
|
|
85
|
+
challenge_data = resp.json()
|
|
86
|
+
|
|
87
|
+
signature = identity.sign({"challenge": challenge_data["challenge"]})
|
|
88
|
+
resp = requests.post(
|
|
89
|
+
f"{self.oracle_url}/v1/agents/register",
|
|
90
|
+
json={
|
|
91
|
+
"challengeId": challenge_data["challengeId"],
|
|
92
|
+
"signature": signature,
|
|
93
|
+
"publicKey": identity.public_key_str,
|
|
94
|
+
"name": name,
|
|
95
|
+
},
|
|
96
|
+
timeout=10,
|
|
97
|
+
)
|
|
98
|
+
if resp.status_code == 409:
|
|
99
|
+
self._registered.add(identity.did)
|
|
100
|
+
return True
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
self._registered.add(identity.did)
|
|
103
|
+
logger.info(f"Registered agent '{name}' as {identity.did}")
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.warning(f"Failed to register '{name}' with oracle: {e}")
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def _submit_report(
|
|
111
|
+
self,
|
|
112
|
+
reporter: AgentIdentity,
|
|
113
|
+
subject: AgentIdentity,
|
|
114
|
+
domain: str,
|
|
115
|
+
score: float,
|
|
116
|
+
context: Optional[dict] = None,
|
|
117
|
+
) -> bool:
|
|
118
|
+
report = {
|
|
119
|
+
"id": str(uuid.uuid4()),
|
|
120
|
+
"reporter": reporter.did,
|
|
121
|
+
"subject": subject.did,
|
|
122
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
123
|
+
"domain": domain,
|
|
124
|
+
"score": max(-1.0, min(1.0, score)),
|
|
125
|
+
}
|
|
126
|
+
if context:
|
|
127
|
+
report["context"] = context
|
|
128
|
+
|
|
129
|
+
signature = reporter.sign(report)
|
|
130
|
+
report["signature"] = signature
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
resp = requests.post(
|
|
134
|
+
f"{self.oracle_url}/v1/reports",
|
|
135
|
+
json=report,
|
|
136
|
+
timeout=10,
|
|
137
|
+
)
|
|
138
|
+
if resp.status_code == 201:
|
|
139
|
+
logger.debug(
|
|
140
|
+
f"Reported: {reporter.did[:20]}... → {subject.did[:20]}... "
|
|
141
|
+
f"domain={domain} score={score}"
|
|
142
|
+
)
|
|
143
|
+
return True
|
|
144
|
+
else:
|
|
145
|
+
logger.warning(f"Oracle rejected report: {resp.json()}")
|
|
146
|
+
return False
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning(f"Failed to submit report: {e}")
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _report_event(
|
|
152
|
+
self,
|
|
153
|
+
node_name: str,
|
|
154
|
+
domain: str,
|
|
155
|
+
score: float,
|
|
156
|
+
context: dict,
|
|
157
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Submit a report for a node-level event. Skips top-level graph runs."""
|
|
160
|
+
if parent_run_id is None:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
subject_name = f"{self.graph_name}:{node_name}"
|
|
164
|
+
subject = self._get_or_create_identity(subject_name)
|
|
165
|
+
|
|
166
|
+
if not self._ensure_registered(self._graph_identity, f"graph:{self.graph_name}"):
|
|
167
|
+
return
|
|
168
|
+
if not self._ensure_registered(subject, subject_name):
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
self._submit_report(
|
|
172
|
+
reporter=self._graph_identity,
|
|
173
|
+
subject=subject,
|
|
174
|
+
domain=domain,
|
|
175
|
+
score=score,
|
|
176
|
+
context=context,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# --- LangChain callback methods ---
|
|
180
|
+
# TODO: Consider an AsyncNoSocialCallbackHandler using httpx for
|
|
181
|
+
# async LangGraph graphs. Sync callbacks are acceptable for now since
|
|
182
|
+
# LangChain v0.3+ backgrounds callbacks by default.
|
|
183
|
+
|
|
184
|
+
def on_chain_end(
|
|
185
|
+
self,
|
|
186
|
+
outputs: dict[str, Any],
|
|
187
|
+
*,
|
|
188
|
+
run_id: uuid.UUID,
|
|
189
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
190
|
+
**kwargs: Any,
|
|
191
|
+
) -> None:
|
|
192
|
+
node_name = kwargs.get("name", "unknown-node")
|
|
193
|
+
domain, score, context = map_chain_end(outputs)
|
|
194
|
+
self._report_event(node_name, domain, score, context, parent_run_id)
|
|
195
|
+
|
|
196
|
+
def on_chain_error(
|
|
197
|
+
self,
|
|
198
|
+
error: BaseException,
|
|
199
|
+
*,
|
|
200
|
+
run_id: uuid.UUID,
|
|
201
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
202
|
+
**kwargs: Any,
|
|
203
|
+
) -> None:
|
|
204
|
+
node_name = kwargs.get("name", "unknown-node")
|
|
205
|
+
domain, score, context = map_chain_error(error)
|
|
206
|
+
self._report_event(node_name, domain, score, context, parent_run_id)
|
|
207
|
+
|
|
208
|
+
def on_tool_end(
|
|
209
|
+
self,
|
|
210
|
+
output: Any,
|
|
211
|
+
*,
|
|
212
|
+
run_id: uuid.UUID,
|
|
213
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
214
|
+
**kwargs: Any,
|
|
215
|
+
) -> None:
|
|
216
|
+
tool_name = kwargs.get("name", "unknown-tool")
|
|
217
|
+
domain, score, context = map_tool_end(str(output))
|
|
218
|
+
self._report_event(tool_name, domain, score, context, parent_run_id)
|
|
219
|
+
|
|
220
|
+
def on_retriever_end(
|
|
221
|
+
self,
|
|
222
|
+
documents: Optional[Sequence[Document]],
|
|
223
|
+
*,
|
|
224
|
+
run_id: uuid.UUID,
|
|
225
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
226
|
+
**kwargs: Any,
|
|
227
|
+
) -> None:
|
|
228
|
+
retriever_name = kwargs.get("name", "unknown-retriever")
|
|
229
|
+
domain, score, context = map_retriever_end(list(documents or []))
|
|
230
|
+
self._report_event(retriever_name, domain, score, context, parent_run_id)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NoSocial agent identity — Ed25519 keypairs and DID derivation.
|
|
3
|
+
|
|
4
|
+
Each agent gets a persistent NoSocial identity (keypair + DID).
|
|
5
|
+
Keys are stored as PEM files in a configurable directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
15
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
16
|
+
Encoding,
|
|
17
|
+
NoEncryption,
|
|
18
|
+
PrivateFormat,
|
|
19
|
+
PublicFormat,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _base64url_encode(data: bytes) -> str:
|
|
24
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_filename(name: str) -> str:
|
|
28
|
+
"""Derive a safe filename from an agent name using a hash prefix."""
|
|
29
|
+
h = hashlib.sha256(name.encode()).hexdigest()[:12]
|
|
30
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
|
|
31
|
+
return f"{safe}_{h}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentIdentity:
|
|
35
|
+
"""A NoSocial identity for an agent."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, private_key: Ed25519PrivateKey):
|
|
38
|
+
self._private_key = private_key
|
|
39
|
+
self._public_key = private_key.public_key()
|
|
40
|
+
raw_pub = self._public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
41
|
+
self.public_key_str = f"ed25519:{_base64url_encode(raw_pub)}"
|
|
42
|
+
self.did = f"did:nosocial:{hashlib.sha256(raw_pub).hexdigest()}"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def generate(cls) -> "AgentIdentity":
|
|
46
|
+
"""Generate a new random identity."""
|
|
47
|
+
return cls(Ed25519PrivateKey.generate())
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load_or_create(cls, name: str, keys_dir: str = ".nosocial/keys") -> "AgentIdentity":
|
|
51
|
+
"""Load an existing identity for an agent name, or create one."""
|
|
52
|
+
path = Path(keys_dir)
|
|
53
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
key_file = path / f"{_safe_filename(name)}.pem"
|
|
55
|
+
|
|
56
|
+
if key_file.exists():
|
|
57
|
+
pem_data = key_file.read_bytes()
|
|
58
|
+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
59
|
+
private_key = load_pem_private_key(pem_data, password=None)
|
|
60
|
+
if not isinstance(private_key, Ed25519PrivateKey):
|
|
61
|
+
raise ValueError(f"Key in {key_file} is not Ed25519")
|
|
62
|
+
return cls(private_key)
|
|
63
|
+
|
|
64
|
+
identity = cls.generate()
|
|
65
|
+
pem_data = identity._private_key.private_bytes(
|
|
66
|
+
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
|
|
67
|
+
)
|
|
68
|
+
fd = os.open(key_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
69
|
+
with os.fdopen(fd, "wb") as f:
|
|
70
|
+
f.write(pem_data)
|
|
71
|
+
return identity
|
|
72
|
+
|
|
73
|
+
def sign(self, obj: dict) -> str:
|
|
74
|
+
"""Sign a canonical JSON object, returning 'ed25519:{base64url}'."""
|
|
75
|
+
message = _canonicalize(obj).encode("utf-8")
|
|
76
|
+
sig = self._private_key.sign(message)
|
|
77
|
+
return f"ed25519:{_base64url_encode(sig)}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _canonicalize(obj) -> str:
|
|
81
|
+
"""Recursive canonical JSON: keys sorted at every level, no whitespace."""
|
|
82
|
+
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
83
|
+
return json.dumps(obj)
|
|
84
|
+
if isinstance(obj, list):
|
|
85
|
+
return "[" + ",".join(_canonicalize(v) for v in obj) + "]"
|
|
86
|
+
if isinstance(obj, dict):
|
|
87
|
+
entries = sorted(obj.keys())
|
|
88
|
+
parts = [json.dumps(k) + ":" + _canonicalize(obj[k]) for k in entries]
|
|
89
|
+
return "{" + ",".join(parts) + "}"
|
|
90
|
+
return json.dumps(obj)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event-to-report mapping for LangChain/LangGraph callbacks.
|
|
3
|
+
|
|
4
|
+
Maps LangChain callback events to NoSocial report parameters (domain + score).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
def map_chain_end(outputs: dict) -> tuple[str, float, dict]:
|
|
8
|
+
"""Map on_chain_end to a NoSocial report."""
|
|
9
|
+
has_output = bool(outputs) and any(
|
|
10
|
+
v is not None and v != "" for v in (outputs.values() if isinstance(outputs, dict) else [outputs])
|
|
11
|
+
)
|
|
12
|
+
score = 0.8 if has_output else -0.5
|
|
13
|
+
return (
|
|
14
|
+
"task_completion",
|
|
15
|
+
score,
|
|
16
|
+
{"taskType": "langgraph-node", "outputAccepted": has_output},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def map_chain_error(error: Exception) -> tuple[str, float, dict]:
|
|
21
|
+
"""Map on_chain_error to a NoSocial report."""
|
|
22
|
+
return (
|
|
23
|
+
"reliability",
|
|
24
|
+
-0.8,
|
|
25
|
+
{"taskType": "langgraph-node", "error": type(error).__name__},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def map_tool_end(output: str) -> tuple[str, float, dict]:
|
|
30
|
+
"""Map on_tool_end to a NoSocial report."""
|
|
31
|
+
has_output = bool(output and str(output).strip())
|
|
32
|
+
score = 0.8 if has_output else -0.5
|
|
33
|
+
return (
|
|
34
|
+
"task_completion",
|
|
35
|
+
score,
|
|
36
|
+
{"taskType": "langgraph-tool", "outputAccepted": has_output},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def map_retriever_end(documents: list) -> tuple[str, float, dict]:
|
|
41
|
+
"""Map on_retriever_end to a NoSocial report."""
|
|
42
|
+
has_docs = bool(documents) and len(documents) > 0
|
|
43
|
+
score = 0.7 if has_docs else -0.3
|
|
44
|
+
return (
|
|
45
|
+
"information_quality",
|
|
46
|
+
score,
|
|
47
|
+
{"taskType": "langgraph-retriever", "docCount": len(documents) if documents else 0},
|
|
48
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nosocial-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NoSocial reputation reporting for LangGraph/LangChain — auto-reports agent interactions to the NoSocial oracle
|
|
5
|
+
Author-email: NoSocial <hello@nosocial.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nosocial.me
|
|
8
|
+
Project-URL: Repository, https://github.com/pcdkd/nosocial-protocol
|
|
9
|
+
Project-URL: Specification, https://nosocial.me/extensions/agent-profile
|
|
10
|
+
Keywords: nosocial,langgraph,langchain,agent,reputation,a2a
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: langgraph>=0.2.0
|
|
22
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: cryptography>=42.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# nosocial-langgraph
|
|
30
|
+
|
|
31
|
+
NoSocial reputation reporting for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com). Add a callback handler — your graph nodes and tools build reputation automatically.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install nosocial-langgraph
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from nosocial_langgraph import NoSocialCallbackHandler
|
|
43
|
+
|
|
44
|
+
handler = NoSocialCallbackHandler(oracle_url="https://api.nosocial.me")
|
|
45
|
+
|
|
46
|
+
# Pass handler to any LangGraph graph invocation
|
|
47
|
+
result = graph.invoke({"messages": []}, config={"callbacks": [handler]})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Note:** Reports are only generated for node-level events (where `parent_run_id` is set). This works with LangGraph graphs where nodes execute as sub-runs. Standalone LangChain chain calls may not trigger reports since they run as top-level invocations.
|
|
51
|
+
|
|
52
|
+
## What it does
|
|
53
|
+
|
|
54
|
+
The callback handler intercepts LangChain/LangGraph events and submits signed interaction reports:
|
|
55
|
+
|
|
56
|
+
| Event | Domain | Score |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `on_chain_end` (node completes) | `task_completion` | 0.8 if output non-empty, -0.5 if empty |
|
|
59
|
+
| `on_chain_error` (node fails) | `reliability` | -0.8 |
|
|
60
|
+
| `on_tool_end` (tool completes) | `task_completion` | 0.8 if output non-empty, -0.5 if empty |
|
|
61
|
+
| `on_retriever_end` (retriever returns) | `information_quality` | 0.7 if docs returned, -0.3 if empty |
|
|
62
|
+
|
|
63
|
+
Only node-level events are reported — top-level graph runs are skipped to avoid double-counting.
|
|
64
|
+
|
|
65
|
+
## Identity mapping
|
|
66
|
+
|
|
67
|
+
- **Reporter:** The graph itself, identified by `graph_name`
|
|
68
|
+
- **Subject:** Each node/tool in the graph, namespaced as `{graph_name}:{node_name}`
|
|
69
|
+
|
|
70
|
+
Each identity gets a persistent Ed25519 keypair stored in `.nosocial/keys/`.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
handler = NoSocialCallbackHandler(
|
|
76
|
+
oracle_url="https://api.nosocial.me", # Oracle endpoint
|
|
77
|
+
keys_dir=".nosocial/keys", # Where to store agent keypairs
|
|
78
|
+
graph_name="my-graph", # Name for the graph's identity
|
|
79
|
+
auto_register=True, # Auto-register agents with oracle
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Key storage
|
|
84
|
+
|
|
85
|
+
Agent keypairs are stored as PEM files in `.nosocial/keys/` with `0600` permissions. Add this to your `.gitignore`:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
.nosocial/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
nosocial_langgraph/__init__.py
|
|
4
|
+
nosocial_langgraph/handler.py
|
|
5
|
+
nosocial_langgraph/identity.py
|
|
6
|
+
nosocial_langgraph/mapping.py
|
|
7
|
+
nosocial_langgraph.egg-info/PKG-INFO
|
|
8
|
+
nosocial_langgraph.egg-info/SOURCES.txt
|
|
9
|
+
nosocial_langgraph.egg-info/dependency_links.txt
|
|
10
|
+
nosocial_langgraph.egg-info/requires.txt
|
|
11
|
+
nosocial_langgraph.egg-info/top_level.txt
|
|
12
|
+
tests/test_handler.py
|
|
13
|
+
tests/test_identity.py
|
|
14
|
+
tests/test_mapping.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nosocial_langgraph
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nosocial-langgraph"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "NoSocial reputation reporting for LangGraph/LangChain — auto-reports agent interactions to the NoSocial oracle"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "NoSocial", email = "hello@nosocial.me" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["nosocial", "langgraph", "langchain", "agent", "reputation", "a2a"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"langgraph>=0.2.0",
|
|
28
|
+
"langchain-core>=0.3.0",
|
|
29
|
+
"requests>=2.31.0",
|
|
30
|
+
"cryptography>=42.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://nosocial.me"
|
|
35
|
+
Repository = "https://github.com/pcdkd/nosocial-protocol"
|
|
36
|
+
Specification = "https://nosocial.me/extensions/agent-profile"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.0.0",
|
|
41
|
+
"responses>=0.25.0",
|
|
42
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tests for NoSocial LangGraph callback handler."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
import responses
|
|
7
|
+
|
|
8
|
+
from nosocial_langgraph.handler import NoSocialCallbackHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ORACLE_URL = "http://test-oracle:3000"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _mock_oracle(oracle_url: str = ORACLE_URL):
|
|
15
|
+
"""Set up mock oracle endpoints."""
|
|
16
|
+
responses.post(
|
|
17
|
+
f"{oracle_url}/v1/agents/challenge",
|
|
18
|
+
json={
|
|
19
|
+
"challengeId": "test-challenge-id",
|
|
20
|
+
"challenge": "test-challenge-string",
|
|
21
|
+
"did": "did:nosocial:abc",
|
|
22
|
+
"expiresAt": "2099-01-01T00:00:00Z",
|
|
23
|
+
},
|
|
24
|
+
status=201,
|
|
25
|
+
)
|
|
26
|
+
responses.post(
|
|
27
|
+
f"{oracle_url}/v1/agents/register",
|
|
28
|
+
json={"did": "did:nosocial:abc", "name": "test"},
|
|
29
|
+
status=201,
|
|
30
|
+
)
|
|
31
|
+
responses.post(
|
|
32
|
+
f"{oracle_url}/v1/reports",
|
|
33
|
+
json={"accepted": True},
|
|
34
|
+
status=201,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestHandlerInit:
|
|
39
|
+
def test_creates_graph_identity(self):
|
|
40
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
41
|
+
handler = NoSocialCallbackHandler(
|
|
42
|
+
keys_dir=tmpdir,
|
|
43
|
+
auto_register=False,
|
|
44
|
+
)
|
|
45
|
+
assert handler._graph_identity.did.startswith("did:nosocial:")
|
|
46
|
+
|
|
47
|
+
def test_graph_identity_persists(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
49
|
+
h1 = NoSocialCallbackHandler(keys_dir=tmpdir, graph_name="test", auto_register=False)
|
|
50
|
+
h2 = NoSocialCallbackHandler(keys_dir=tmpdir, graph_name="test", auto_register=False)
|
|
51
|
+
assert h1._graph_identity.did == h2._graph_identity.did
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestOnChainEnd:
|
|
55
|
+
@responses.activate
|
|
56
|
+
def test_reports_on_node_completion(self):
|
|
57
|
+
_mock_oracle()
|
|
58
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
59
|
+
handler = NoSocialCallbackHandler(
|
|
60
|
+
oracle_url=ORACLE_URL,
|
|
61
|
+
keys_dir=tmpdir,
|
|
62
|
+
graph_name="test-graph",
|
|
63
|
+
)
|
|
64
|
+
handler.on_chain_end(
|
|
65
|
+
outputs={"result": "done"},
|
|
66
|
+
run_id=uuid.uuid4(),
|
|
67
|
+
parent_run_id=uuid.uuid4(),
|
|
68
|
+
name="my-node",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
report_calls = [c for c in responses.calls if "/v1/reports" in c.request.url]
|
|
72
|
+
assert len(report_calls) == 1
|
|
73
|
+
|
|
74
|
+
def test_skips_top_level_graph_run(self):
|
|
75
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
76
|
+
handler = NoSocialCallbackHandler(
|
|
77
|
+
keys_dir=tmpdir,
|
|
78
|
+
auto_register=False,
|
|
79
|
+
)
|
|
80
|
+
# parent_run_id=None means this is the top-level graph run
|
|
81
|
+
handler.on_chain_end(
|
|
82
|
+
outputs={"result": "done"},
|
|
83
|
+
run_id=uuid.uuid4(),
|
|
84
|
+
parent_run_id=None,
|
|
85
|
+
name="graph",
|
|
86
|
+
)
|
|
87
|
+
# No exception, no report submitted
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestOnChainError:
|
|
91
|
+
@responses.activate
|
|
92
|
+
def test_reports_negative_reliability(self):
|
|
93
|
+
_mock_oracle()
|
|
94
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
95
|
+
handler = NoSocialCallbackHandler(
|
|
96
|
+
oracle_url=ORACLE_URL,
|
|
97
|
+
keys_dir=tmpdir,
|
|
98
|
+
)
|
|
99
|
+
handler.on_chain_error(
|
|
100
|
+
error=ValueError("something broke"),
|
|
101
|
+
run_id=uuid.uuid4(),
|
|
102
|
+
parent_run_id=uuid.uuid4(),
|
|
103
|
+
name="failing-node",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
report_calls = [c for c in responses.calls if "/v1/reports" in c.request.url]
|
|
107
|
+
assert len(report_calls) == 1
|
|
108
|
+
|
|
109
|
+
import json
|
|
110
|
+
body = json.loads(report_calls[0].request.body)
|
|
111
|
+
assert body["domain"] == "reliability"
|
|
112
|
+
assert body["score"] == -0.8
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestOnToolEnd:
|
|
116
|
+
@responses.activate
|
|
117
|
+
def test_reports_tool_completion(self):
|
|
118
|
+
_mock_oracle()
|
|
119
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
120
|
+
handler = NoSocialCallbackHandler(
|
|
121
|
+
oracle_url=ORACLE_URL,
|
|
122
|
+
keys_dir=tmpdir,
|
|
123
|
+
)
|
|
124
|
+
handler.on_tool_end(
|
|
125
|
+
output="search results",
|
|
126
|
+
run_id=uuid.uuid4(),
|
|
127
|
+
parent_run_id=uuid.uuid4(),
|
|
128
|
+
name="search-tool",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
report_calls = [c for c in responses.calls if "/v1/reports" in c.request.url]
|
|
132
|
+
assert len(report_calls) == 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestOnRetrieverEnd:
|
|
136
|
+
@responses.activate
|
|
137
|
+
def test_reports_retriever_with_docs(self):
|
|
138
|
+
_mock_oracle()
|
|
139
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
140
|
+
handler = NoSocialCallbackHandler(
|
|
141
|
+
oracle_url=ORACLE_URL,
|
|
142
|
+
keys_dir=tmpdir,
|
|
143
|
+
)
|
|
144
|
+
from langchain_core.documents import Document
|
|
145
|
+
handler.on_retriever_end(
|
|
146
|
+
documents=[Document(page_content="relevant doc")],
|
|
147
|
+
run_id=uuid.uuid4(),
|
|
148
|
+
parent_run_id=uuid.uuid4(),
|
|
149
|
+
name="my-retriever",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
report_calls = [c for c in responses.calls if "/v1/reports" in c.request.url]
|
|
153
|
+
assert len(report_calls) == 1
|
|
154
|
+
|
|
155
|
+
import json
|
|
156
|
+
body = json.loads(report_calls[0].request.body)
|
|
157
|
+
assert body["domain"] == "information_quality"
|
|
158
|
+
assert body["score"] == 0.7
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestNamespacing:
|
|
162
|
+
def test_node_identities_namespaced_by_graph(self):
|
|
163
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
164
|
+
h1 = NoSocialCallbackHandler(keys_dir=tmpdir, graph_name="graph-a", auto_register=False)
|
|
165
|
+
h2 = NoSocialCallbackHandler(keys_dir=tmpdir, graph_name="graph-b", auto_register=False)
|
|
166
|
+
id1 = h1._get_or_create_identity("graph-a:researcher")
|
|
167
|
+
id2 = h2._get_or_create_identity("graph-b:researcher")
|
|
168
|
+
assert id1.did != id2.did
|
|
169
|
+
|
|
170
|
+
def test_identity_reused_across_calls(self):
|
|
171
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
172
|
+
handler = NoSocialCallbackHandler(keys_dir=tmpdir, auto_register=False)
|
|
173
|
+
id1 = handler._get_or_create_identity("node-a")
|
|
174
|
+
id2 = handler._get_or_create_identity("node-a")
|
|
175
|
+
assert id1.did == id2.did
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TestAlreadyRegistered:
|
|
179
|
+
@responses.activate
|
|
180
|
+
def test_handles_409_already_registered(self):
|
|
181
|
+
"""Handler should treat 409 as successful registration."""
|
|
182
|
+
responses.post(
|
|
183
|
+
f"{ORACLE_URL}/v1/agents/challenge",
|
|
184
|
+
json={"error": "Already registered"},
|
|
185
|
+
status=409,
|
|
186
|
+
)
|
|
187
|
+
responses.post(
|
|
188
|
+
f"{ORACLE_URL}/v1/reports",
|
|
189
|
+
json={"accepted": True},
|
|
190
|
+
status=201,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
194
|
+
handler = NoSocialCallbackHandler(
|
|
195
|
+
oracle_url=ORACLE_URL,
|
|
196
|
+
keys_dir=tmpdir,
|
|
197
|
+
)
|
|
198
|
+
handler.on_chain_end(
|
|
199
|
+
outputs={"result": "done"},
|
|
200
|
+
run_id=uuid.uuid4(),
|
|
201
|
+
parent_run_id=uuid.uuid4(),
|
|
202
|
+
name="my-node",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
report_calls = [c for c in responses.calls if "/v1/reports" in c.request.url]
|
|
206
|
+
assert len(report_calls) == 1
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for NoSocial agent identity."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
from nosocial_langgraph.identity import AgentIdentity, _canonicalize
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_generate_identity():
|
|
9
|
+
identity = AgentIdentity.generate()
|
|
10
|
+
assert identity.public_key_str.startswith("ed25519:")
|
|
11
|
+
assert identity.did.startswith("did:nosocial:")
|
|
12
|
+
assert len(identity.did) == len("did:nosocial:") + 64 # sha256 hex
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_load_or_create_persists():
|
|
17
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
18
|
+
id1 = AgentIdentity.load_or_create("test-agent", keys_dir=tmpdir)
|
|
19
|
+
id2 = AgentIdentity.load_or_create("test-agent", keys_dir=tmpdir)
|
|
20
|
+
assert id1.did == id2.did
|
|
21
|
+
assert id1.public_key_str == id2.public_key_str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_different_names_different_keys():
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
26
|
+
id1 = AgentIdentity.load_or_create("agent-a", keys_dir=tmpdir)
|
|
27
|
+
id2 = AgentIdentity.load_or_create("agent-b", keys_dir=tmpdir)
|
|
28
|
+
assert id1.did != id2.did
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_sign_produces_valid_format():
|
|
32
|
+
identity = AgentIdentity.generate()
|
|
33
|
+
sig = identity.sign({"hello": "world"})
|
|
34
|
+
assert sig.startswith("ed25519:")
|
|
35
|
+
assert len(sig) > 20
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_canonicalize_sorts_keys():
|
|
39
|
+
result = _canonicalize({"b": 1, "a": 2})
|
|
40
|
+
assert result == '{"a":2,"b":1}'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_canonicalize_nested():
|
|
44
|
+
result = _canonicalize({"z": {"b": 1, "a": 2}, "a": 3})
|
|
45
|
+
assert result == '{"a":3,"z":{"a":2,"b":1}}'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_canonicalize_array():
|
|
49
|
+
result = _canonicalize({"items": [3, 1, 2]})
|
|
50
|
+
assert result == '{"items":[3,1,2]}'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tests for event-to-report mapping."""
|
|
2
|
+
|
|
3
|
+
from nosocial_langgraph.mapping import (
|
|
4
|
+
map_chain_end,
|
|
5
|
+
map_chain_error,
|
|
6
|
+
map_retriever_end,
|
|
7
|
+
map_tool_end,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestMapChainEnd:
|
|
12
|
+
def test_non_empty_output(self):
|
|
13
|
+
domain, score, ctx = map_chain_end({"result": "hello"})
|
|
14
|
+
assert domain == "task_completion"
|
|
15
|
+
assert score == 0.8
|
|
16
|
+
assert ctx["outputAccepted"] is True
|
|
17
|
+
|
|
18
|
+
def test_empty_output(self):
|
|
19
|
+
domain, score, ctx = map_chain_end({})
|
|
20
|
+
assert domain == "task_completion"
|
|
21
|
+
assert score == -0.5
|
|
22
|
+
assert ctx["outputAccepted"] is False
|
|
23
|
+
|
|
24
|
+
def test_none_values(self):
|
|
25
|
+
domain, score, ctx = map_chain_end({"result": None})
|
|
26
|
+
assert score == -0.5
|
|
27
|
+
|
|
28
|
+
def test_empty_string_value(self):
|
|
29
|
+
domain, score, ctx = map_chain_end({"result": ""})
|
|
30
|
+
assert score == -0.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestMapChainError:
|
|
34
|
+
def test_error_report(self):
|
|
35
|
+
domain, score, ctx = map_chain_error(ValueError("test"))
|
|
36
|
+
assert domain == "reliability"
|
|
37
|
+
assert score == -0.8
|
|
38
|
+
assert ctx["error"] == "ValueError"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestMapToolEnd:
|
|
42
|
+
def test_non_empty_output(self):
|
|
43
|
+
domain, score, ctx = map_tool_end("search results here")
|
|
44
|
+
assert domain == "task_completion"
|
|
45
|
+
assert score == 0.8
|
|
46
|
+
|
|
47
|
+
def test_empty_output(self):
|
|
48
|
+
domain, score, ctx = map_tool_end("")
|
|
49
|
+
assert score == -0.5
|
|
50
|
+
|
|
51
|
+
def test_whitespace_only(self):
|
|
52
|
+
domain, score, ctx = map_tool_end(" ")
|
|
53
|
+
assert score == -0.5
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestMapRetrieverEnd:
|
|
57
|
+
def test_docs_returned(self):
|
|
58
|
+
domain, score, ctx = map_retriever_end([{"page_content": "doc1"}])
|
|
59
|
+
assert domain == "information_quality"
|
|
60
|
+
assert score == 0.7
|
|
61
|
+
assert ctx["docCount"] == 1
|
|
62
|
+
|
|
63
|
+
def test_no_docs(self):
|
|
64
|
+
domain, score, ctx = map_retriever_end([])
|
|
65
|
+
assert score == -0.3
|
|
66
|
+
assert ctx["docCount"] == 0
|
|
67
|
+
|
|
68
|
+
def test_none_docs(self):
|
|
69
|
+
domain, score, ctx = map_retriever_end(None)
|
|
70
|
+
assert score == -0.3
|