governance-sdk 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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: governance-sdk
3
+ Version: 0.1.0
4
+ Summary: A plug-and-play SDK to capture and audit tool calls in Agentic AI applications
5
+ Author-email: Sanjay Nithin <sanjaynithin220@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.25.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
12
+ Requires-Dist: langchain-core>=0.1.0; extra == "dev"
13
+
14
+ # Governance SDK (AI Frameworks Edition)
15
+
16
+ A lightweight, non-blocking, plug-and-play Python SDK to automatically intercept, evaluate, and authorize tool calls in LangChain, LangGraph, and CrewAI agentic applications.
17
+
18
+ ## Features
19
+
20
+ - **Multi-Framework Auto-Interception**: Automatically intercepts tool calls in **LangChain**, **LangGraph**, and **CrewAI** (by hooking base tool run/arun methods) with zero configuration.
21
+ - **Zero-Touch Automatic Context Capture**: Walks up the Python execution stack frames to auto-resolve active context details such as `session_id`, `agent_name`, `purpose`, and `trace_id` by inspecting local variables. No manual wrapping required!
22
+ - **Three-Tier Governance Workflow**: Evaluates risk and routes tool execution decisions:
23
+ - `allow`: Automatically executes the tool.
24
+ - `preview_and_confirmation`: Triggers an interactive CLI prompt to confirm execution.
25
+ - `needs_full_review`: Triggers an interactive CLI prompt indicating full review is required.
26
+ - **Exception-Based Control**: If a developer/user denies an execution prompt, the SDK raises `PermissionDeniedError` or `ReviewRequiredError`, letting the calling application abort or handle it gracefully.
27
+ - **Non-Blocking Asynchronous Auditing**: Completed tool logs are queued in-memory and batch-shipped to the Governance Server asynchronously by a background worker thread.
28
+ - **Fail-safe & Resilient**: Operates with exponential backoff on server log shipment delivery failure.
29
+
30
+ ---
31
+
32
+ ## Installation
33
+
34
+ Install the package in editable mode:
35
+ ```bash
36
+ pip install -e .
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Quickstart
42
+
43
+ Initialize the SDK at the entry point of your agentic application.
44
+
45
+ ### 1. SDK Initialization
46
+ ```python
47
+ import governance_sdk
48
+
49
+ governance_sdk.init(
50
+ server_url="http://127.0.0.1:8000/api/v1/tool-calls",
51
+ risk_check_url="http://127.0.0.1:8000/api/v1/risk-checks",
52
+ project_name="customer-support-agent"
53
+ )
54
+ ```
55
+
56
+ You can also use environment variables:
57
+ ```bash
58
+ export GOVERNANCE_SERVER_URL="http://127.0.0.1:8000/api/v1/tool-calls"
59
+ export GOVERNANCE_RISK_CHECK_URL="http://127.0.0.1:8000/api/v1/risk-checks"
60
+ export GOVERNANCE_PROJECT_NAME="customer-support-agent"
61
+ ```
62
+ And simply call `init()`:
63
+ ```python
64
+ import governance_sdk
65
+ governance_sdk.init()
66
+ ```
67
+
68
+ ### 2. Auto-Interception & Context Capture
69
+ Once `init()` is called, any tool executed by your agent in LangChain, LangGraph, or CrewAI will be automatically intercepted:
70
+
71
+ ```python
72
+ from langchain_core.tools import tool
73
+
74
+ @tool
75
+ def delete_user_data(user_id: str) -> str:
76
+ """Deletes sensitive user information."""
77
+ return f"User {user_id} deleted."
78
+
79
+ # In your agent logic:
80
+ # When this tool is called, the SDK automatically walks the stack to extract:
81
+ # - session_id (resolves from variables named session_id, sid, session, etc.)
82
+ # - agent_name (resolves from class name or variable agent_name)
83
+ # - purpose (resolves from variables named purpose, intent, reason, etc.)
84
+ # Then it verifies the risk score on the server side.
85
+ delete_user_data.run({"user_id": "usr_992"})
86
+ ```
87
+
88
+ ### 3. Explicit Context Override (Optional)
89
+ If you want to manually specify context instead of relying on the auto-captured stack frames, use the `agent_context` manager:
90
+
91
+ ```python
92
+ import governance_sdk
93
+
94
+ with governance_sdk.agent_context(agent_name="Supervisor-Agent", session_id="session_override_123", purpose="cleanup"):
95
+ # All tool calls executed inside this block will prioritize these values
96
+ agent.run("delete temporary files")
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Captured Payload Format
102
+
103
+ The JSON payload sent to the governance logging server has the following structure:
104
+
105
+ ```json
106
+ {
107
+ "project_name": "customer-support-agent",
108
+ "tool_calls": [
109
+ {
110
+ "tool_name": "delete_user_data",
111
+ "tool_description": "Deletes sensitive user information.",
112
+ "arguments": { "user_id": "usr_992" },
113
+ "output": "User usr_992 deleted.",
114
+ "error": null,
115
+ "status": "success",
116
+ "timestamp_start": "2026-07-30T13:30:00Z",
117
+ "timestamp_end": "2026-07-30T13:30:00.045Z",
118
+ "duration_ms": 45,
119
+ "context": {
120
+ "agent_name": "Supervisor-Agent",
121
+ "session_id": "session_override_123",
122
+ "purpose": "cleanup",
123
+ "caller_filename": "main.py",
124
+ "caller_line_number": 42,
125
+ "caller_function": "execute_task"
126
+ },
127
+ "risk_score": 0.90,
128
+ "risk_category": "high_risk",
129
+ "governance_decision": "allow"
130
+ }
131
+ ]
132
+ }
133
+ ```
@@ -0,0 +1,120 @@
1
+ # Governance SDK (AI Frameworks Edition)
2
+
3
+ A lightweight, non-blocking, plug-and-play Python SDK to automatically intercept, evaluate, and authorize tool calls in LangChain, LangGraph, and CrewAI agentic applications.
4
+
5
+ ## Features
6
+
7
+ - **Multi-Framework Auto-Interception**: Automatically intercepts tool calls in **LangChain**, **LangGraph**, and **CrewAI** (by hooking base tool run/arun methods) with zero configuration.
8
+ - **Zero-Touch Automatic Context Capture**: Walks up the Python execution stack frames to auto-resolve active context details such as `session_id`, `agent_name`, `purpose`, and `trace_id` by inspecting local variables. No manual wrapping required!
9
+ - **Three-Tier Governance Workflow**: Evaluates risk and routes tool execution decisions:
10
+ - `allow`: Automatically executes the tool.
11
+ - `preview_and_confirmation`: Triggers an interactive CLI prompt to confirm execution.
12
+ - `needs_full_review`: Triggers an interactive CLI prompt indicating full review is required.
13
+ - **Exception-Based Control**: If a developer/user denies an execution prompt, the SDK raises `PermissionDeniedError` or `ReviewRequiredError`, letting the calling application abort or handle it gracefully.
14
+ - **Non-Blocking Asynchronous Auditing**: Completed tool logs are queued in-memory and batch-shipped to the Governance Server asynchronously by a background worker thread.
15
+ - **Fail-safe & Resilient**: Operates with exponential backoff on server log shipment delivery failure.
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ Install the package in editable mode:
22
+ ```bash
23
+ pip install -e .
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Quickstart
29
+
30
+ Initialize the SDK at the entry point of your agentic application.
31
+
32
+ ### 1. SDK Initialization
33
+ ```python
34
+ import governance_sdk
35
+
36
+ governance_sdk.init(
37
+ server_url="http://127.0.0.1:8000/api/v1/tool-calls",
38
+ risk_check_url="http://127.0.0.1:8000/api/v1/risk-checks",
39
+ project_name="customer-support-agent"
40
+ )
41
+ ```
42
+
43
+ You can also use environment variables:
44
+ ```bash
45
+ export GOVERNANCE_SERVER_URL="http://127.0.0.1:8000/api/v1/tool-calls"
46
+ export GOVERNANCE_RISK_CHECK_URL="http://127.0.0.1:8000/api/v1/risk-checks"
47
+ export GOVERNANCE_PROJECT_NAME="customer-support-agent"
48
+ ```
49
+ And simply call `init()`:
50
+ ```python
51
+ import governance_sdk
52
+ governance_sdk.init()
53
+ ```
54
+
55
+ ### 2. Auto-Interception & Context Capture
56
+ Once `init()` is called, any tool executed by your agent in LangChain, LangGraph, or CrewAI will be automatically intercepted:
57
+
58
+ ```python
59
+ from langchain_core.tools import tool
60
+
61
+ @tool
62
+ def delete_user_data(user_id: str) -> str:
63
+ """Deletes sensitive user information."""
64
+ return f"User {user_id} deleted."
65
+
66
+ # In your agent logic:
67
+ # When this tool is called, the SDK automatically walks the stack to extract:
68
+ # - session_id (resolves from variables named session_id, sid, session, etc.)
69
+ # - agent_name (resolves from class name or variable agent_name)
70
+ # - purpose (resolves from variables named purpose, intent, reason, etc.)
71
+ # Then it verifies the risk score on the server side.
72
+ delete_user_data.run({"user_id": "usr_992"})
73
+ ```
74
+
75
+ ### 3. Explicit Context Override (Optional)
76
+ If you want to manually specify context instead of relying on the auto-captured stack frames, use the `agent_context` manager:
77
+
78
+ ```python
79
+ import governance_sdk
80
+
81
+ with governance_sdk.agent_context(agent_name="Supervisor-Agent", session_id="session_override_123", purpose="cleanup"):
82
+ # All tool calls executed inside this block will prioritize these values
83
+ agent.run("delete temporary files")
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Captured Payload Format
89
+
90
+ The JSON payload sent to the governance logging server has the following structure:
91
+
92
+ ```json
93
+ {
94
+ "project_name": "customer-support-agent",
95
+ "tool_calls": [
96
+ {
97
+ "tool_name": "delete_user_data",
98
+ "tool_description": "Deletes sensitive user information.",
99
+ "arguments": { "user_id": "usr_992" },
100
+ "output": "User usr_992 deleted.",
101
+ "error": null,
102
+ "status": "success",
103
+ "timestamp_start": "2026-07-30T13:30:00Z",
104
+ "timestamp_end": "2026-07-30T13:30:00.045Z",
105
+ "duration_ms": 45,
106
+ "context": {
107
+ "agent_name": "Supervisor-Agent",
108
+ "session_id": "session_override_123",
109
+ "purpose": "cleanup",
110
+ "caller_filename": "main.py",
111
+ "caller_line_number": 42,
112
+ "caller_function": "execute_task"
113
+ },
114
+ "risk_score": 0.90,
115
+ "risk_category": "high_risk",
116
+ "governance_decision": "allow"
117
+ }
118
+ ]
119
+ }
120
+ ```
@@ -0,0 +1,81 @@
1
+ from typing import Optional, Dict, Any
2
+
3
+ from governance_sdk.config import SDKConfig
4
+ from governance_sdk.client import GovernanceClient
5
+ from governance_sdk.context import agent_context
6
+ from governance_sdk.exceptions import GovernanceError, PermissionDeniedError, ReviewRequiredError
7
+ from governance_sdk.instrumentor import patch_all
8
+
9
+ _active_client: Optional[GovernanceClient] = None
10
+
11
+ def init(
12
+ api_key: str,
13
+ server_url: Optional[str] = None,
14
+ risk_check_url: Optional[str] = None,
15
+ project_name: Optional[str] = None,
16
+ enabled: Optional[bool] = None,
17
+ batch_size: Optional[int] = None,
18
+ flush_interval: Optional[float] = None,
19
+ max_queue_size: Optional[int] = None,
20
+ fallback_policy: Optional[str] = None,
21
+ risk_threshold: Optional[float] = None,
22
+ extra_metadata: Optional[Dict[str, Any]] = None
23
+ ) -> Optional[GovernanceClient]:
24
+ """
25
+ Initializes the Governance SDK.
26
+ Starts the background worker queue and patches LangChain tool calling mechanisms.
27
+
28
+ Example:
29
+ import governance_sdk
30
+ governance_sdk.init(
31
+ server_url="https://governance.yourorg.com/api/v1/tool-calls",
32
+ project_name="customer-support-agent"
33
+ )
34
+ """
35
+ global _active_client
36
+
37
+ config = SDKConfig(
38
+ server_url=server_url,
39
+ risk_check_url=risk_check_url,
40
+ api_key=api_key,
41
+ project_name=project_name,
42
+ enabled=enabled,
43
+ batch_size=batch_size,
44
+ flush_interval=flush_interval,
45
+ max_queue_size=max_queue_size,
46
+ fallback_policy=fallback_policy,
47
+ risk_threshold=risk_threshold,
48
+ extra_metadata=extra_metadata
49
+ )
50
+
51
+ if not config.enabled: # is governance server enabled or not
52
+ if _active_client:
53
+ _active_client.shutdown()
54
+ _active_client = None
55
+ return None
56
+
57
+ if not _active_client:
58
+ _active_client = GovernanceClient(config)
59
+ _active_client.start()
60
+ else:
61
+ _active_client.config = config
62
+ _active_client.start()
63
+
64
+ # Auto-patch LangChain tool calling
65
+ patch_all(_active_client)
66
+
67
+ return _active_client
68
+
69
+ def get_active_client() -> Optional[GovernanceClient]:
70
+ """Retrieves the active global governance client."""
71
+ return _active_client
72
+
73
+ __all__ = [
74
+ "init",
75
+ "agent_context",
76
+ "get_active_client",
77
+ "GovernanceError",
78
+ "PermissionDeniedError",
79
+ "ReviewRequiredError"
80
+ ]
81
+
@@ -0,0 +1,331 @@
1
+ import atexit
2
+ import json
3
+ import logging
4
+ import queue
5
+ import threading
6
+ import time
7
+ from typing import Dict, Any, List, Optional
8
+ import urllib.request
9
+ import urllib.error
10
+
11
+ from governance_sdk.config import SDKConfig
12
+
13
+ logger = logging.getLogger("governance_sdk")
14
+
15
+ def safe_serialize(obj: Any) -> Any:
16
+ """Safely serializes objects that might not be JSON serializable."""
17
+ try:
18
+ json.dumps(obj)
19
+ return obj
20
+ except (TypeError, OverflowError):
21
+ if isinstance(obj, dict):
22
+ return {str(k): safe_serialize(v) for k, v in obj.items()}
23
+ elif isinstance(obj, (list, tuple, set)):
24
+ return [safe_serialize(x) for x in obj]
25
+ else:
26
+ try:
27
+ if hasattr(obj, "__dict__"):
28
+ return safe_serialize(obj.__dict__)
29
+ return repr(obj)
30
+ except Exception:
31
+ return str(obj)
32
+
33
+ class GovernanceClient:
34
+ def __init__(self, config: SDKConfig):
35
+ self.config = config
36
+ self.queue: queue.Queue = queue.Queue(maxsize=config.max_queue_size)
37
+ self.stop_event = threading.Event()
38
+ self.worker_thread: Optional[threading.Thread] = None
39
+ self.lock = threading.Lock()
40
+
41
+ def start(self) -> None:
42
+ """Starts the background worker thread."""
43
+ with self.lock:
44
+ if self.worker_thread and self.worker_thread.is_alive():
45
+ return
46
+ self.stop_event.clear()
47
+ self.worker_thread = threading.Thread(
48
+ target=self._worker_loop,
49
+ name="governance-sdk-worker",
50
+ daemon=True
51
+ )
52
+ self.worker_thread.start()
53
+ atexit.register(self.shutdown)
54
+
55
+ def send_tool_call(self, tool_call_data: Dict[str, Any]) -> None:
56
+ """Enqueues a tool call payload without blocking."""
57
+ if not self.config.is_configured():
58
+ return
59
+
60
+ if not self.worker_thread or not self.worker_thread.is_alive():
61
+ self.start()
62
+
63
+ sanitized = safe_serialize(tool_call_data)
64
+
65
+ try:
66
+ self.queue.put_nowait(sanitized)
67
+ except queue.Full:
68
+ logger.warning("Governance SDK queue is full. Dropping tool call report.")
69
+
70
+ def check_tool_risk(
71
+ self,
72
+ tool_name: str,
73
+ tool_description: str,
74
+ arguments: Any,
75
+ context: Dict[str, Any]
76
+ ) -> Dict[str, Any]:
77
+ """
78
+ Synchronously queries the governance server to evaluate the risk score of a tool call before execution.
79
+ Categorizes risk and returns decision (allow, preview_and_confirmation, needs_full_review).
80
+ """
81
+ if not self.config.is_configured():
82
+ return {
83
+ "decision": "allow",
84
+ "risk_score": 0.0,
85
+ "risk_category": "safe",
86
+ "reason": "Governance SDK is disabled or not configured"
87
+ }
88
+
89
+ payload = {
90
+ "project_name": self.config.project_name,
91
+ "tool_name": tool_name,
92
+ "tool_description": tool_description,
93
+ "arguments": safe_serialize(arguments),
94
+ "context": safe_serialize(context)
95
+ }
96
+
97
+ data = json.dumps(payload).encode("utf-8")
98
+ headers = {
99
+ "Content-Type": "application/json",
100
+ "User-Agent": "governance-sdk-python/0.1.0"
101
+ }
102
+ if self.config.api_key:
103
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
104
+
105
+ try:
106
+ req = urllib.request.Request(
107
+ self.config.risk_check_url,
108
+ data=data,
109
+ headers=headers,
110
+ method="POST"
111
+ )
112
+ # Use a 10-second timeout for risk assessment to support multi-agent analysis
113
+ with urllib.request.urlopen(req, timeout=10.0) as response:
114
+ if 200 <= response.status < 300:
115
+ resp_data = json.loads(response.read().decode("utf-8"))
116
+
117
+ risk_score = float(resp_data.get("risk_score", 0.0))
118
+ raw_decision = resp_data.get("decision", "allow").lower()
119
+
120
+ # Categorize risk score if not provided by server
121
+ if "risk_category" in resp_data:
122
+ risk_category = resp_data["risk_category"].lower()
123
+ else:
124
+ if risk_score >= 0.80:
125
+ risk_category = "high_risk"
126
+ elif risk_score >= 0.60:
127
+ risk_category = "medium_risk"
128
+ elif risk_score >= 0.30:
129
+ risk_category = "low_risk"
130
+ else:
131
+ risk_category = "safe"
132
+
133
+ reason = resp_data.get("reason", "Approved by default policy")
134
+
135
+ # Local risk override guardrail: map high risk or threshold exceedance to user prompt
136
+ if raw_decision == "allow" and risk_category == "high_risk":
137
+ decision = "preview_and_confirmation"
138
+ reason = f"Local override: High risk score ({risk_score}) requires permission. {reason}"
139
+ elif raw_decision == "allow" and risk_score > self.config.risk_threshold:
140
+ decision = "preview_and_confirmation"
141
+ reason = f"Local override: Threshold exceeded ({risk_score} > {self.config.risk_threshold}) requires permission. {reason}"
142
+ else:
143
+ # Standardize decision to the three allowed options
144
+ if raw_decision in ("preview_and_confirmation", "needs_permission"):
145
+ decision = "preview_and_confirmation"
146
+ elif raw_decision in ("needs_full_review", "block"):
147
+ decision = "needs_full_review"
148
+ else:
149
+ decision = "allow"
150
+
151
+ return {
152
+ "decision": decision,
153
+ "risk_score": risk_score,
154
+ "risk_category": risk_category,
155
+ "reason": reason
156
+ }
157
+ except Exception as e:
158
+ logger.warning(f"Governance Server risk-check failed: {e}. Falling back to '{self.config.fallback_policy}' policy.")
159
+
160
+ # Fallback handling
161
+ fallback_decision = self.config.fallback_policy
162
+ if fallback_decision == "policy":
163
+ return self._evaluate_local_fallback_policy(tool_name, tool_description, arguments)
164
+ elif fallback_decision == "block":
165
+ return {
166
+ "decision": "needs_full_review",
167
+ "risk_score": 1.0,
168
+ "risk_category": "high_risk",
169
+ "reason": "Server unreachable. Applied fallback closed policy: block."
170
+ }
171
+ else:
172
+ return {
173
+ "decision": "allow",
174
+ "risk_score": 0.0,
175
+ "risk_category": "safe",
176
+ "reason": "Server unreachable. Applied fallback open policy: allow."
177
+ }
178
+
179
+ def _evaluate_local_fallback_policy(self, tool_name: str, tool_description: str, arguments: Any) -> Dict[str, Any]:
180
+ """
181
+ Evaluates a local rules-based safety policy when the Governance Server is unreachable.
182
+ """
183
+ tool_lower = tool_name.lower()
184
+ desc_lower = (tool_description or "").lower()
185
+ args_str = str(arguments).lower()
186
+
187
+ # 1. Check for destructive commands/shell execution
188
+ destructive_commands = ["rm -rf", "rm -f", "mkfs", "dd if=", "reboot", "shutdown", "chmod 777", "chown"]
189
+ shell_keywords = ["shell", "terminal", "exec", "command", "system", "run"]
190
+
191
+ is_shell_tool = any(kw in tool_lower for kw in shell_keywords)
192
+ has_destructive = any(cmd in args_str for cmd in destructive_commands)
193
+
194
+ if is_shell_tool and has_destructive:
195
+ return {
196
+ "decision": "needs_full_review",
197
+ "risk_score": 0.95,
198
+ "risk_category": "high_risk",
199
+ "reason": "Server unreachable. Local policy block: Destructive command detected in shell execution."
200
+ }
201
+
202
+ # 2. Check for credential leaks / sensitive reads
203
+ sensitive_patterns = ["akiat", "akia", "slack_token", "stripe", "github_token", "password", "private_key", ".pem", "id_rsa"]
204
+ has_sensitive = any(pat in args_str for pat in sensitive_patterns)
205
+ has_sensitive_files = any(f in args_str for f in ["/etc/passwd", "/etc/shadow", ".ssh/"])
206
+
207
+ if has_sensitive or has_sensitive_files:
208
+ return {
209
+ "decision": "preview_and_confirmation",
210
+ "risk_score": 0.85,
211
+ "risk_category": "high_risk",
212
+ "reason": "Server unreachable. Local policy warning: Sensitive data or credential leak risk detected."
213
+ }
214
+
215
+ # 3. Check for general database or filesystem deletion
216
+ delete_keywords = ["delete", "remove", "destroy", "rm", "drop", "truncate"]
217
+ is_delete_op = any(kw in tool_lower for kw in delete_keywords) or any(kw in desc_lower for kw in delete_keywords)
218
+
219
+ if is_delete_op:
220
+ return {
221
+ "decision": "preview_and_confirmation",
222
+ "risk_score": 0.70,
223
+ "risk_category": "medium_risk",
224
+ "reason": "Server unreachable. Local policy warning: Destructive delete/remove operation detected."
225
+ }
226
+
227
+ # 4. Otherwise allow
228
+ return {
229
+ "decision": "allow",
230
+ "risk_score": 0.15,
231
+ "risk_category": "safe",
232
+ "reason": "Server unreachable. Local policy allow: No high-risk patterns matched."
233
+ }
234
+
235
+
236
+ def _worker_loop(self) -> None:
237
+ """Background thread loop that batches and sends tool logs."""
238
+ batch_size = self.config.batch_size
239
+ flush_interval = self.config.flush_interval
240
+
241
+ while not self.stop_event.is_set():
242
+ batch = self._gather_batch(batch_size, timeout=flush_interval)
243
+ if batch:
244
+ self._send_batch_with_retry(batch)
245
+
246
+ self._flush_remaining()
247
+
248
+ def _gather_batch(self, batch_size: int, timeout: float) -> List[Dict[str, Any]]:
249
+ batch = []
250
+ start_time = time.time()
251
+
252
+ while len(batch) < batch_size:
253
+ remaining_time = timeout - (time.time() - start_time)
254
+ if remaining_time <= 0:
255
+ break
256
+
257
+ try:
258
+ item = self.queue.get(timeout=max(0.01, remaining_time))
259
+ batch.append(item)
260
+ self.queue.task_done()
261
+ except queue.Empty:
262
+ break
263
+
264
+ return batch
265
+
266
+ def _send_batch_with_retry(self, batch: List[Dict[str, Any]]) -> None:
267
+ payload = {
268
+ "project_name": self.config.project_name,
269
+ "tool_calls": batch
270
+ }
271
+
272
+ data = json.dumps(payload).encode("utf-8")
273
+ headers = {
274
+ "Content-Type": "application/json",
275
+ "User-Agent": "governance-sdk-python/0.1.0"
276
+ }
277
+ if self.config.api_key:
278
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
279
+
280
+ backoff = 1.0
281
+ max_backoff = 30.0
282
+ retries = 3
283
+
284
+ for attempt in range(retries):
285
+ if self.stop_event.is_set() and attempt > 0:
286
+ break
287
+
288
+ try:
289
+ req = urllib.request.Request(
290
+ self.config.server_url,
291
+ data=data,
292
+ headers=headers,
293
+ method="POST"
294
+ )
295
+ with urllib.request.urlopen(req, timeout=5.0) as response:
296
+ if 200 <= response.status < 300:
297
+ return
298
+ except (urllib.error.URLError, Exception) as e:
299
+ if attempt < retries - 1:
300
+ time.sleep(backoff)
301
+ backoff = min(backoff * 2, max_backoff)
302
+ else:
303
+ logger.error(f"Governance SDK failed to send {len(batch)} tool logs: {e}")
304
+
305
+ def _flush_remaining(self) -> None:
306
+ while not self.queue.empty():
307
+ batch = []
308
+ while len(batch) < self.config.batch_size:
309
+ try:
310
+ item = self.queue.get_nowait()
311
+ batch.append(item)
312
+ self.queue.task_done()
313
+ except queue.Empty:
314
+ break
315
+ if batch:
316
+ self._send_batch_with_retry(batch)
317
+
318
+ def shutdown(self) -> None:
319
+ with self.lock:
320
+ if self.stop_event.is_set():
321
+ return
322
+ self.stop_event.set()
323
+
324
+ if self.worker_thread:
325
+ self.worker_thread.join(timeout=3.0)
326
+ self.worker_thread = None
327
+
328
+ try:
329
+ atexit.unregister(self.shutdown)
330
+ except Exception:
331
+ pass