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.
@@ -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,4 @@
1
+ from nosocial_langgraph.handler import NoSocialCallbackHandler
2
+ from nosocial_langgraph.identity import AgentIdentity
3
+
4
+ __all__ = ["NoSocialCallbackHandler", "AgentIdentity"]
@@ -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,8 @@
1
+ langgraph>=0.2.0
2
+ langchain-core>=0.3.0
3
+ requests>=2.31.0
4
+ cryptography>=42.0.0
5
+
6
+ [dev]
7
+ pytest>=8.0.0
8
+ responses>=0.25.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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