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
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline backtest harness: replays historical JSONL tool-call traces
|
|
3
|
+
through a simulator + evaluator, WITHOUT re-executing anything, to
|
|
4
|
+
measure the four-quadrant FP/FN/TN/steer-recovery breakdown against
|
|
5
|
+
the trace's recorded existing_gate_decision and actual_execution_status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Callable, Dict, Iterable, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_traces(path: str) -> List[Dict]:
|
|
13
|
+
traces = []
|
|
14
|
+
with open(path) as f:
|
|
15
|
+
for line in f:
|
|
16
|
+
line = line.strip()
|
|
17
|
+
if line:
|
|
18
|
+
traces.append(json.loads(line))
|
|
19
|
+
return traces
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_backtest(traces: Iterable[Dict], simulate_and_evaluate: Callable[[Dict], str]) -> List[Dict]:
|
|
23
|
+
"""
|
|
24
|
+
simulate_and_evaluate: function(trace) -> decision string ("ALLOW"/"DENY"/"ASK"/"STEER")
|
|
25
|
+
Returns per-trace records annotated with quadrant classification.
|
|
26
|
+
"""
|
|
27
|
+
results = []
|
|
28
|
+
for trace in traces:
|
|
29
|
+
new_decision = simulate_and_evaluate(trace)
|
|
30
|
+
old_decision = trace.get("existing_gate_decision", "ALLOW")
|
|
31
|
+
outcome = trace.get("actual_execution_status", "UNKNOWN")
|
|
32
|
+
|
|
33
|
+
if old_decision == "ALLOW" and new_decision in ("DENY", "STEER", "ASK") and outcome != "SUCCESS":
|
|
34
|
+
quadrant = "FALSE_NEGATIVE_CAUGHT"
|
|
35
|
+
elif old_decision in ("DENY", "ASK") and new_decision == "ALLOW":
|
|
36
|
+
quadrant = "FALSE_POSITIVE_RELIEVED"
|
|
37
|
+
elif old_decision == "ALLOW" and new_decision == "ALLOW":
|
|
38
|
+
quadrant = "TRUE_NEGATIVE"
|
|
39
|
+
else:
|
|
40
|
+
quadrant = "OTHER"
|
|
41
|
+
|
|
42
|
+
results.append({**trace, "simulated_decision": new_decision, "quadrant": quadrant})
|
|
43
|
+
return results
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates the four-quadrant FP/FN breakdown report from backtest results.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_report(results: List[Dict]) -> Dict:
|
|
10
|
+
counts = Counter(r["quadrant"] for r in results)
|
|
11
|
+
total = len(results)
|
|
12
|
+
return {
|
|
13
|
+
"total_traces": total,
|
|
14
|
+
"true_negative": counts.get("TRUE_NEGATIVE", 0),
|
|
15
|
+
"false_negative_caught": counts.get("FALSE_NEGATIVE_CAUGHT", 0),
|
|
16
|
+
"false_positive_relieved": counts.get("FALSE_POSITIVE_RELIEVED", 0),
|
|
17
|
+
"other": counts.get("OTHER", 0),
|
|
18
|
+
"false_negative_rate": counts.get("FALSE_NEGATIVE_CAUGHT", 0) / total if total else 0.0,
|
|
19
|
+
"false_positive_relief_rate": counts.get("FALSE_POSITIVE_RELIEVED", 0) / total if total else 0.0,
|
|
20
|
+
}
|
consequence_gate/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for consequence-gate.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from consequence_gate import __version__
|
|
9
|
+
from consequence_gate.backtest.harness import load_traces, run_backtest
|
|
10
|
+
from consequence_gate.backtest.reporter import generate_report
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_evaluator(trace: dict) -> str:
|
|
14
|
+
"""Heuristic evaluator for CLI demo and trace analysis."""
|
|
15
|
+
tool = trace.get("tool_name", "")
|
|
16
|
+
args = trace.get("tool_args", {})
|
|
17
|
+
|
|
18
|
+
tool_lower = tool.lower()
|
|
19
|
+
args_str = str(args).lower()
|
|
20
|
+
|
|
21
|
+
if any(k in tool_lower for k in ("delete", "drop", "purge", "truncate")) or any(
|
|
22
|
+
k in args_str for k in ("drop ", "delete from", "truncate ", "purge")
|
|
23
|
+
):
|
|
24
|
+
return "DENY"
|
|
25
|
+
if "transfer" in tool.lower() or "pay" in tool.lower():
|
|
26
|
+
amount = args.get("amount", 0)
|
|
27
|
+
if isinstance(amount, (int, float)) and amount > 5000:
|
|
28
|
+
return "ASK"
|
|
29
|
+
return "ALLOW"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="consequence-gate",
|
|
35
|
+
description="Speculative outcome-simulation gate & trace backtesting for AI agent tool calls.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
38
|
+
|
|
39
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
40
|
+
|
|
41
|
+
# Backtest subcommand
|
|
42
|
+
bt_parser = subparsers.add_parser("backtest", help="Run offline backtesting on a JSONL trace file")
|
|
43
|
+
bt_parser.add_argument("file", help="Path to JSONL file containing recorded agent traces")
|
|
44
|
+
bt_parser.add_argument("--json", action="store_true", help="Output results in JSON format")
|
|
45
|
+
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
|
|
48
|
+
if args.command == "backtest":
|
|
49
|
+
try:
|
|
50
|
+
traces = load_traces(args.file)
|
|
51
|
+
results = run_backtest(traces, default_evaluator)
|
|
52
|
+
report = generate_report(results)
|
|
53
|
+
|
|
54
|
+
if args.json:
|
|
55
|
+
print(json.dumps(report, indent=2))
|
|
56
|
+
else:
|
|
57
|
+
print("\n================ CONSEQUENCE GATE BACKTEST REPORT ================")
|
|
58
|
+
print(f"Total Traces Evaluated: {report['total_traces']}")
|
|
59
|
+
print(f"True Negatives: {report['true_negative']}")
|
|
60
|
+
print(f"False Negatives Caught: {report['false_negative_caught']}")
|
|
61
|
+
print(f"False Positives Relieved: {report['false_positive_relieved']}")
|
|
62
|
+
print(f"Other / Unclassified: {report['other']}")
|
|
63
|
+
print("-----------------------------------------------------------------")
|
|
64
|
+
print(f"False Negative Catch Rate: {report['false_negative_rate']:.2%}")
|
|
65
|
+
print(f"False Positive Relief Rate: {report['false_positive_relief_rate']:.2%}")
|
|
66
|
+
print("=================================================================\n")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f"Error executing backtest: {e}", file=sys.stderr)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
else:
|
|
71
|
+
parser.print_help()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerCircuitBreaker: idempotency-locked retry cap for STEER decisions.
|
|
3
|
+
|
|
4
|
+
Design contract (see project history / design notes):
|
|
5
|
+
- The idempotency token is derived ONCE from the transaction's own natural
|
|
6
|
+
key (e.g. claim_id, table+filter hash) -- never regenerated per retry.
|
|
7
|
+
A fresh UUID per attempt defeats duplicate-execution protection.
|
|
8
|
+
- Responses are cached per token, so a retry with the same natural key
|
|
9
|
+
returns the cached result instead of re-executing (Stripe-style contract).
|
|
10
|
+
- Retry count is tracked server-side per token, with a hard cap. Once
|
|
11
|
+
exceeded, the breaker forces ASK (human escalation) regardless of how
|
|
12
|
+
good the steering guidance is -- this is a backstop against
|
|
13
|
+
loop-thrashing, independent of guidance quality.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
from .models import GateDecision, EvaluationResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SteerCircuitBreaker:
|
|
21
|
+
def __init__(self, max_retries: int = 2):
|
|
22
|
+
self.max_retries = max_retries
|
|
23
|
+
self._attempts: Dict[str, int] = {}
|
|
24
|
+
self._responses: Dict[str, EvaluationResult] = {}
|
|
25
|
+
|
|
26
|
+
def token_for(self, natural_key: str) -> str:
|
|
27
|
+
return f"steer_{natural_key}"
|
|
28
|
+
|
|
29
|
+
def resolve(self, natural_key: str, confidence: float,
|
|
30
|
+
base_steer: Dict[str, Any]) -> EvaluationResult:
|
|
31
|
+
token = self.token_for(natural_key)
|
|
32
|
+
|
|
33
|
+
if token in self._responses:
|
|
34
|
+
return self._responses[token]
|
|
35
|
+
|
|
36
|
+
attempt = self._attempts.get(token, 0)
|
|
37
|
+
|
|
38
|
+
if attempt >= self.max_retries:
|
|
39
|
+
result = EvaluationResult(
|
|
40
|
+
decision=GateDecision.ASK,
|
|
41
|
+
confidence=confidence,
|
|
42
|
+
reason=f"Steer circuit breaker tripped ({attempt}/{self.max_retries}). Escalating to human.",
|
|
43
|
+
)
|
|
44
|
+
self._responses[token] = result
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
self._attempts[token] = attempt + 1
|
|
48
|
+
base_steer.setdefault("suggested_args", {})["idempotency_key"] = token
|
|
49
|
+
|
|
50
|
+
result = EvaluationResult(
|
|
51
|
+
decision=GateDecision.STEER,
|
|
52
|
+
confidence=confidence,
|
|
53
|
+
reason=f"Steered to safer path (attempt {attempt + 1}/{self.max_retries}).",
|
|
54
|
+
steer_payload=base_steer,
|
|
55
|
+
)
|
|
56
|
+
return result
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BlastRadiusEvaluator: generic threshold evaluation for simulated deltas.
|
|
3
|
+
Domain simulators typically implement their own evaluate() with
|
|
4
|
+
domain-specific rules, but this provides a reusable default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import SimulatedStateDelta, GateDecision
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BlastRadiusEvaluator:
|
|
11
|
+
def __init__(self, max_irreversible_value: float = 1000.0,
|
|
12
|
+
min_confidence_for_autopass: float = 0.85):
|
|
13
|
+
self.max_irreversible_value = max_irreversible_value
|
|
14
|
+
self.min_confidence_for_autopass = min_confidence_for_autopass
|
|
15
|
+
|
|
16
|
+
def evaluate(self, delta: SimulatedStateDelta) -> GateDecision:
|
|
17
|
+
if delta.confidence < self.min_confidence_for_autopass:
|
|
18
|
+
return GateDecision.ASK
|
|
19
|
+
|
|
20
|
+
breach = any(abs(v) > self.max_irreversible_value
|
|
21
|
+
for v in delta.numeric_deltas.values())
|
|
22
|
+
|
|
23
|
+
if delta.irreversibility_score >= 0.9 and breach:
|
|
24
|
+
return GateDecision.STEER
|
|
25
|
+
if breach:
|
|
26
|
+
return GateDecision.ASK
|
|
27
|
+
return GateDecision.ALLOW
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data models shared across all domain simulators.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GateDecision(str, Enum):
|
|
11
|
+
ALLOW = "ALLOW"
|
|
12
|
+
DENY = "DENY"
|
|
13
|
+
ASK = "ASK"
|
|
14
|
+
STEER = "STEER"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SimulatedStateDelta:
|
|
19
|
+
"""Generic projected-outcome envelope. Domain simulators subclass or
|
|
20
|
+
populate this with their own numeric_deltas / side-effect semantics."""
|
|
21
|
+
tool_name: str
|
|
22
|
+
proposed_args: Dict[str, Any]
|
|
23
|
+
numeric_deltas: Dict[str, float] = field(default_factory=dict)
|
|
24
|
+
irreversibility_score: float = 0.0 # 0.0 fully reversible -> 1.0 irreversible
|
|
25
|
+
confidence: float = 0.0 # 0.0 -> 1.0, simulator's own confidence in this projection
|
|
26
|
+
simulated_side_effects: List[str] = field(default_factory=list)
|
|
27
|
+
natural_key: Optional[str] = None # stable identity for idempotency (NOT a random token)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class EvaluationResult:
|
|
32
|
+
decision: GateDecision
|
|
33
|
+
confidence: float
|
|
34
|
+
reason: str
|
|
35
|
+
steer_payload: Optional[Dict[str, Any]] = None
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example: Use consequence-gate with LangGraph agents via middleware.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m consequence_gate.integrations.examples.run_langgraph
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Add parent directory to path for imports
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
13
|
+
|
|
14
|
+
from langchain.agents import create_agent
|
|
15
|
+
from langchain_core.tools import tool
|
|
16
|
+
|
|
17
|
+
from consequence_gate.integrations.langgraph_hook import create_financial_gate_middleware
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool
|
|
21
|
+
def process_claim(amount: float, claim_id: str, currency: str = "INR", payout_method: str = "standard_ach") -> str:
|
|
22
|
+
"""Process a claim payout."""
|
|
23
|
+
return f"Processed claim {claim_id} for {amount} {currency} via {payout_method}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
# Create middleware
|
|
28
|
+
middleware = create_financial_gate_middleware(
|
|
29
|
+
daily_tier_limit_inr=25000.0,
|
|
30
|
+
instant_wire_threshold=10000.0,
|
|
31
|
+
max_retries=2,
|
|
32
|
+
context_provider=lambda state: {
|
|
33
|
+
"account_rolling_24h_spend": 0.0,
|
|
34
|
+
"kyc_verified": True,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Create agent with middleware
|
|
39
|
+
agent = create_agent(
|
|
40
|
+
model="claude-sonnet-4",
|
|
41
|
+
tools=[process_claim],
|
|
42
|
+
middleware=[middleware],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print("LangGraph example:")
|
|
46
|
+
print("Agent created with consequence-gate middleware")
|
|
47
|
+
print()
|
|
48
|
+
print("Try this invocation:")
|
|
49
|
+
print('agent.invoke({"messages": [("user", "Process claim for 50,000 INR")]})')
|
|
50
|
+
print()
|
|
51
|
+
print("Expected behavior:")
|
|
52
|
+
print("- The agent will propose a tool call: process_claim(amount=50000, ...)")
|
|
53
|
+
print("- The middleware will intercept and simulate the outcome")
|
|
54
|
+
print("- Since 50,000 INR exceeds the 25,000 INR tier limit, the middleware will return:")
|
|
55
|
+
print(' ToolMessage(content="STEER_GUIDANCE: Cannot process full 50,000 INR...")')
|
|
56
|
+
print("- The agent will see this as the tool result and can retry with a safer alternative")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example: Run consequence-gate as an MCP proxy.
|
|
3
|
+
|
|
4
|
+
This script demonstrates how to run the MCP proxy in front of a downstream
|
|
5
|
+
MCP server. The proxy intercepts tools/call requests, runs consequence
|
|
6
|
+
simulation, and either allows, denies, asks, or steers the call.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
# Financial disbursement gate
|
|
10
|
+
python -m consequence_gate.integrations.examples.run_mcp_proxy financial \\
|
|
11
|
+
--downstream-command "npx -y @modelcontextprotocol/server-postgres postgresql://localhost/mydb" \\
|
|
12
|
+
--daily-tier-limit 25000 \\
|
|
13
|
+
--instant-wire-threshold 10000
|
|
14
|
+
|
|
15
|
+
# Database deletion gate
|
|
16
|
+
python -m consequence_gate.integrations.examples.run_mcp_proxy database \\
|
|
17
|
+
--downstream-command "npx -y @modelcontextprotocol/server-postgres postgresql://localhost/mydb" \\
|
|
18
|
+
--max-autonomous-delete-rows 100
|
|
19
|
+
|
|
20
|
+
# Communications blast gate
|
|
21
|
+
python -m consequence_gate.integrations.examples.run_mcp_proxy communications \\
|
|
22
|
+
--downstream-command "npx -y @modelcontextprotocol/server-sendgrid" \\
|
|
23
|
+
--max-autonomous-recipients 10000
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from consequence_gate.integrations.mcp_proxy import (
|
|
31
|
+
create_financial_mcp_proxy,
|
|
32
|
+
create_database_mcp_proxy,
|
|
33
|
+
create_communications_mcp_proxy,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_args():
|
|
38
|
+
parser = argparse.ArgumentParser(description="Run consequence-gate MCP proxy")
|
|
39
|
+
subparsers = parser.add_subparsers(dest="domain", required=True)
|
|
40
|
+
|
|
41
|
+
# Financial subcommand
|
|
42
|
+
financial_parser = subparsers.add_parser("financial", help="Financial disbursement gate")
|
|
43
|
+
financial_parser.add_argument("--downstream-command", required=True, help="Downstream MCP server command")
|
|
44
|
+
financial_parser.add_argument("--daily-tier-limit", type=float, default=25000.0)
|
|
45
|
+
financial_parser.add_argument("--instant-wire-threshold", type=float, default=10000.0)
|
|
46
|
+
financial_parser.add_argument("--max-retries", type=int, default=2)
|
|
47
|
+
|
|
48
|
+
# Database subcommand
|
|
49
|
+
db_parser = subparsers.add_parser("database", help="Database deletion gate")
|
|
50
|
+
db_parser.add_argument("--downstream-command", required=True, help="Downstream MCP server command")
|
|
51
|
+
db_parser.add_argument("--max-autonomous-delete-rows", type=int, default=100)
|
|
52
|
+
db_parser.add_argument("--max-retries", type=int, default=2)
|
|
53
|
+
|
|
54
|
+
# Communications subcommand
|
|
55
|
+
comm_parser = subparsers.add_parser("communications", help="Communications blast gate")
|
|
56
|
+
comm_parser.add_argument("--downstream-command", required=True, help="Downstream MCP server command")
|
|
57
|
+
comm_parser.add_argument("--max-autonomous-recipients", type=int, default=10000)
|
|
58
|
+
comm_parser.add_argument("--canary-min-size", type=int, default=100)
|
|
59
|
+
comm_parser.add_argument("--max-retries", type=int, default=2)
|
|
60
|
+
|
|
61
|
+
return parser.parse_args()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
args = parse_args()
|
|
66
|
+
|
|
67
|
+
downstream_command = args.downstream_command.split()
|
|
68
|
+
|
|
69
|
+
if args.domain == "financial":
|
|
70
|
+
proxy = create_financial_mcp_proxy(
|
|
71
|
+
downstream_command=downstream_command,
|
|
72
|
+
daily_tier_limit_inr=args.daily_tier_limit,
|
|
73
|
+
instant_wire_threshold=args.instant_wire_threshold,
|
|
74
|
+
max_retries=args.max_retries,
|
|
75
|
+
)
|
|
76
|
+
elif args.domain == "database":
|
|
77
|
+
proxy = create_database_mcp_proxy(
|
|
78
|
+
downstream_command=downstream_command,
|
|
79
|
+
max_autonomous_delete_rows=args.max_autonomous_delete_rows,
|
|
80
|
+
max_retries=args.max_retries,
|
|
81
|
+
)
|
|
82
|
+
elif args.domain == "communications":
|
|
83
|
+
proxy = create_communications_mcp_proxy(
|
|
84
|
+
downstream_command=downstream_command,
|
|
85
|
+
max_autonomous_recipients=args.max_autonomous_recipients,
|
|
86
|
+
canary_min_size=args.canary_min_size,
|
|
87
|
+
max_retries=args.max_retries,
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f"Unknown domain: {args.domain}")
|
|
91
|
+
|
|
92
|
+
print(f"Starting consequence-gate MCP proxy ({args.domain} domain)...", file=sys.stderr)
|
|
93
|
+
print(f"Downstream command: {' '.join(downstream_command)}", file=sys.stderr)
|
|
94
|
+
print("Reading from stdin, writing to stdout...", file=sys.stderr)
|
|
95
|
+
|
|
96
|
+
proxy.run()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangGraph integration: middleware that wraps tool calls with consequence gating.
|
|
3
|
+
|
|
4
|
+
Two patterns supported:
|
|
5
|
+
1. @wrap_tool_call decorator - intercepts tool execution in LangGraph agents
|
|
6
|
+
2. Custom ToolNode wrapper - for StateGraph workflows with explicit ToolNode
|
|
7
|
+
|
|
8
|
+
Reference:
|
|
9
|
+
- Agent Middleware: https://www.langchain.com/blog/agent-middleware
|
|
10
|
+
- wrap_tool_call: https://mcpservers.org/agent-skills/langchain-ai/langchain-middleware
|
|
11
|
+
- ToolNode: https://reference.langchain.com/python/langgraph.prebuilt/tool_node/ToolNode
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any, Callable, Dict, Optional
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from langchain_core.messages import ToolMessage
|
|
19
|
+
from langchain.tools.tool_node import ToolCallRequest
|
|
20
|
+
except ImportError:
|
|
21
|
+
ToolMessage = None
|
|
22
|
+
ToolCallRequest = None
|
|
23
|
+
|
|
24
|
+
from ..core.models import GateDecision, EvaluationResult
|
|
25
|
+
from ..core.circuit_breaker import SteerCircuitBreaker
|
|
26
|
+
from ..simulators.financial import FinancialDeltaPredictor
|
|
27
|
+
from ..simulators.database import DataDeletionSimulator
|
|
28
|
+
from ..simulators.communications import OutboundCommunicationSimulator
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_consequence_middleware(
|
|
32
|
+
simulator_fn: Callable[[str, Dict[str, Any], Dict[str, Any]], Any],
|
|
33
|
+
evaluator_fn: Callable[[Any, SteerCircuitBreaker], EvaluationResult],
|
|
34
|
+
circuit_breaker: Optional[SteerCircuitBreaker] = None,
|
|
35
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Factory for creating a LangGraph middleware that wraps tool calls.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
from langchain.agents import create_agent
|
|
42
|
+
from consequence_gate.integrations.langgraph_hook import create_consequence_middleware
|
|
43
|
+
|
|
44
|
+
middleware = create_consequence_middleware(
|
|
45
|
+
simulator_fn=financial_simulator,
|
|
46
|
+
evaluator_fn=evaluator,
|
|
47
|
+
circuit_breaker=SteerCircuitBreaker(),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
agent = create_agent(
|
|
51
|
+
model="claude-sonnet-4",
|
|
52
|
+
tools=[my_tool],
|
|
53
|
+
middleware=[middleware],
|
|
54
|
+
)
|
|
55
|
+
"""
|
|
56
|
+
from langchain.agents.middleware import wrap_tool_call
|
|
57
|
+
|
|
58
|
+
breaker = circuit_breaker or SteerCircuitBreaker(max_retries=2)
|
|
59
|
+
context_fn = context_provider or (lambda state: {})
|
|
60
|
+
|
|
61
|
+
@wrap_tool_call
|
|
62
|
+
def consequence_gate_wrapper(request: ToolCallRequest, handler):
|
|
63
|
+
"""
|
|
64
|
+
Wrap tool calls to run consequence simulation before execution.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
request: ToolCallRequest with tool_call dict and state
|
|
68
|
+
handler: function to call to actually execute the tool
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Tool execution result, or raises exception for DENY,
|
|
72
|
+
or returns ToolMessage with guidance for STEER.
|
|
73
|
+
"""
|
|
74
|
+
tool_call = request.tool_call
|
|
75
|
+
tool_name = tool_call.get("name", "unknown")
|
|
76
|
+
arguments = tool_call.get("args", {})
|
|
77
|
+
state = request.state or {}
|
|
78
|
+
context = context_fn(state)
|
|
79
|
+
|
|
80
|
+
# Extract natural key for idempotency
|
|
81
|
+
natural_key = arguments.get("claim_id") or arguments.get("transaction_ref") or f"{tool_name}:{json.dumps(arguments, sort_keys=True)}"
|
|
82
|
+
|
|
83
|
+
# Run simulation + evaluation
|
|
84
|
+
delta = simulator_fn(tool_name, arguments, context)
|
|
85
|
+
result = evaluator_fn(delta, breaker)
|
|
86
|
+
|
|
87
|
+
if result.decision == GateDecision.ALLOW:
|
|
88
|
+
# Pass through - tool executes normally
|
|
89
|
+
return handler(request)
|
|
90
|
+
|
|
91
|
+
if result.decision == GateDecision.DENY:
|
|
92
|
+
# Hard block - raise exception that becomes a ToolMessage error
|
|
93
|
+
raise ValueError(f"BLOCKED: {result.reason}")
|
|
94
|
+
|
|
95
|
+
if result.decision == GateDecision.ASK:
|
|
96
|
+
# Human approval required - for now, raise exception with escalation message
|
|
97
|
+
# In production, this would integrate with LangGraph's interrupt() or a human-in-the-loop system
|
|
98
|
+
raise ValueError(f"ESCALATION_REQUIRED: {result.reason}")
|
|
99
|
+
|
|
100
|
+
if result.decision == GateDecision.STEER:
|
|
101
|
+
# Return ToolMessage with guidance - agent sees this as the tool result
|
|
102
|
+
steer_payload = result.steer_payload or {}
|
|
103
|
+
guidance = steer_payload.get("guidance", result.reason)
|
|
104
|
+
suggested_tool = steer_payload.get("suggested_tool")
|
|
105
|
+
suggested_args = steer_payload.get("suggested_args", {})
|
|
106
|
+
idempotency_key = suggested_args.get("idempotency_key")
|
|
107
|
+
if idempotency_key:
|
|
108
|
+
guidance += f" [idempotency_key={idempotency_key}]"
|
|
109
|
+
|
|
110
|
+
error_text = (
|
|
111
|
+
f"STEER_GUIDANCE: {guidance}\\n"
|
|
112
|
+
f"Suggested alternative: {suggested_tool} with args {suggested_args}"
|
|
113
|
+
)
|
|
114
|
+
return ToolMessage(content=error_text, tool_call_id=tool_call.get("id", ""), name=tool_name, status="error")
|
|
115
|
+
|
|
116
|
+
# Should not reach here
|
|
117
|
+
return handler(request)
|
|
118
|
+
|
|
119
|
+
return consequence_gate_wrapper
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Convenience factory functions
|
|
123
|
+
|
|
124
|
+
def create_financial_gate_middleware(
|
|
125
|
+
daily_tier_limit_inr: float = 25000.0,
|
|
126
|
+
instant_wire_threshold: float = 10000.0,
|
|
127
|
+
max_retries: int = 2,
|
|
128
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
129
|
+
):
|
|
130
|
+
"""Factory for financial gate middleware."""
|
|
131
|
+
predictor = FinancialDeltaPredictor(
|
|
132
|
+
daily_tier_limit_inr=daily_tier_limit_inr,
|
|
133
|
+
instant_wire_threshold=instant_wire_threshold,
|
|
134
|
+
)
|
|
135
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
136
|
+
|
|
137
|
+
def evaluator(delta, circuit_breaker):
|
|
138
|
+
return predictor.evaluate(delta, circuit_breaker)
|
|
139
|
+
|
|
140
|
+
return create_consequence_middleware(
|
|
141
|
+
simulator_fn=predictor.simulate,
|
|
142
|
+
evaluator_fn=evaluator,
|
|
143
|
+
circuit_breaker=breaker,
|
|
144
|
+
context_provider=context_provider,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_database_gate_middleware(
|
|
149
|
+
max_autonomous_delete_rows: int = 100,
|
|
150
|
+
db_conn=None,
|
|
151
|
+
max_retries: int = 2,
|
|
152
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
153
|
+
):
|
|
154
|
+
"""Factory for database gate middleware."""
|
|
155
|
+
simulator = DataDeletionSimulator(
|
|
156
|
+
max_autonomous_delete_rows=max_autonomous_delete_rows,
|
|
157
|
+
db_conn=db_conn,
|
|
158
|
+
)
|
|
159
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
160
|
+
|
|
161
|
+
def evaluator(delta, circuit_breaker):
|
|
162
|
+
return simulator.evaluate(delta, circuit_breaker)
|
|
163
|
+
|
|
164
|
+
return create_consequence_middleware(
|
|
165
|
+
simulator_fn=simulator.simulate,
|
|
166
|
+
evaluator_fn=evaluator,
|
|
167
|
+
circuit_breaker=breaker,
|
|
168
|
+
context_provider=context_provider,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def create_communications_gate_middleware(
|
|
173
|
+
max_autonomous_recipients: int = 10000,
|
|
174
|
+
canary_min_size: int = 100,
|
|
175
|
+
canary_max_bounce_rate: float = 0.05,
|
|
176
|
+
canary_max_complaint_rate: float = 0.01,
|
|
177
|
+
max_retries: int = 2,
|
|
178
|
+
context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
|
|
179
|
+
):
|
|
180
|
+
"""Factory for communications gate middleware."""
|
|
181
|
+
simulator = OutboundCommunicationSimulator(
|
|
182
|
+
max_autonomous_recipients=max_autonomous_recipients,
|
|
183
|
+
canary_min_size=canary_min_size,
|
|
184
|
+
canary_max_bounce_rate=canary_max_bounce_rate,
|
|
185
|
+
canary_max_complaint_rate=canary_max_complaint_rate,
|
|
186
|
+
)
|
|
187
|
+
breaker = SteerCircuitBreaker(max_retries=max_retries)
|
|
188
|
+
|
|
189
|
+
def evaluator(delta, circuit_breaker):
|
|
190
|
+
return simulator.evaluate(delta, circuit_breaker)
|
|
191
|
+
|
|
192
|
+
return create_consequence_middleware(
|
|
193
|
+
simulator_fn=simulator.simulate,
|
|
194
|
+
evaluator_fn=evaluator,
|
|
195
|
+
circuit_breaker=breaker,
|
|
196
|
+
context_provider=context_provider,
|
|
197
|
+
)
|