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

Viewing files at commit 4e1e50d12753Commit detailsView latest

benchmarks/tau3/openclasp_client.py 212 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 = 40.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
                "proposedAction": draft[:1000],
130
                "facts": [],
131
                "evidence": [],
132
                "policies": [],
133
            },
134
        )
135
        if not isinstance(value, dict) or not isinstance(value.get("consultation"), dict):
136
            raise OpenClaspMcpError("Shield returned an invalid consultation")
137
        return value
138
139
    def close_case(
140
        self,
141
        *,
142
        case_id: str,
143
        result: str,
144
        accepted_advice: bool,
145
        action_taken: str,
146
        observed_impact: str,
147
    ) -> dict[str, Any]:
148
        value = self.call_tool(
149
            "openclasp_shield_close_case",
150
            {
151
                "caseId": case_id,
152
                "result": result,
153
                "acceptedAdvice": accepted_advice,
154
                "actionTaken": action_taken[:2000],
155
                "observedImpact": observed_impact[:2000],
156
            },
157
        )
158
        if not isinstance(value, dict):
159
            raise OpenClaspMcpError("Shield returned an invalid outcome")
160
        return value
161
162
163
def _split_policy(policy: str, limit: int = 1900) -> list[str]:
164
    chunks: list[str] = []
165
    current = ""
166
    for line in policy.splitlines():
167
        remaining = line
168
        while len(remaining) > limit:
169
            if current:
170
                chunks.append(current)
171
                current = ""
172
            chunks.append(remaining[:limit])
173
            remaining = remaining[limit:]
174
        candidate = f"{current}\n{remaining}".strip() if current else remaining
175
        if len(candidate) > limit:
176
            chunks.append(current)
177
            current = remaining
178
        else:
179
            current = candidate
180
    if current:
181
        chunks.append(current)
182
    if len(chunks) > 50:
183
        raise ValueError("τ³ domain policy is too large for a Shield case")
184
    return chunks
185
186
187
def _parse_rpc_body(body: str) -> dict[str, Any]:
188
    candidates = [
189
        line[5:].strip()
190
        for line in body.splitlines()
191
        if line.startswith("data:") and line[5:].strip()
192
    ]
193
    raw = candidates[-1] if candidates else body
194
    try:
195
        value = json.loads(raw)
196
    except json.JSONDecodeError as error:
197
        raise OpenClaspMcpError("OpenClasp returned invalid JSON") from error
198
    if not isinstance(value, dict):
199
        raise OpenClaspMcpError("OpenClasp returned an invalid JSON-RPC envelope")
200
    return value
201
202
203
def _content_text(result: dict[str, Any]) -> str | None:
204
    content = result.get("content")
205
    if not isinstance(content, list):
206
        return None
207
    for item in content:
208
        if isinstance(item, dict) and item.get("type") == "text":
209
            text = item.get("text")
210
            if isinstance(text, str):
211
                return text
212
    return None