The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Download ZIP
benchmarks/tau3/openclasp_client.py 213 lines · 7.2 KB · python
1
"""Small dependency-free MCP client for OpenClasp Shield benchmark runs."""
2
3
from __future__ import annotations
4
5
import json
6
import uuid
7
from typing import Any
8
from urllib.error import HTTPError, URLError
9
from urllib.request import Request, urlopen
10
11
12
class OpenClaspMcpError(RuntimeError):
13
    pass
14
15
16
class OpenClaspMcpClient:
17
    def __init__(self, base_url: str, token: str, timeout_seconds: float = 58.0):
18
        if not base_url.startswith(("http://", "https://")):
19
            raise ValueError("OPENCLASP_URL must be an HTTP(S) URL")
20
        if not token.startswith("oc_at_"):
21
            raise ValueError("OPENCLASP_AGENT_TOKEN must be an oc_at_ token")
22
        self.endpoint = (
23
            base_url.rstrip("/")
24
            if base_url.rstrip("/").endswith("/mcp")
25
            else f"{base_url.rstrip('/')}/mcp"
26
        )
27
        self._token = token
28
        self._timeout_seconds = timeout_seconds
29
30
    def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
31
        payload = json.dumps(
32
            {
33
                "jsonrpc": "2.0",
34
                "id": str(uuid.uuid4()),
35
                "method": "tools/call",
36
                "params": {"name": name, "arguments": arguments or {}},
37
            }
38
        ).encode("utf-8")
39
        request = Request(
40
            self.endpoint,
41
            data=payload,
42
            method="POST",
43
            headers={
44
                "Authorization": f"Bearer {self._token}",
45
                "Accept": "application/json, text/event-stream",
46
                "Content-Type": "application/json",
47
                "MCP-Protocol-Version": "2025-06-18",
48
            },
49
        )
50
        try:
51
            with urlopen(request, timeout=self._timeout_seconds) as response:
52
                body = response.read().decode("utf-8")
53
        except HTTPError as error:
54
            body = error.read().decode("utf-8", errors="replace")
55
            raise OpenClaspMcpError(
56
                f"OpenClasp MCP returned HTTP {error.code}: {body[:500]}"
57
            ) from error
58
        except (URLError, TimeoutError) as error:
59
            raise OpenClaspMcpError(f"OpenClasp MCP request failed: {error}") from error
60
61
        rpc = _parse_rpc_body(body)
62
        if rpc.get("error"):
63
            message = rpc["error"].get("message", "Unknown MCP error")
64
            raise OpenClaspMcpError(str(message))
65
        result = rpc.get("result")
66
        if not isinstance(result, dict):
67
            raise OpenClaspMcpError("OpenClasp returned no MCP result")
68
        if result.get("isError"):
69
            raise OpenClaspMcpError(_content_text(result) or "OpenClasp tool failed")
70
        text = _content_text(result)
71
        if text is None:
72
            return result
73
        try:
74
            return json.loads(text)
75
        except json.JSONDecodeError:
76
            return text
77
78
    def open_case(
79
        self,
80
        *,
81
        title: str,
82
        goal: str,
83
        domain_policy: str,
84
    ) -> dict[str, Any]:
85
        policies = [
86
            {
87
                "title": f"Visible τ³ domain policy {index + 1}",
88
                "statement": chunk,
89
            }
90
            for index, chunk in enumerate(_split_policy(domain_policy))
91
        ]
92
        value = self.call_tool(
93
            "openclasp_shield_open_case",
94
            {
95
                "title": title[:160],
96
                "goal": goal[:2000],
97
                "brief": (
98
                    "Benchmark evaluation case. Shield receives only benchmark-visible policy, "
99
                    "messages, tool results, and the protected agent's proposed next step."
100
                ),
101
                "counterparty": {"type": "human"},
102
                "facts": [],
103
                "evidence": [],
104
                "policies": policies,
105
            },
106
        )
107
        if not isinstance(value, dict) or not value.get("caseId"):
108
            raise OpenClaspMcpError("Shield did not return a caseId")
109
        return value
110
111
    def consult(
112
        self,
113
        *,
114
        case_id: str,
115
        draft: str,
116
        situation_context: str,
117
    ) -> dict[str, Any]:
118
        value = self.call_tool(
119
            "openclasp_shield_consult",
120
            {
121
                "caseId": case_id,
122
                "message": (
123
                    "Review the protected agent's proposed next step. Check it against the "
124
                    "visible policy and verified tool results. Identify unsupported user claims, "
125
                    "pressure tactics, missing evidence, and the safest correct response.\n\n"
126
                    f"PROPOSED NEXT STEP\n{draft}"
127
                )[:4000],
128
                "situationContext": situation_context[:8000],
129
                "analysisDepth": "fast",
130
                "proposedAction": draft[:1000],
131
                "facts": [],
132
                "evidence": [],
133
                "policies": [],
134
            },
135
        )
136
        if not isinstance(value, dict) or not isinstance(value.get("consultation"), dict):
137
            raise OpenClaspMcpError("Shield returned an invalid consultation")
138
        return value
139
140
    def close_case(
141
        self,
142
        *,
143
        case_id: str,
144
        result: str,
145
        accepted_advice: bool,
146
        action_taken: str,
147
        observed_impact: str,
148
    ) -> dict[str, Any]:
149
        value = self.call_tool(
150
            "openclasp_shield_close_case",
151
            {
152
                "caseId": case_id,
153
                "result": result,
154
                "acceptedAdvice": accepted_advice,
155
                "actionTaken": action_taken[:2000],
156
                "observedImpact": observed_impact[:2000],
157
            },
158
        )
159
        if not isinstance(value, dict):
160
            raise OpenClaspMcpError("Shield returned an invalid outcome")
161
        return value
162
163
164
def _split_policy(policy: str, limit: int = 1900) -> list[str]:
165
    chunks: list[str] = []
166
    current = ""
167
    for line in policy.splitlines():
168
        remaining = line
169
        while len(remaining) > limit:
170
            if current:
171
                chunks.append(current)
172
                current = ""
173
            chunks.append(remaining[:limit])
174
            remaining = remaining[limit:]
175
        candidate = f"{current}\n{remaining}".strip() if current else remaining
176
        if len(candidate) > limit:
177
            chunks.append(current)
178
            current = remaining
179
        else:
180
            current = candidate
181
    if current:
182
        chunks.append(current)
183
    if len(chunks) > 50:
184
        raise ValueError("τ³ domain policy is too large for a Shield case")
185
    return chunks
186
187
188
def _parse_rpc_body(body: str) -> dict[str, Any]:
189
    candidates = [
190
        line[5:].strip()
191
        for line in body.splitlines()
192
        if line.startswith("data:") and line[5:].strip()
193
    ]
194
    raw = candidates[-1] if candidates else body
195
    try:
196
        value = json.loads(raw)
197
    except json.JSONDecodeError as error:
198
        raise OpenClaspMcpError("OpenClasp returned invalid JSON") from error
199
    if not isinstance(value, dict):
200
        raise OpenClaspMcpError("OpenClasp returned an invalid JSON-RPC envelope")
201
    return value
202
203
204
def _content_text(result: dict[str, Any]) -> str | None:
205
    content = result.get("content")
206
    if not isinstance(content, list):
207
        return None
208
    for item in content:
209
        if isinstance(item, dict) and item.get("type") == "text":
210
            text = item.get("text")
211
            if isinstance(text, str):
212
                return text
213
    return None