consequence-gate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- consequence_gate/__init__.py +3 -0
- consequence_gate/backtest/__init__.py +0 -0
- consequence_gate/backtest/harness.py +43 -0
- consequence_gate/backtest/reporter.py +20 -0
- consequence_gate/cli.py +75 -0
- consequence_gate/core/__init__.py +0 -0
- consequence_gate/core/circuit_breaker.py +56 -0
- consequence_gate/core/evaluator.py +27 -0
- consequence_gate/core/models.py +35 -0
- consequence_gate/integrations/__init__.py +0 -0
- consequence_gate/integrations/examples/run_langgraph.py +60 -0
- consequence_gate/integrations/examples/run_mcp_proxy.py +100 -0
- consequence_gate/integrations/langgraph_hook.py +197 -0
- consequence_gate/integrations/mcp_proxy.py +316 -0
- consequence_gate/integrations/strands_hook.py +224 -0
- consequence_gate/simulators/__init__.py +0 -0
- consequence_gate/simulators/communications.py +290 -0
- consequence_gate/simulators/database.py +165 -0
- consequence_gate/simulators/financial.py +102 -0
- consequence_gate-0.1.0.dist-info/METADATA +254 -0
- consequence_gate-0.1.0.dist-info/RECORD +24 -0
- consequence_gate-0.1.0.dist-info/WHEEL +5 -0
- consequence_gate-0.1.0.dist-info/entry_points.txt +2 -0
- consequence_gate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Proxy: consequence-gate middleware for Model Context Protocol.
|
|
3
|
+
|
|
4
|
+
Intercepts tools/call requests, runs consequence simulation, and either:
|
|
5
|
+
- ALLOW: forwards request to downstream MCP server
|
|
6
|
+
- DENY: returns JSON-RPC error (code=-32603, "BLOCKED: <reason>")
|
|
7
|
+
- ASK: returns tool result with isError=true and "ESCALATION_REQUIRED" message
|
|
8
|
+
- STEER: returns tool result with isError=true and structured guidance
|
|
9
|
+
|
|
10
|
+
Transport: stdio (newline-delimited JSON-RPC)
|
|
11
|
+
- Reads from stdin (client -> proxy)
|
|
12
|
+
- Writes to stdout (proxy -> client)
|
|
13
|
+
- Forwards to downstream MCP server via subprocess stdio
|
|
14
|
+
|
|
15
|
+
MCP spec reference:
|
|
16
|
+
- tools/call: https://modelcontextprotocol.io/specification/2025-11-25/server/tools/
|
|
17
|
+
- Transport: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports/
|
|
18
|
+
- Error handling: https://apxml.com/courses/getting-started-model-context-protocol/chapter-3-implementing-tools-and-logic/error-handling-reporting
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any, Callable, Dict, Optional
|
|
25
|
+
|
|
26
|
+
from ..core.models import GateDecision, EvaluationResult
|
|
27
|
+
from ..core.circuit_breaker import SteerCircuitBreaker
|
|
28
|
+
from ..simulators.financial import FinancialDeltaPredictor
|
|
29
|
+
from ..simulators.database import DataDeletionSimulator
|
|
30
|
+
from ..simulators.communications import OutboundCommunicationSimulator
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MCPConsequenceProxy:
|
|
34
|
+
"""
|
|
35
|
+
MCP proxy that sits between an MCP client (Claude Desktop, Cursor, etc.)
|
|
36
|
+
and a downstream MCP server, intercepting tools/call requests to run
|
|
37
|
+
consequence simulation before forwarding.
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
proxy = MCPConsequenceProxy(
|
|
41
|
+
downstream_command=["npx", "-y", "mcp-server-mytool"],
|
|
42
|
+
simulator_fn=financial_simulator,
|
|
43
|
+
evaluator_fn=evaluator,
|
|
44
|
+
circuit_breaker=SteerCircuitBreaker(),
|
|
45
|
+
)
|
|
46
|
+
proxy.run() # Blocks, reading from stdin, writing to stdout
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
downstream_command: list,
|
|
52
|
+
simulator_fn: Callable[[str, Dict[str, Any], Dict[str, Any]], Any],
|
|
53
|
+
evaluator_fn: Callable[[Any, SteerCircuitBreaker], EvaluationResult],
|
|
54
|
+
circuit_breaker: Optional[SteerCircuitBreaker] = None,
|
|
55
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Args:
|
|
59
|
+
downstream_command: Command to launch downstream MCP server
|
|
60
|
+
simulator_fn: function(tool_name, args, context) -> delta
|
|
61
|
+
evaluator_fn: function(delta, circuit_breaker) -> EvaluationResult
|
|
62
|
+
circuit_breaker: SteerCircuitBreaker (default: max_retries=2)
|
|
63
|
+
context_provider: function(request_params) -> context dict
|
|
64
|
+
"""
|
|
65
|
+
self.downstream_command = downstream_command
|
|
66
|
+
self.simulator_fn = simulator_fn
|
|
67
|
+
self.evaluator_fn = evaluator_fn
|
|
68
|
+
self.circuit_breaker = circuit_breaker or SteerCircuitBreaker(max_retries=2)
|
|
69
|
+
self.context_provider = context_provider or (lambda params: {})
|
|
70
|
+
|
|
71
|
+
self.downstream_process: Optional[subprocess.Popen] = None
|
|
72
|
+
|
|
73
|
+
def _extract_natural_key(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
74
|
+
"""Extract stable natural key for idempotency."""
|
|
75
|
+
return arguments.get("claim_id") or arguments.get("transaction_ref") or f"{tool_name}:{json.dumps(arguments, sort_keys=True)}"
|
|
76
|
+
|
|
77
|
+
def _intercept_tools_call(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
78
|
+
"""
|
|
79
|
+
Intercept a tools/call request. Returns a response dict if the gate
|
|
80
|
+
decides DENY/ASK/STEER, or None if the request should be forwarded.
|
|
81
|
+
"""
|
|
82
|
+
params = request.get("params", {})
|
|
83
|
+
tool_name = params.get("name", "unknown")
|
|
84
|
+
arguments = params.get("arguments", {})
|
|
85
|
+
context = self.context_provider(params)
|
|
86
|
+
|
|
87
|
+
natural_key = self._extract_natural_key(tool_name, arguments)
|
|
88
|
+
delta = self.simulator_fn(tool_name, arguments, context)
|
|
89
|
+
result = self.evaluator_fn(delta, self.circuit_breaker)
|
|
90
|
+
|
|
91
|
+
request_id = request.get("id")
|
|
92
|
+
|
|
93
|
+
if result.decision == GateDecision.ALLOW:
|
|
94
|
+
return None # Forward to downstream
|
|
95
|
+
|
|
96
|
+
if result.decision == GateDecision.DENY:
|
|
97
|
+
# Protocol error - model cannot retry
|
|
98
|
+
return {
|
|
99
|
+
"jsonrpc": "2.0",
|
|
100
|
+
"id": request_id,
|
|
101
|
+
"error": {
|
|
102
|
+
"code": -32603,
|
|
103
|
+
"message": f"BLOCKED: {result.reason}",
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if result.decision in (GateDecision.ASK, GateDecision.STEER):
|
|
108
|
+
# Tool execution error - model can retry with adjusted parameters
|
|
109
|
+
if result.decision == GateDecision.ASK:
|
|
110
|
+
error_text = f"ESCALATION_REQUIRED: {result.reason}"
|
|
111
|
+
else: # STEER
|
|
112
|
+
steer_payload = result.steer_payload or {}
|
|
113
|
+
guidance = steer_payload.get("guidance", result.reason)
|
|
114
|
+
suggested_tool = steer_payload.get("suggested_tool")
|
|
115
|
+
suggested_args = steer_payload.get("suggested_args", {})
|
|
116
|
+
idempotency_key = suggested_args.get("idempotency_key")
|
|
117
|
+
if idempotency_key:
|
|
118
|
+
guidance += f" [idempotency_key={idempotency_key}]"
|
|
119
|
+
error_text = (
|
|
120
|
+
f"STEER_GUIDANCE: {guidance}\n"
|
|
121
|
+
f"Suggested alternative: {suggested_tool} with args {suggested_args}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"jsonrpc": "2.0",
|
|
126
|
+
"id": request_id,
|
|
127
|
+
"result": {
|
|
128
|
+
"content": [{"type": "text", "text": error_text}],
|
|
129
|
+
"isError": True,
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return None # Should not reach here
|
|
134
|
+
|
|
135
|
+
def _forward_to_downstream(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
136
|
+
"""Forward request to downstream MCP server and return response."""
|
|
137
|
+
if self.downstream_process is None:
|
|
138
|
+
self.downstream_process = subprocess.Popen(
|
|
139
|
+
self.downstream_command,
|
|
140
|
+
stdin=subprocess.PIPE,
|
|
141
|
+
stdout=subprocess.PIPE,
|
|
142
|
+
stderr=subprocess.PIPE,
|
|
143
|
+
text=True,
|
|
144
|
+
bufsize=1,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Write request to downstream stdin
|
|
148
|
+
request_line = json.dumps(request) + "\n"
|
|
149
|
+
self.downstream_process.stdin.write(request_line)
|
|
150
|
+
self.downstream_process.stdin.flush()
|
|
151
|
+
|
|
152
|
+
# Read response from downstream stdout
|
|
153
|
+
response_line = self.downstream_process.stdout.readline()
|
|
154
|
+
return json.loads(response_line)
|
|
155
|
+
|
|
156
|
+
def _process_line(self, line: str) -> Optional[str]:
|
|
157
|
+
"""Process a single JSON-RPC line from client."""
|
|
158
|
+
try:
|
|
159
|
+
request = json.loads(line)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
# Malformed JSON - forward as-is, let downstream handle
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
method = request.get("method")
|
|
165
|
+
if method != "tools/call":
|
|
166
|
+
# Not a tool call - forward as-is
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
# Intercept tools/call
|
|
170
|
+
intercepted_response = self._intercept_tools_call(request)
|
|
171
|
+
if intercepted_response is not None:
|
|
172
|
+
# Gate decided - return response directly to client
|
|
173
|
+
return json.dumps(intercepted_response)
|
|
174
|
+
|
|
175
|
+
# Gate allowed - forward to downstream
|
|
176
|
+
response = self._forward_to_downstream(request)
|
|
177
|
+
return json.dumps(response)
|
|
178
|
+
|
|
179
|
+
def run(self):
|
|
180
|
+
"""Main proxy loop: read from stdin, process, write to stdout."""
|
|
181
|
+
try:
|
|
182
|
+
for line in sys.stdin:
|
|
183
|
+
line = line.strip()
|
|
184
|
+
if not line:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
response_line = self._process_line(line)
|
|
188
|
+
if response_line is not None:
|
|
189
|
+
sys.stdout.write(response_line + "\n")
|
|
190
|
+
sys.stdout.flush()
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
pass
|
|
193
|
+
finally:
|
|
194
|
+
if self.downstream_process is not None:
|
|
195
|
+
self.downstream_process.terminate()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Convenience factory functions
|
|
199
|
+
|
|
200
|
+
def create_financial_mcp_proxy(
|
|
201
|
+
downstream_command: list,
|
|
202
|
+
daily_tier_limit_inr: float = 25000.0,
|
|
203
|
+
instant_wire_threshold: float = 10000.0,
|
|
204
|
+
max_retries: int = 2,
|
|
205
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
206
|
+
) -> MCPConsequenceProxy:
|
|
207
|
+
"""
|
|
208
|
+
Factory for financial-disbursement MCP proxy.
|
|
209
|
+
|
|
210
|
+
Usage:
|
|
211
|
+
proxy = create_financial_mcp_proxy(
|
|
212
|
+
downstream_command=["npx", "-y", "mcp-server-payments"],
|
|
213
|
+
daily_tier_limit_inr=25000.0,
|
|
214
|
+
context_provider=lambda params: {
|
|
215
|
+
"account_rolling_24h_spend": get_spend(params),
|
|
216
|
+
"kyc_verified": is_kyc_verified(params),
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
proxy.run()
|
|
220
|
+
"""
|
|
221
|
+
predictor = FinancialDeltaPredictor(
|
|
222
|
+
daily_tier_limit_inr=daily_tier_limit_inr,
|
|
223
|
+
instant_wire_threshold=instant_wire_threshold,
|
|
224
|
+
)
|
|
225
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
226
|
+
|
|
227
|
+
def evaluator(delta, circuit_breaker):
|
|
228
|
+
return predictor.evaluate(delta, circuit_breaker)
|
|
229
|
+
|
|
230
|
+
return MCPConsequenceProxy(
|
|
231
|
+
downstream_command=downstream_command,
|
|
232
|
+
simulator_fn=predictor.simulate,
|
|
233
|
+
evaluator_fn=evaluator,
|
|
234
|
+
circuit_breaker=breaker,
|
|
235
|
+
context_provider=context_provider,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def create_database_mcp_proxy(
|
|
240
|
+
downstream_command: list,
|
|
241
|
+
max_autonomous_delete_rows: int = 100,
|
|
242
|
+
db_conn=None,
|
|
243
|
+
max_retries: int = 2,
|
|
244
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
245
|
+
) -> MCPConsequenceProxy:
|
|
246
|
+
"""
|
|
247
|
+
Factory for database-deletion MCP proxy.
|
|
248
|
+
|
|
249
|
+
Usage:
|
|
250
|
+
proxy = create_database_mcp_proxy(
|
|
251
|
+
downstream_command=["npx", "-y", "mcp-server-postgres"],
|
|
252
|
+
max_autonomous_delete_rows=100,
|
|
253
|
+
db_conn=get_db_connection(),
|
|
254
|
+
)
|
|
255
|
+
proxy.run()
|
|
256
|
+
"""
|
|
257
|
+
simulator = DataDeletionSimulator(
|
|
258
|
+
max_autonomous_delete_rows=max_autonomous_delete_rows,
|
|
259
|
+
db_conn=db_conn,
|
|
260
|
+
)
|
|
261
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
262
|
+
|
|
263
|
+
def evaluator(delta, circuit_breaker):
|
|
264
|
+
return simulator.evaluate(delta, circuit_breaker)
|
|
265
|
+
|
|
266
|
+
return MCPConsequenceProxy(
|
|
267
|
+
downstream_command=downstream_command,
|
|
268
|
+
simulator_fn=simulator.simulate,
|
|
269
|
+
evaluator_fn=evaluator,
|
|
270
|
+
circuit_breaker=breaker,
|
|
271
|
+
context_provider=context_provider,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def create_communications_mcp_proxy(
|
|
276
|
+
downstream_command: list,
|
|
277
|
+
max_autonomous_recipients: int = 10000,
|
|
278
|
+
canary_min_size: int = 100,
|
|
279
|
+
canary_max_bounce_rate: float = 0.05,
|
|
280
|
+
canary_max_complaint_rate: float = 0.01,
|
|
281
|
+
max_retries: int = 2,
|
|
282
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
283
|
+
) -> MCPConsequenceProxy:
|
|
284
|
+
"""
|
|
285
|
+
Factory for communications-blast MCP proxy.
|
|
286
|
+
|
|
287
|
+
Usage:
|
|
288
|
+
proxy = create_communications_mcp_proxy(
|
|
289
|
+
downstream_command=["npx", "-y", "mcp-server-sendgrid"],
|
|
290
|
+
max_autonomous_recipients=10000,
|
|
291
|
+
context_provider=lambda params: {
|
|
292
|
+
"segment_counts": get_segments(params),
|
|
293
|
+
"recent_unsubscribes": get_unsubscribes(params),
|
|
294
|
+
"historical_bounce_rate": 0.02,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
proxy.run()
|
|
298
|
+
"""
|
|
299
|
+
simulator = OutboundCommunicationSimulator(
|
|
300
|
+
max_autonomous_recipients=max_autonomous_recipients,
|
|
301
|
+
canary_min_size=canary_min_size,
|
|
302
|
+
canary_max_bounce_rate=canary_max_bounce_rate,
|
|
303
|
+
canary_max_complaint_rate=canary_max_complaint_rate,
|
|
304
|
+
)
|
|
305
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
306
|
+
|
|
307
|
+
def evaluator(delta, circuit_breaker):
|
|
308
|
+
return simulator.evaluate(delta, circuit_breaker)
|
|
309
|
+
|
|
310
|
+
return MCPConsequenceProxy(
|
|
311
|
+
downstream_command=downstream_command,
|
|
312
|
+
simulator_fn=simulator.simulate,
|
|
313
|
+
evaluator_fn=evaluator,
|
|
314
|
+
circuit_breaker=breaker,
|
|
315
|
+
context_provider=context_provider,
|
|
316
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strands Agents integration: BeforeToolCallEvent hook adapter.
|
|
3
|
+
|
|
4
|
+
Strands API reference:
|
|
5
|
+
- BeforeToolCallEvent: https://strandsagents.com/docs/api/python/strands.hooks.events/
|
|
6
|
+
- Event attributes: selected_tool, cancel_tool (string or True)
|
|
7
|
+
- No event.set_result() exists — steering must be done via cancellation
|
|
8
|
+
message that the agent sees on its next turn and can re-reason from.
|
|
9
|
+
|
|
10
|
+
Design contract (from project history):
|
|
11
|
+
- No silent argument mutation — we cancel with guidance, not rewrite args.
|
|
12
|
+
- Idempotency keys derived from natural_key, not regenerated per retry.
|
|
13
|
+
- Hard retry cap (default: 2) before forced escalation to ASK.
|
|
14
|
+
- Confidence-gated escalation — low confidence routes to ASK, never
|
|
15
|
+
confident ALLOW/DENY on unfounded projections.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Any, Callable, Dict, Optional
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from strands.hooks import BeforeToolCallEvent
|
|
23
|
+
from strands.hooks.events import HookProvider, HookRegistry
|
|
24
|
+
except ImportError:
|
|
25
|
+
class HookProvider: # type: ignore[no-redef]
|
|
26
|
+
"""Fallback base class when strands-agents is not installed."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
BeforeToolCallEvent = Any # type: ignore[misc,assignment]
|
|
30
|
+
HookRegistry = Any # type: ignore[misc,assignment]
|
|
31
|
+
|
|
32
|
+
from ..core.models import GateDecision, EvaluationResult
|
|
33
|
+
from ..core.circuit_breaker import SteerCircuitBreaker
|
|
34
|
+
from ..simulators.financial import FinancialDeltaPredictor
|
|
35
|
+
from ..simulators.database import DataDeletionSimulator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConsequenceGateHook(HookProvider):
|
|
39
|
+
"""
|
|
40
|
+
Strands hook provider that intercepts BeforeToolCallEvent, runs the
|
|
41
|
+
consequence simulation gate, and either:
|
|
42
|
+
- ALLOW: returns cleanly, tool executes normally
|
|
43
|
+
- DENY: sets event.cancel_tool with a hard-block message
|
|
44
|
+
- ASK: sets event.cancel_tool with an escalation message
|
|
45
|
+
- STEER: sets event.cancel_tool with structured guidance that the
|
|
46
|
+
agent can parse and retry toward on its next turn
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
from consequence_gate.integrations.strands_hook import ConsequenceGateHook
|
|
50
|
+
|
|
51
|
+
hook = ConsequenceGateHook(
|
|
52
|
+
simulator_fn=financial_simulator, # or database_simulator, etc.
|
|
53
|
+
circuit_breaker=SteerCircuitBreaker(max_retries=2),
|
|
54
|
+
)
|
|
55
|
+
agent = Agent(hooks=[hook])
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
simulator_fn: Callable[[str, Dict[str, Any], Dict[str, Any]], Any],
|
|
61
|
+
evaluator_fn: Optional[Callable[[Any, SteerCircuitBreaker], EvaluationResult]] = None,
|
|
62
|
+
circuit_breaker: Optional[SteerCircuitBreaker] = None,
|
|
63
|
+
context_provider: Optional[Callable[[BeforeToolCallEvent], Dict[str, Any]]] = None,
|
|
64
|
+
):
|
|
65
|
+
"""
|
|
66
|
+
Args:
|
|
67
|
+
simulator_fn: function(tool_name, args, context) -> delta object
|
|
68
|
+
evaluator_fn: function(delta, circuit_breaker) -> EvaluationResult
|
|
69
|
+
If None, uses a default generic evaluator.
|
|
70
|
+
circuit_breaker: SteerCircuitBreaker instance (default: max_retries=2)
|
|
71
|
+
context_provider: function(event) -> context dict for simulation
|
|
72
|
+
If None, uses a minimal default context.
|
|
73
|
+
"""
|
|
74
|
+
self.simulator_fn = simulator_fn
|
|
75
|
+
self.evaluator_fn = evaluator_fn
|
|
76
|
+
self.circuit_breaker = circuit_breaker or SteerCircuitBreaker(max_retries=2)
|
|
77
|
+
self.context_provider = context_provider or self._default_context
|
|
78
|
+
|
|
79
|
+
def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
|
|
80
|
+
registry.add_callback(BeforeToolCallEvent, self.intercept)
|
|
81
|
+
|
|
82
|
+
def _default_context(self, event: BeforeToolCallEvent) -> Dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Minimal default context extractor. Override this to pull in
|
|
85
|
+
session state, user identity, account telemetry, etc.
|
|
86
|
+
"""
|
|
87
|
+
return {
|
|
88
|
+
"tool_use_id": event.tool_use.get("toolUseId"),
|
|
89
|
+
"conversation_id": getattr(event, "invocation_state", {}).get("conversation_id"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def intercept(self, event: BeforeToolCallEvent) -> None:
|
|
93
|
+
tool_name = event.tool_use.get("name", "unknown")
|
|
94
|
+
args = event.tool_use.get("input", {})
|
|
95
|
+
context = self.context_provider(event)
|
|
96
|
+
|
|
97
|
+
# Extract natural key from args (domain-specific; financial uses claim_id,
|
|
98
|
+
# database uses table+filter hash, etc.)
|
|
99
|
+
natural_key = args.get("claim_id") or args.get("transaction_ref") or f"{tool_name}:{json.dumps(args, sort_keys=True)}"
|
|
100
|
+
|
|
101
|
+
# Run simulation + evaluation
|
|
102
|
+
delta = self.simulator_fn(tool_name, args, context)
|
|
103
|
+
|
|
104
|
+
if self.evaluator_fn is not None:
|
|
105
|
+
result = self.evaluator_fn(delta, self.circuit_breaker)
|
|
106
|
+
else:
|
|
107
|
+
# Fallback: generic evaluator (not domain-aware, but safe)
|
|
108
|
+
from ..core.evaluator import BlastRadiusEvaluator
|
|
109
|
+
evaluator = BlastRadiusEvaluator()
|
|
110
|
+
gate_decision = evaluator.evaluate(delta)
|
|
111
|
+
# Wrap generic decision into EvaluationResult for uniform handling
|
|
112
|
+
result = EvaluationResult(
|
|
113
|
+
decision=gate_decision,
|
|
114
|
+
confidence=delta.confidence,
|
|
115
|
+
reason="Generic evaluator fallback.",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Apply decision
|
|
119
|
+
if result.decision == GateDecision.ALLOW:
|
|
120
|
+
# Pass through — do nothing, tool executes normally
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if result.decision == GateDecision.DENY:
|
|
124
|
+
event.cancel_tool = f"BLOCKED: {result.reason}"
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if result.decision == GateDecision.ASK:
|
|
128
|
+
event.cancel_tool = f"ESCALATION_REQUIRED: {result.reason}"
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if result.decision == GateDecision.STEER:
|
|
132
|
+
# Cancel with structured guidance — agent sees this message
|
|
133
|
+
# on its next turn and can re-reason toward the safer path.
|
|
134
|
+
steer_payload = result.steer_payload or {}
|
|
135
|
+
guidance_text = steer_payload.get("guidance", result.reason)
|
|
136
|
+
suggested_tool = steer_payload.get("suggested_tool")
|
|
137
|
+
suggested_args = steer_payload.get("suggested_args", {})
|
|
138
|
+
|
|
139
|
+
# Include idempotency key in the guidance so the agent can
|
|
140
|
+
# include it when retrying (if the suggested tool expects it).
|
|
141
|
+
idempotency_key = suggested_args.get("idempotency_key")
|
|
142
|
+
if idempotency_key:
|
|
143
|
+
guidance_text += f" [idempotency_key={idempotency_key}]"
|
|
144
|
+
|
|
145
|
+
cancel_message = (
|
|
146
|
+
f"STEER_GUIDANCE: {guidance_text}\n"
|
|
147
|
+
f"Suggested alternative: {suggested_tool} with args {suggested_args}"
|
|
148
|
+
)
|
|
149
|
+
event.cancel_tool = cancel_message
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Convenience factory functions for common domain simulators
|
|
154
|
+
|
|
155
|
+
def create_financial_gate_hook(
|
|
156
|
+
daily_tier_limit_inr: float = 25000.0,
|
|
157
|
+
instant_wire_threshold: float = 10000.0,
|
|
158
|
+
max_retries: int = 2,
|
|
159
|
+
context_provider: Optional[Callable[[BeforeToolCallEvent], Dict[str, Any]]] = None,
|
|
160
|
+
) -> ConsequenceGateHook:
|
|
161
|
+
"""
|
|
162
|
+
Factory for a financial-disbursement gate hook.
|
|
163
|
+
|
|
164
|
+
Usage:
|
|
165
|
+
hook = create_financial_gate_hook(
|
|
166
|
+
daily_tier_limit_inr=25000.0,
|
|
167
|
+
context_provider=lambda event: {
|
|
168
|
+
"account_rolling_24h_spend": get_current_spend(event),
|
|
169
|
+
"kyc_verified": is_kyc_verified(event),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
agent = Agent(hooks=[hook])
|
|
173
|
+
"""
|
|
174
|
+
predictor = FinancialDeltaPredictor(
|
|
175
|
+
daily_tier_limit_inr=daily_tier_limit_inr,
|
|
176
|
+
instant_wire_threshold=instant_wire_threshold,
|
|
177
|
+
)
|
|
178
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
179
|
+
|
|
180
|
+
def evaluator(delta, circuit_breaker):
|
|
181
|
+
return predictor.evaluate(delta, circuit_breaker)
|
|
182
|
+
|
|
183
|
+
return ConsequenceGateHook(
|
|
184
|
+
simulator_fn=predictor.simulate,
|
|
185
|
+
evaluator_fn=evaluator,
|
|
186
|
+
circuit_breaker=breaker,
|
|
187
|
+
context_provider=context_provider,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_database_gate_hook(
|
|
192
|
+
max_autonomous_delete_rows: int = 100,
|
|
193
|
+
db_conn=None,
|
|
194
|
+
max_retries: int = 2,
|
|
195
|
+
context_provider: Optional[Callable[[BeforeToolCallEvent], Dict[str, Any]]] = None,
|
|
196
|
+
) -> ConsequenceGateHook:
|
|
197
|
+
"""
|
|
198
|
+
Factory for a database-deletion gate hook.
|
|
199
|
+
|
|
200
|
+
Usage:
|
|
201
|
+
hook = create_database_gate_hook(
|
|
202
|
+
max_autonomous_delete_rows=100,
|
|
203
|
+
db_conn=get_db_connection(),
|
|
204
|
+
context_provider=lambda event: {
|
|
205
|
+
"table_metadata": get_table_metadata(event),
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
agent = Agent(hooks=[hook])
|
|
209
|
+
"""
|
|
210
|
+
simulator = DataDeletionSimulator(
|
|
211
|
+
max_autonomous_delete_rows=max_autonomous_delete_rows,
|
|
212
|
+
db_conn=db_conn,
|
|
213
|
+
)
|
|
214
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
215
|
+
|
|
216
|
+
def evaluator(delta, circuit_breaker):
|
|
217
|
+
return simulator.evaluate(delta, circuit_breaker)
|
|
218
|
+
|
|
219
|
+
return ConsequenceGateHook(
|
|
220
|
+
simulator_fn=simulator.simulate,
|
|
221
|
+
evaluator_fn=evaluator,
|
|
222
|
+
circuit_breaker=breaker,
|
|
223
|
+
context_provider=context_provider,
|
|
224
|
+
)
|
|
File without changes
|