scientific-protocol 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,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: scientific-protocol
3
+ Version: 0.1.0
4
+ Summary: Python client and operator CLI for Scientific Protocol.
5
+ Author: Scientific Protocol
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://scientificprotocol.org
8
+ Project-URL: Documentation, https://scientificprotocol.org
9
+ Project-URL: Repository, https://github.com/emgun/scientific-protocol
10
+ Project-URL: Issues, https://github.com/emgun/scientific-protocol/issues
11
+ Project-URL: Changelog, https://github.com/emgun/scientific-protocol/blob/main/CHANGELOG.md
12
+ Keywords: ethereum,protocol,replication,science,scientific-computing,web3
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Scientific Protocol Python Client
27
+
28
+ This package provides a lightweight Python client for the Scientific Protocol API plus a small
29
+ operator CLI for the hybrid agent runtime.
30
+
31
+ It is intentionally stdlib-first. The only external runtime dependency for signed actions is
32
+ Foundry `cast` when you use `CastAgentSigner`.
33
+
34
+ ## Install
35
+
36
+ From PyPI:
37
+
38
+ ```bash
39
+ pip install scientific-protocol
40
+ ```
41
+
42
+ Or from the repo:
43
+
44
+ ```bash
45
+ cd /Users/emerygunselman/Code/scientific-protocol
46
+ python3 -m pip install -e python
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ The package installs `sp-agent-client`.
52
+
53
+ Examples:
54
+
55
+ ```bash
56
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client health
57
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client list-work-items --claimable --limit 10
58
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client runtime-events --agent-id 1 --limit 25
59
+ ```
60
+
61
+ Webhook subscription creation is also available:
62
+
63
+ ```bash
64
+ SP_API_BASE_URL=http://127.0.0.1:3000 \
65
+ sp-agent-client create-webhook-subscription \
66
+ --agent-id 1 \
67
+ --private-key 0x... \
68
+ --target-url https://example.com/sp-webhooks \
69
+ --event-type review.submitted \
70
+ --event-type work.claimed
71
+ ```
72
+
73
+ ## Webhook signature verification
74
+
75
+ The module exposes `verify_webhook_signature(...)` for receivers.
76
+
77
+ You can also verify a payload directly from the CLI:
78
+
79
+ ```bash
80
+ sp-agent-client verify-webhook-signature \
81
+ --secret ospwhsec_... \
82
+ --timestamp 2026-04-13T12:00:00.000Z \
83
+ --signature v1=... \
84
+ --payload-file payload.json
85
+ ```
86
+
87
+ ## Python API
88
+
89
+ ```python
90
+ from scientific_protocol_client import ScientificProtocolClient
91
+
92
+ client = ScientificProtocolClient("http://127.0.0.1:3000")
93
+ items = client.list_work_items(claimable=True, limit=10)
94
+ print(items["items"][0]["itemId"])
95
+ ```
96
+
97
+ For signed agent requests, use:
98
+
99
+ - `create_signed_agent_request(...)`
100
+ - `CastAgentSigner`
101
+ - `claim_work_item(...)`
102
+ - `heartbeat_work_item(...)`
103
+ - `submit_work_results(...)`
@@ -0,0 +1,78 @@
1
+ # Scientific Protocol Python Client
2
+
3
+ This package provides a lightweight Python client for the Scientific Protocol API plus a small
4
+ operator CLI for the hybrid agent runtime.
5
+
6
+ It is intentionally stdlib-first. The only external runtime dependency for signed actions is
7
+ Foundry `cast` when you use `CastAgentSigner`.
8
+
9
+ ## Install
10
+
11
+ From PyPI:
12
+
13
+ ```bash
14
+ pip install scientific-protocol
15
+ ```
16
+
17
+ Or from the repo:
18
+
19
+ ```bash
20
+ cd /Users/emerygunselman/Code/scientific-protocol
21
+ python3 -m pip install -e python
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ The package installs `sp-agent-client`.
27
+
28
+ Examples:
29
+
30
+ ```bash
31
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client health
32
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client list-work-items --claimable --limit 10
33
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client runtime-events --agent-id 1 --limit 25
34
+ ```
35
+
36
+ Webhook subscription creation is also available:
37
+
38
+ ```bash
39
+ SP_API_BASE_URL=http://127.0.0.1:3000 \
40
+ sp-agent-client create-webhook-subscription \
41
+ --agent-id 1 \
42
+ --private-key 0x... \
43
+ --target-url https://example.com/sp-webhooks \
44
+ --event-type review.submitted \
45
+ --event-type work.claimed
46
+ ```
47
+
48
+ ## Webhook signature verification
49
+
50
+ The module exposes `verify_webhook_signature(...)` for receivers.
51
+
52
+ You can also verify a payload directly from the CLI:
53
+
54
+ ```bash
55
+ sp-agent-client verify-webhook-signature \
56
+ --secret ospwhsec_... \
57
+ --timestamp 2026-04-13T12:00:00.000Z \
58
+ --signature v1=... \
59
+ --payload-file payload.json
60
+ ```
61
+
62
+ ## Python API
63
+
64
+ ```python
65
+ from scientific_protocol_client import ScientificProtocolClient
66
+
67
+ client = ScientificProtocolClient("http://127.0.0.1:3000")
68
+ items = client.list_work_items(claimable=True, limit=10)
69
+ print(items["items"][0]["itemId"])
70
+ ```
71
+
72
+ For signed agent requests, use:
73
+
74
+ - `create_signed_agent_request(...)`
75
+ - `CastAgentSigner`
76
+ - `claim_work_item(...)`
77
+ - `heartbeat_work_item(...)`
78
+ - `submit_work_results(...)`
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scientific-protocol"
7
+ version = "0.1.0"
8
+ description = "Python client and operator CLI for Scientific Protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Scientific Protocol" }]
12
+ license = "MIT"
13
+ keywords = [
14
+ "ethereum",
15
+ "protocol",
16
+ "replication",
17
+ "science",
18
+ "scientific-computing",
19
+ "web3",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Developers",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Scientific/Engineering",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ ]
33
+ dependencies = []
34
+
35
+ [project.urls]
36
+ Homepage = "https://scientificprotocol.org"
37
+ Documentation = "https://scientificprotocol.org"
38
+ Repository = "https://github.com/emgun/scientific-protocol"
39
+ Issues = "https://github.com/emgun/scientific-protocol/issues"
40
+ Changelog = "https://github.com/emgun/scientific-protocol/blob/main/CHANGELOG.md"
41
+
42
+ [project.scripts]
43
+ sp-agent-client = "scientific_protocol_client:main"
44
+
45
+ [tool.setuptools]
46
+ py-modules = ["scientific_protocol_client"]
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: scientific-protocol
3
+ Version: 0.1.0
4
+ Summary: Python client and operator CLI for Scientific Protocol.
5
+ Author: Scientific Protocol
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://scientificprotocol.org
8
+ Project-URL: Documentation, https://scientificprotocol.org
9
+ Project-URL: Repository, https://github.com/emgun/scientific-protocol
10
+ Project-URL: Issues, https://github.com/emgun/scientific-protocol/issues
11
+ Project-URL: Changelog, https://github.com/emgun/scientific-protocol/blob/main/CHANGELOG.md
12
+ Keywords: ethereum,protocol,replication,science,scientific-computing,web3
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Scientific Protocol Python Client
27
+
28
+ This package provides a lightweight Python client for the Scientific Protocol API plus a small
29
+ operator CLI for the hybrid agent runtime.
30
+
31
+ It is intentionally stdlib-first. The only external runtime dependency for signed actions is
32
+ Foundry `cast` when you use `CastAgentSigner`.
33
+
34
+ ## Install
35
+
36
+ From PyPI:
37
+
38
+ ```bash
39
+ pip install scientific-protocol
40
+ ```
41
+
42
+ Or from the repo:
43
+
44
+ ```bash
45
+ cd /Users/emerygunselman/Code/scientific-protocol
46
+ python3 -m pip install -e python
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ The package installs `sp-agent-client`.
52
+
53
+ Examples:
54
+
55
+ ```bash
56
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client health
57
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client list-work-items --claimable --limit 10
58
+ SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client runtime-events --agent-id 1 --limit 25
59
+ ```
60
+
61
+ Webhook subscription creation is also available:
62
+
63
+ ```bash
64
+ SP_API_BASE_URL=http://127.0.0.1:3000 \
65
+ sp-agent-client create-webhook-subscription \
66
+ --agent-id 1 \
67
+ --private-key 0x... \
68
+ --target-url https://example.com/sp-webhooks \
69
+ --event-type review.submitted \
70
+ --event-type work.claimed
71
+ ```
72
+
73
+ ## Webhook signature verification
74
+
75
+ The module exposes `verify_webhook_signature(...)` for receivers.
76
+
77
+ You can also verify a payload directly from the CLI:
78
+
79
+ ```bash
80
+ sp-agent-client verify-webhook-signature \
81
+ --secret ospwhsec_... \
82
+ --timestamp 2026-04-13T12:00:00.000Z \
83
+ --signature v1=... \
84
+ --payload-file payload.json
85
+ ```
86
+
87
+ ## Python API
88
+
89
+ ```python
90
+ from scientific_protocol_client import ScientificProtocolClient
91
+
92
+ client = ScientificProtocolClient("http://127.0.0.1:3000")
93
+ items = client.list_work_items(claimable=True, limit=10)
94
+ print(items["items"][0]["itemId"])
95
+ ```
96
+
97
+ For signed agent requests, use:
98
+
99
+ - `create_signed_agent_request(...)`
100
+ - `CastAgentSigner`
101
+ - `claim_work_item(...)`
102
+ - `heartbeat_work_item(...)`
103
+ - `submit_work_results(...)`
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ scientific_protocol_client.py
4
+ scientific_protocol.egg-info/PKG-INFO
5
+ scientific_protocol.egg-info/SOURCES.txt
6
+ scientific_protocol.egg-info/dependency_links.txt
7
+ scientific_protocol.egg-info/entry_points.txt
8
+ scientific_protocol.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sp-agent-client = scientific_protocol_client:main
@@ -0,0 +1 @@
1
+ scientific_protocol_client
@@ -0,0 +1,609 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hmac
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import secrets
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Protocol
14
+ from urllib.error import HTTPError, URLError
15
+ from urllib.parse import quote, urlencode
16
+ from urllib.request import Request, urlopen
17
+
18
+
19
+ @dataclass
20
+ class ScientificProtocolApiError(Exception):
21
+ status: int
22
+ body: Any
23
+
24
+ def __str__(self) -> str:
25
+ return f"Scientific Protocol API request failed with status {self.status}"
26
+
27
+
28
+ class AgentRequestSigner(Protocol):
29
+ def get_address(self) -> str: ...
30
+
31
+ def sign_message(self, message: str) -> str: ...
32
+
33
+
34
+ @dataclass
35
+ class CastAgentSigner:
36
+ private_key: str
37
+ cast_binary: str = "cast"
38
+
39
+ def get_address(self) -> str:
40
+ result = subprocess.run(
41
+ [self.cast_binary, "wallet", "address", "--private-key", self.private_key],
42
+ capture_output=True,
43
+ check=True,
44
+ text=True,
45
+ )
46
+ return result.stdout.strip()
47
+
48
+ def sign_message(self, message: str) -> str:
49
+ result = subprocess.run(
50
+ [self.cast_binary, "wallet", "sign", "--private-key", self.private_key, message],
51
+ capture_output=True,
52
+ check=True,
53
+ text=True,
54
+ )
55
+ return result.stdout.strip()
56
+
57
+
58
+ def _stable_serialize(value: Any) -> str:
59
+ if isinstance(value, list):
60
+ return "[" + ",".join(_stable_serialize(item) for item in value) + "]"
61
+ if isinstance(value, dict):
62
+ entries: list[str] = []
63
+ for key in sorted(value.keys()):
64
+ entries.append(json.dumps(str(key)) + ":" + _stable_serialize(value[key]))
65
+ return "{" + ",".join(entries) + "}"
66
+ if isinstance(value, bool) or value is None:
67
+ return json.dumps(value)
68
+ if isinstance(value, int):
69
+ return json.dumps(str(value))
70
+ return json.dumps(value)
71
+
72
+
73
+ def hash_agent_request_envelope(envelope: dict[str, Any]) -> str:
74
+ serialized = _stable_serialize(envelope).encode("utf-8")
75
+ return "0x" + hashlib.sha256(serialized).hexdigest()
76
+
77
+
78
+ def sign_webhook_payload(*, payload_body: str, secret: str, timestamp: str) -> str:
79
+ digest = hmac.new(
80
+ secret.encode("utf-8"),
81
+ f"{timestamp}.{payload_body}".encode("utf-8"),
82
+ hashlib.sha256,
83
+ ).hexdigest()
84
+ return f"v1={digest}"
85
+
86
+
87
+ def verify_webhook_signature(
88
+ *,
89
+ payload_body: str,
90
+ secret: str,
91
+ signature: str,
92
+ timestamp: str,
93
+ ) -> bool:
94
+ expected = sign_webhook_payload(payload_body=payload_body, secret=secret, timestamp=timestamp)
95
+ return hmac.compare_digest(expected, signature)
96
+
97
+
98
+ def create_signed_agent_request(
99
+ *,
100
+ action_type: str,
101
+ agent_id: str | int,
102
+ payload: dict[str, Any],
103
+ scope_key: str,
104
+ signer: AgentRequestSigner,
105
+ actor_address: str | None = None,
106
+ issued_at: str | None = None,
107
+ request_nonce: str | None = None,
108
+ ) -> dict[str, Any]:
109
+ resolved_actor = actor_address or signer.get_address()
110
+ envelope = {
111
+ "actionType": action_type,
112
+ "actorAddress": resolved_actor,
113
+ "agentId": str(agent_id),
114
+ "issuedAt": issued_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
115
+ "payload": payload,
116
+ "requestNonce": request_nonce or secrets.token_hex(16),
117
+ "scopeKey": scope_key,
118
+ }
119
+ request_hash = hash_agent_request_envelope(envelope)
120
+ signature = signer.sign_message(request_hash)
121
+ return {
122
+ "envelope": envelope,
123
+ "signature": signature,
124
+ }
125
+
126
+
127
+ def _parse_work_item_id(item_id: str) -> tuple[str, str]:
128
+ separator = item_id.find(":")
129
+ if separator <= 0 or separator >= len(item_id) - 1:
130
+ raise ValueError(f"unsupported_work_item_id:{item_id}")
131
+ return item_id[:separator], item_id[separator + 1 :]
132
+
133
+
134
+ def _claim_route_for_item(item_id: str) -> str:
135
+ item_key, source_id = _parse_work_item_id(item_id)
136
+ if item_key == "review-task":
137
+ return f"/agent/review-tasks/{source_id}/claim"
138
+ if item_key == "artifact-maintenance":
139
+ return f"/agent/artifact-maintenance-tasks/{source_id}/claim"
140
+ if item_key == "replication-job":
141
+ return f"/agent/replication-jobs/{source_id}/claim"
142
+ raise ValueError(f"unsupported_claimable_work_item:{item_id}")
143
+
144
+
145
+ def _heartbeat_route_for_item(item_id: str) -> str:
146
+ item_key, source_id = _parse_work_item_id(item_id)
147
+ if item_key == "review-task":
148
+ return f"/agent/review-tasks/{source_id}/heartbeat"
149
+ if item_key == "artifact-maintenance":
150
+ return f"/agent/artifact-maintenance-tasks/{source_id}/heartbeat"
151
+ if item_key == "replication-job":
152
+ return f"/agent/replication-jobs/{source_id}/heartbeat"
153
+ raise ValueError(f"unsupported_heartbeatable_work_item:{item_id}")
154
+
155
+
156
+ def _submission_route_for_item(item_id: str, action_type: str) -> str:
157
+ item_key, source_id = _parse_work_item_id(item_id)
158
+ if item_key == "review-task" and action_type == "review_task_submission":
159
+ return f"/agent/review-tasks/{source_id}/submissions"
160
+ if item_key == "artifact-maintenance":
161
+ if action_type == "artifact_task_audit_submission":
162
+ return f"/agent/artifact-maintenance-tasks/{source_id}/audit-results"
163
+ if action_type == "artifact_task_repair_submission":
164
+ return f"/agent/artifact-maintenance-tasks/{source_id}/repair-results"
165
+ if item_key == "replication-job" and action_type == "replication_job_submission":
166
+ return f"/agent/replication-jobs/{source_id}/submissions"
167
+ raise ValueError(f"unsupported_work_result_submission:{item_id}:{action_type}")
168
+
169
+
170
+ class ScientificProtocolClient:
171
+ def __init__(self, base_url: str, timeout: float = 30.0) -> None:
172
+ self.base_url = base_url.rstrip("/")
173
+ self.timeout = timeout
174
+
175
+ def get_health(self) -> dict[str, Any]:
176
+ return self._request("GET", "/health")
177
+
178
+ def get_claim(self, claim_id: str | int, view: str = "full") -> dict[str, Any]:
179
+ return self._request("GET", f"/claims/{claim_id}", {"view": view})
180
+
181
+ def get_work_item(
182
+ self,
183
+ item_id: str,
184
+ claim_id: str | int | None = None,
185
+ ) -> dict[str, Any]:
186
+ query: dict[str, Any] = {}
187
+ if claim_id is not None:
188
+ query["claimId"] = claim_id
189
+ encoded_item_id = quote(item_id, safe="")
190
+ return self._request("GET", f"/work-items/{encoded_item_id}", query)
191
+
192
+ def list_work_items(
193
+ self,
194
+ *,
195
+ claim_id: str | int | None = None,
196
+ claimable: bool | None = None,
197
+ kind: str | None = None,
198
+ lane: str | None = None,
199
+ limit: int | None = None,
200
+ offset: int | None = None,
201
+ status: str | None = None,
202
+ ) -> dict[str, Any]:
203
+ return self._request(
204
+ "GET",
205
+ "/work-items",
206
+ {
207
+ "claimId": claim_id,
208
+ "claimable": claimable,
209
+ "kind": kind,
210
+ "lane": lane,
211
+ "limit": limit,
212
+ "offset": offset,
213
+ "status": status,
214
+ },
215
+ )
216
+
217
+ def get_agent_review_calibration(
218
+ self,
219
+ agent_id: str | int,
220
+ *,
221
+ limit: int | None = None,
222
+ offset: int | None = None,
223
+ ) -> dict[str, Any]:
224
+ return self._request(
225
+ "GET",
226
+ f"/agents/{agent_id}/review-calibration",
227
+ {
228
+ "limit": limit,
229
+ "offset": offset,
230
+ },
231
+ )
232
+
233
+ def get_agent_work_summary(
234
+ self,
235
+ agent_id: str | int,
236
+ *,
237
+ domain_id: int | None = None,
238
+ ) -> dict[str, Any]:
239
+ return self._request(
240
+ "GET",
241
+ f"/agents/{agent_id}/work-summary",
242
+ {
243
+ "domainId": domain_id,
244
+ },
245
+ )
246
+
247
+ def get_agent_runtime_events(
248
+ self,
249
+ *,
250
+ agent_id: str | int | None = None,
251
+ claim_id: str | int | None = None,
252
+ limit: int | None = None,
253
+ offset: int | None = None,
254
+ since: str | None = None,
255
+ ) -> dict[str, Any]:
256
+ return self._request(
257
+ "GET",
258
+ "/agent-runtime/events",
259
+ {
260
+ "agentId": agent_id,
261
+ "claimId": claim_id,
262
+ "limit": limit,
263
+ "offset": offset,
264
+ "since": since,
265
+ },
266
+ )
267
+
268
+ def get_agent_webhook_subscription(self, subscription_id: str | int) -> dict[str, Any]:
269
+ return self._request("GET", f"/agent-webhook-subscriptions/{subscription_id}")
270
+
271
+ def get_agent_webhook_deliveries(
272
+ self,
273
+ *,
274
+ agent_id: str | int | None = None,
275
+ limit: int | None = None,
276
+ offset: int | None = None,
277
+ status: str | None = None,
278
+ subscription_id: str | int | None = None,
279
+ ) -> dict[str, Any]:
280
+ return self._request(
281
+ "GET",
282
+ "/agent-webhook-deliveries",
283
+ {
284
+ "agentId": agent_id,
285
+ "limit": limit,
286
+ "offset": offset,
287
+ "status": status,
288
+ "subscriptionId": subscription_id,
289
+ },
290
+ )
291
+
292
+ def get_agent_webhook_delivery(self, delivery_id: str | int) -> dict[str, Any]:
293
+ return self._request("GET", f"/agent-webhook-deliveries/{delivery_id}")
294
+
295
+ def get_agent_webhook_subscriptions(
296
+ self,
297
+ *,
298
+ agent_id: str | int | None = None,
299
+ limit: int | None = None,
300
+ offset: int | None = None,
301
+ status: str | None = None,
302
+ ) -> dict[str, Any]:
303
+ return self._request(
304
+ "GET",
305
+ "/agent-webhook-subscriptions",
306
+ {
307
+ "agentId": agent_id,
308
+ "limit": limit,
309
+ "offset": offset,
310
+ "status": status,
311
+ },
312
+ )
313
+
314
+ def claim_work_item(self, item_id: str, signed_request: dict[str, Any]) -> dict[str, Any]:
315
+ return self._request("POST", _claim_route_for_item(item_id), body=signed_request)
316
+
317
+ def heartbeat_work_item(self, item_id: str, signed_request: dict[str, Any]) -> dict[str, Any]:
318
+ return self._request("POST", _heartbeat_route_for_item(item_id), body=signed_request)
319
+
320
+ def submit_work_results(self, item_id: str, signed_request: dict[str, Any]) -> dict[str, Any]:
321
+ action_type = str(signed_request["envelope"]["actionType"])
322
+ return self._request(
323
+ "POST",
324
+ _submission_route_for_item(item_id, action_type),
325
+ body=signed_request,
326
+ )
327
+
328
+ def create_agent_webhook_subscription(
329
+ self,
330
+ *,
331
+ agent_id: str | int,
332
+ signer: AgentRequestSigner,
333
+ target_url: str,
334
+ event_types: list[str] | None = None,
335
+ label: str | None = None,
336
+ signing_secret: str | None = None,
337
+ actor_address: str | None = None,
338
+ issued_at: str | None = None,
339
+ request_nonce: str | None = None,
340
+ ) -> dict[str, Any]:
341
+ signed_request = create_signed_agent_request(
342
+ action_type="webhook_subscription_create",
343
+ actor_address=actor_address,
344
+ agent_id=agent_id,
345
+ issued_at=issued_at,
346
+ payload={
347
+ "eventTypes": event_types,
348
+ "label": label,
349
+ "signingSecret": signing_secret,
350
+ "targetUrl": target_url,
351
+ },
352
+ request_nonce=request_nonce,
353
+ scope_key=f"agent-webhook-subscriptions:{agent_id}",
354
+ signer=signer,
355
+ )
356
+ return self._request("POST", "/agent/webhook-subscriptions", body=signed_request)
357
+
358
+ def delete_agent_webhook_subscription(
359
+ self,
360
+ *,
361
+ agent_id: str | int,
362
+ signer: AgentRequestSigner,
363
+ subscription_id: str | int,
364
+ actor_address: str | None = None,
365
+ issued_at: str | None = None,
366
+ request_nonce: str | None = None,
367
+ ) -> dict[str, Any]:
368
+ signed_request = create_signed_agent_request(
369
+ action_type="webhook_subscription_delete",
370
+ actor_address=actor_address,
371
+ agent_id=agent_id,
372
+ issued_at=issued_at,
373
+ payload={},
374
+ request_nonce=request_nonce,
375
+ scope_key=f"agent-webhook-subscription:{subscription_id}",
376
+ signer=signer,
377
+ )
378
+ return self._request(
379
+ "POST",
380
+ f"/agent/webhook-subscriptions/{subscription_id}/delete",
381
+ body=signed_request,
382
+ )
383
+
384
+ def ping_agent_webhook_subscription(
385
+ self,
386
+ *,
387
+ agent_id: str | int,
388
+ signer: AgentRequestSigner,
389
+ subscription_id: str | int,
390
+ actor_address: str | None = None,
391
+ issued_at: str | None = None,
392
+ request_nonce: str | None = None,
393
+ ) -> dict[str, Any]:
394
+ signed_request = create_signed_agent_request(
395
+ action_type="webhook_subscription_ping",
396
+ actor_address=actor_address,
397
+ agent_id=agent_id,
398
+ issued_at=issued_at,
399
+ payload={},
400
+ request_nonce=request_nonce,
401
+ scope_key=f"agent-webhook-subscription:{subscription_id}",
402
+ signer=signer,
403
+ )
404
+ return self._request(
405
+ "POST",
406
+ f"/agent/webhook-subscriptions/{subscription_id}/ping",
407
+ body=signed_request,
408
+ )
409
+
410
+ def _request(
411
+ self,
412
+ method: str,
413
+ path: str,
414
+ query: dict[str, Any] | None = None,
415
+ *,
416
+ body: Any | None = None,
417
+ ) -> dict[str, Any]:
418
+ cleaned_query = {
419
+ key: value
420
+ for key, value in (query or {}).items()
421
+ if value is not None and value != ""
422
+ }
423
+ query_string = urlencode(cleaned_query)
424
+ url = f"{self.base_url}{path}"
425
+ if query_string:
426
+ url = f"{url}?{query_string}"
427
+
428
+ headers = {"accept": "application/json"}
429
+ encoded_body: bytes | None = None
430
+ if body is not None:
431
+ encoded_body = json.dumps(body).encode("utf-8")
432
+ headers["content-type"] = "application/json"
433
+
434
+ request = Request(url, data=encoded_body, headers=headers, method=method.upper())
435
+ try:
436
+ with urlopen(request, timeout=self.timeout) as response:
437
+ raw_body = response.read().decode("utf-8")
438
+ except HTTPError as error:
439
+ raise ScientificProtocolApiError(error.code, self._decode_error_body(error)) from error
440
+ except URLError as error:
441
+ raise RuntimeError(f"Scientific Protocol API request failed: {error.reason}") from error
442
+
443
+ if not raw_body:
444
+ return {}
445
+ return json.loads(raw_body)
446
+
447
+ @staticmethod
448
+ def _decode_error_body(error: HTTPError) -> Any:
449
+ raw = error.read().decode("utf-8")
450
+ if not raw:
451
+ return None
452
+ try:
453
+ return json.loads(raw)
454
+ except json.JSONDecodeError:
455
+ return raw
456
+
457
+
458
+ def _print_json(value: Any) -> None:
459
+ print(json.dumps(value, indent=2, sort_keys=True))
460
+
461
+
462
+ def _required_base_url(value: str | None) -> str:
463
+ resolved = value or os.environ.get("SP_API_BASE_URL")
464
+ if not resolved:
465
+ raise SystemExit("missing_api_base_url: pass --base-url or set SP_API_BASE_URL")
466
+ return resolved
467
+
468
+
469
+ def build_cli_parser() -> argparse.ArgumentParser:
470
+ parser = argparse.ArgumentParser(prog="sp-agent-client")
471
+ parser.add_argument("--base-url", help="Scientific Protocol API base URL")
472
+ subparsers = parser.add_subparsers(dest="command", required=True)
473
+
474
+ subparsers.add_parser("health")
475
+
476
+ list_work = subparsers.add_parser("list-work-items")
477
+ list_work.add_argument("--claim-id")
478
+ list_work.add_argument("--claimable", action="store_true")
479
+ list_work.add_argument("--kind")
480
+ list_work.add_argument("--lane")
481
+ list_work.add_argument("--limit", type=int)
482
+ list_work.add_argument("--offset", type=int)
483
+ list_work.add_argument("--status")
484
+
485
+ get_work = subparsers.add_parser("get-work-item")
486
+ get_work.add_argument("item_id")
487
+ get_work.add_argument("--claim-id")
488
+
489
+ runtime_events = subparsers.add_parser("runtime-events")
490
+ runtime_events.add_argument("--agent-id")
491
+ runtime_events.add_argument("--claim-id")
492
+ runtime_events.add_argument("--limit", type=int)
493
+ runtime_events.add_argument("--offset", type=int)
494
+ runtime_events.add_argument("--since")
495
+
496
+ agent_summary = subparsers.add_parser("agent-work-summary")
497
+ agent_summary.add_argument("agent_id")
498
+ agent_summary.add_argument("--domain-id", type=int)
499
+
500
+ agent_calibration = subparsers.add_parser("agent-review-calibration")
501
+ agent_calibration.add_argument("agent_id")
502
+ agent_calibration.add_argument("--limit", type=int)
503
+ agent_calibration.add_argument("--offset", type=int)
504
+
505
+ create_webhook = subparsers.add_parser("create-webhook-subscription")
506
+ create_webhook.add_argument("--agent-id", required=True)
507
+ create_webhook.add_argument("--private-key", required=True)
508
+ create_webhook.add_argument("--target-url", required=True)
509
+ create_webhook.add_argument("--event-type", action="append", dest="event_types")
510
+ create_webhook.add_argument("--label")
511
+ create_webhook.add_argument("--signing-secret")
512
+ create_webhook.add_argument("--cast-binary", default="cast")
513
+
514
+ verify_webhook = subparsers.add_parser("verify-webhook-signature")
515
+ verify_webhook.add_argument("--secret", required=True)
516
+ verify_webhook.add_argument("--timestamp", required=True)
517
+ verify_webhook.add_argument("--signature", required=True)
518
+ verify_webhook.add_argument("--payload-body")
519
+ verify_webhook.add_argument("--payload-file")
520
+
521
+ return parser
522
+
523
+
524
+ def main(argv: list[str] | None = None) -> int:
525
+ parser = build_cli_parser()
526
+ args = parser.parse_args(argv)
527
+
528
+ if args.command == "verify-webhook-signature":
529
+ if bool(args.payload_body) == bool(args.payload_file):
530
+ raise SystemExit("provide exactly one of --payload-body or --payload-file")
531
+ payload_body = (
532
+ args.payload_body
533
+ if args.payload_body is not None
534
+ else open(args.payload_file, "r", encoding="utf-8").read()
535
+ )
536
+ _print_json(
537
+ {
538
+ "ok": verify_webhook_signature(
539
+ payload_body=payload_body,
540
+ secret=args.secret,
541
+ signature=args.signature,
542
+ timestamp=args.timestamp,
543
+ )
544
+ }
545
+ )
546
+ return 0
547
+
548
+ client = ScientificProtocolClient(_required_base_url(args.base_url))
549
+
550
+ if args.command == "health":
551
+ _print_json(client.get_health())
552
+ return 0
553
+ if args.command == "list-work-items":
554
+ _print_json(
555
+ client.list_work_items(
556
+ claim_id=args.claim_id,
557
+ claimable=args.claimable or None,
558
+ kind=args.kind,
559
+ lane=args.lane,
560
+ limit=args.limit,
561
+ offset=args.offset,
562
+ status=args.status,
563
+ )
564
+ )
565
+ return 0
566
+ if args.command == "get-work-item":
567
+ _print_json(client.get_work_item(args.item_id, claim_id=args.claim_id))
568
+ return 0
569
+ if args.command == "runtime-events":
570
+ _print_json(
571
+ client.get_agent_runtime_events(
572
+ agent_id=args.agent_id,
573
+ claim_id=args.claim_id,
574
+ limit=args.limit,
575
+ offset=args.offset,
576
+ since=args.since,
577
+ )
578
+ )
579
+ return 0
580
+ if args.command == "agent-work-summary":
581
+ _print_json(client.get_agent_work_summary(args.agent_id, domain_id=args.domain_id))
582
+ return 0
583
+ if args.command == "agent-review-calibration":
584
+ _print_json(
585
+ client.get_agent_review_calibration(
586
+ args.agent_id,
587
+ limit=args.limit,
588
+ offset=args.offset,
589
+ )
590
+ )
591
+ return 0
592
+ if args.command == "create-webhook-subscription":
593
+ signer = CastAgentSigner(private_key=args.private_key, cast_binary=args.cast_binary)
594
+ _print_json(
595
+ client.create_agent_webhook_subscription(
596
+ agent_id=args.agent_id,
597
+ signer=signer,
598
+ target_url=args.target_url,
599
+ event_types=args.event_types,
600
+ label=args.label,
601
+ signing_secret=args.signing_secret,
602
+ )
603
+ )
604
+ return 0
605
+
606
+ raise SystemExit(f"unsupported_command:{args.command}")
607
+
608
+ if __name__ == "__main__":
609
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+