beeos-cloud-harness-sdk 1.0.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.
@@ -0,0 +1,283 @@
1
+ """Production BeeOS Harness SDK for custom agent harnesses (OpenClaw, Hermes, and others)."""
2
+ from __future__ import annotations
3
+ import json, urllib.error, urllib.parse, urllib.request
4
+ from collections.abc import Callable
5
+ from typing import Any, get_args
6
+
7
+ from ._generated.models import RuntimeMessagePatch, RuntimeMethod
8
+ from ._generated.operations import OPERATIONS, operation_path
9
+
10
+ RUNTIME_METHODS: list[str] = list(get_args(RuntimeMethod))
11
+
12
+ # Cloud chat delivers every user prompt as this runtime method. The bound reply
13
+ # row is pre-created by Cloud in `streaming`; the agent converges it.
14
+ CHAT_PROMPT_METHOD = "session/prompt"
15
+
16
+ class BeeOSAgentError(RuntimeError):
17
+ def __init__(self, status: int, code: str, message: str):
18
+ super().__init__(message)
19
+ self.status, self.code = status, code
20
+
21
+ class BeeOSAgentRuntime:
22
+ def __init__(self, agent_gateway_url: str, message_service_url: str, instance_id: str, agent_id: str, token: str | Callable[[], str], framework: str = "custom", transport=None):
23
+ if not all([agent_gateway_url, message_service_url, instance_id, agent_id, token]):
24
+ raise ValueError("agent runtime requires gateway, message service, instance, agent and token")
25
+ self.agent_gateway_url = agent_gateway_url.rstrip("/") + "/"
26
+ self.message_service_url = message_service_url.rstrip("/") + "/"
27
+ self.instance_id = instance_id
28
+ self.agent_id = agent_id
29
+ self._token = token
30
+ self.framework = framework
31
+ self._transport = transport
32
+ self._closed = False
33
+ self.on_chat_message = None
34
+ self.on_chat_cancel = None
35
+ self.on_runtime_method = None
36
+ self.agent = _Agent(self)
37
+ self.messages = _Messages(self)
38
+ self.chat = _Chat(self)
39
+ self.files = _Files(self)
40
+ self.canvas = _Canvas(self)
41
+ self.a2a = _A2a(self)
42
+ self.messaging = _Messaging(self)
43
+
44
+ def token(self) -> str:
45
+ return self._token() if callable(self._token) else self._token
46
+
47
+ def register(self, *, on_chat_message=None, on_chat_cancel=None, on_runtime_method=None):
48
+ self.on_chat_message = on_chat_message
49
+ self.on_chat_cancel = on_chat_cancel
50
+ self.on_runtime_method = on_runtime_method
51
+
52
+ def request(self, base: str, method: str, path: str, json_body=None, headers=None):
53
+ if self._closed:
54
+ raise BeeOSAgentError(409, "runtime_closed", "BeeOSAgentRuntime is closed")
55
+ root = self.agent_gateway_url if base == "gateway" else self.message_service_url
56
+ url = urllib.parse.urljoin(root, path.lstrip("/"))
57
+ key = self.token()
58
+ req_headers = {
59
+ "Authorization": key if " " in key else f"Bearer {key}",
60
+ "Accept": "application/json",
61
+ "X-BeeOS-Instance-ID": self.instance_id,
62
+ "X-BeeOS-Agent-ID": self.agent_id,
63
+ }
64
+ if headers:
65
+ req_headers.update(headers)
66
+ data = None
67
+ if json_body is not None:
68
+ req_headers["Content-Type"] = "application/json"
69
+ data = json.dumps(json_body).encode()
70
+ if self._transport:
71
+ status, raw, _resp = self._transport(method, url, req_headers, data)
72
+ if status >= 400:
73
+ raise BeeOSAgentError(status, "agent_backend_unavailable", raw.decode() if isinstance(raw, (bytes, bytearray)) else str(raw))
74
+ if status == 204 or not raw:
75
+ return None
76
+ return json.loads(raw)
77
+ req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
78
+ try:
79
+ with urllib.request.urlopen(req) as resp:
80
+ raw = resp.read()
81
+ if resp.status == 204 or not raw:
82
+ return None
83
+ return json.loads(raw.decode())
84
+ except urllib.error.HTTPError as err:
85
+ payload = err.read().decode()
86
+ code, message = "agent_backend_unavailable", err.reason
87
+ try:
88
+ parsed = json.loads(payload)
89
+ code, message = parsed.get("code", code), parsed.get("message", message)
90
+ except Exception:
91
+ pass
92
+ raise BeeOSAgentError(err.code, code, message) from err
93
+
94
+ def _call(self, operation_id: str, json_body=None, headers=None, **path_params):
95
+ method, _path, service = OPERATIONS[operation_id]
96
+ base = "gateway" if service == "agent-gateway" else "message"
97
+ return self.request(base, method, operation_path(operation_id, **path_params), json_body, headers)
98
+
99
+ def dispatch_chat_message(self, event: dict):
100
+ if self.on_chat_message:
101
+ return self.on_chat_message(event)
102
+
103
+ def dispatch_chat_cancel(self, event: dict):
104
+ if self.on_chat_cancel:
105
+ return self.on_chat_cancel(event)
106
+
107
+ def dispatch_runtime_method(self, method: str, params: dict | None = None):
108
+ if method not in RUNTIME_METHODS:
109
+ raise BeeOSAgentError(400, "unsupported_runtime_method", method)
110
+ if method == CHAT_PROMPT_METHOD and not self.on_runtime_method:
111
+ return self.dispatch_session_prompt(params or {})
112
+ if not self.on_runtime_method:
113
+ raise BeeOSAgentError(501, "runtime_method_unhandled", method)
114
+ return self.on_runtime_method(method, params or {})
115
+
116
+ def dispatch_session_prompt(self, params: dict):
117
+ """Cloud chat prompt -> the agent's chat handler (same event shape)."""
118
+ prompt = extract_session_prompt(params, self.agent_id)
119
+ if prompt is None:
120
+ raise BeeOSAgentError(400, "invalid_session_prompt", CHAT_PROMPT_METHOD)
121
+ return self.dispatch_chat_message(prompt)
122
+
123
+ def consume_envelope(self, envelope: dict):
124
+ kind = envelope.get("type") or (envelope.get("message") or {}).get("type") or ""
125
+ if kind == "chat_cancel":
126
+ content = envelope.get("content") or {}
127
+ return self.dispatch_chat_cancel({
128
+ "conversationId": envelope.get("conversationId") or envelope.get("conversation_id") or "",
129
+ "targetMessageId": envelope.get("replyTo") or envelope.get("reply_to") or content.get("target_message_id") or "",
130
+ "agentId": self.agent_id,
131
+ })
132
+ prompt = extract_chat_prompt(envelope, self.agent_id)
133
+ if not prompt or not prompt.get("messageId") or not prompt.get("channelId"):
134
+ return None
135
+ return self.dispatch_chat_message({
136
+ "conversationId": prompt["channelId"],
137
+ "messageId": prompt["messageId"],
138
+ "agentId": self.agent_id,
139
+ "text": prompt["message"],
140
+ "channelId": prompt["channelId"],
141
+ "sessionKey": prompt.get("sessionKey"),
142
+ "files": prompt.get("files") or [],
143
+ })
144
+
145
+ def close(self):
146
+ self._closed = True
147
+ self.on_chat_message = self.on_chat_cancel = self.on_runtime_method = None
148
+
149
+ def extract_chat_prompt(envelope: dict, local_agent_id: str = "") -> dict | None:
150
+ inner = envelope.get("message") or envelope
151
+ content = inner.get("content") or {}
152
+ if not isinstance(content, dict):
153
+ content = {}
154
+ text = content.get("message") or envelope.get("body") or ""
155
+ if not text and isinstance(content.get("parts"), list):
156
+ text = "\n".join(part.get("text", "") for part in content["parts"] if isinstance(part, dict) and part.get("type") == "text")
157
+ if not text:
158
+ return None
159
+ channel_id = inner.get("conversationId") or envelope.get("conversationId") or envelope.get("conversation_id") or content.get("channel_id")
160
+ context_id = content.get("context_id") or (content.get("metadata") or {}).get("context_id")
161
+ message_id = inner.get("id") or envelope.get("id")
162
+ source = "a2a" if inner.get("type") == "agent_request" or envelope.get("type") == "agent_request" else "invoke"
163
+ session_key = content.get("session_key") or f"agent:{local_agent_id}:{source}:{'ctx-' + context_id if context_id else 'ch-' + channel_id if channel_id else 'anon'}"
164
+ files = [item for item in (content.get("files") or []) if isinstance(item, str)]
165
+ if isinstance(content.get("parts"), list):
166
+ files.extend(part.get("file_url") for part in content["parts"] if isinstance(part, dict) and part.get("file_url"))
167
+ return {"message": text, "files": [item for item in files if item], "channelId": channel_id, "contextId": context_id, "sessionKey": session_key, "messageId": message_id}
168
+
169
+ def extract_session_prompt(params: dict, local_agent_id: str = "") -> dict | None:
170
+ """Normalize Cloud chat ``session/prompt`` params into a chat event.
171
+
172
+ Returns None when the task binding Cloud guarantees is incomplete, so the
173
+ caller fails the operation instead of writing to an unbound reply row.
174
+ """
175
+ if not isinstance(params, dict):
176
+ return None
177
+ conversation_id = str(params.get("conversationId") or "")
178
+ reply_message_id = str(params.get("replyMessageId") or "")
179
+ message_id = str(params.get("taskRequestMessageId") or "")
180
+ run_id = str(params.get("runtimeRunId") or params.get("taskId") or "")
181
+ if not conversation_id or not reply_message_id or not message_id or not run_id:
182
+ return None
183
+ raw_parts = params.get("parts") if isinstance(params.get("parts"), list) else []
184
+ parts = [part for part in raw_parts if isinstance(part, dict)]
185
+ text = "\n".join(str(part.get("text") or "") for part in parts if part.get("type") == "text").strip()
186
+ files = [str(part["blobRef"]) for part in parts if part.get("blobRef")]
187
+ agent_id = str(params.get("platformAgentId") or local_agent_id)
188
+ return {
189
+ "conversationId": conversation_id,
190
+ "channelId": conversation_id,
191
+ "messageId": message_id,
192
+ "replyMessageId": reply_message_id,
193
+ "runtimeRunId": run_id,
194
+ "agentId": agent_id,
195
+ "text": text,
196
+ "files": files,
197
+ "modelOverrideId": str(params.get("modelOverrideId") or "") or None,
198
+ "sessionKey": f"agent:{agent_id}:cloud-chat:ch-{conversation_id}",
199
+ }
200
+
201
+
202
+ class _Chat:
203
+ """Cloud chat reply convergence over the current runtime lease."""
204
+
205
+ def __init__(self, runtime: BeeOSAgentRuntime):
206
+ self.runtime = runtime
207
+
208
+ def patch_reply(self, conversation_id: str, reply_message_id: str, body: RuntimeMessagePatch, operation_key: str = ""):
209
+ if not conversation_id or not reply_message_id:
210
+ raise BeeOSAgentError(400, "invalid_chat_reply", "chat reply requires conversationId and replyMessageId")
211
+ headers = {"Idempotency-Key": operation_key} if operation_key else None
212
+ return self.runtime._call("runtimeConversationMessagePatch", body, headers, conversationId=conversation_id, messageId=reply_message_id)
213
+
214
+ def complete_reply(self, conversation_id: str, reply_message_id: str, text: str, operation_key: str = ""):
215
+ return self.patch_reply(
216
+ conversation_id, reply_message_id,
217
+ {"body": text, "parts": [{"type": "text", "text": text}], "state": "completed", "stop_reason": "end_turn"},
218
+ operation_key)
219
+
220
+ def fail_reply(self, conversation_id: str, reply_message_id: str, text: str = "", operation_key: str = ""):
221
+ return self.patch_reply(
222
+ conversation_id, reply_message_id,
223
+ {"body": text, "state": "failed", "stop_reason": "error"},
224
+ operation_key)
225
+
226
+
227
+ class _Agent:
228
+ def __init__(self, runtime: BeeOSAgentRuntime):
229
+ self.runtime = runtime
230
+ def get(self):
231
+ return self.runtime._call("agentGet", agentId=self.runtime.agent_id)
232
+ def update_exposure(self, patch, resource_version):
233
+ return self.runtime._call("agentUpdate", patch, {"If-Match": f'"{resource_version}"'}, agentId=self.runtime.agent_id)
234
+ def sync(self, input):
235
+ return self.runtime._call("agentSync", input)
236
+
237
+ class _Messages:
238
+ def __init__(self, runtime: BeeOSAgentRuntime):
239
+ self.runtime = runtime
240
+ def send(self, conversation_id, input):
241
+ return self.runtime._call("conversationMessageSend", input, conversationId=conversation_id)
242
+ def reply(self, conversation_id: str, inbound_message_id: str, text: str):
243
+ if not conversation_id or not inbound_message_id:
244
+ raise BeeOSAgentError(400, "invalid_agent_reply", "agent_reply requires conversationId and inbound messageId")
245
+ return self.send(conversation_id, {"type": "agent_reply", "replyTo": inbound_message_id, "reply_to": inbound_message_id, "content": {"text": text}, "sender": self.runtime.instance_id})
246
+ def list(self, conversation_id):
247
+ return self.runtime._call("conversationMessagesList", conversationId=conversation_id)
248
+ def consume_envelope(self, envelope: dict):
249
+ return self.runtime.consume_envelope(envelope)
250
+
251
+ class _Files:
252
+ def __init__(self, runtime: BeeOSAgentRuntime):
253
+ self.runtime = runtime
254
+ def list(self):
255
+ return self.runtime._call("agentFilesList")
256
+ def presign(self, input):
257
+ return self.runtime._call("agentFilePresign", input)
258
+ def confirm(self, input):
259
+ return self.runtime._call("agentFileConfirm", input)
260
+ def resolve(self, file_id):
261
+ return self.runtime._call("agentFileResolve", fileId=file_id)
262
+
263
+ class _Canvas:
264
+ def __init__(self, runtime: BeeOSAgentRuntime):
265
+ self.runtime = runtime
266
+ def token(self, input=None):
267
+ return self.runtime._call("canvasTokenCreate", input if input is not None else {})
268
+
269
+ class _A2a:
270
+ def __init__(self, runtime: BeeOSAgentRuntime):
271
+ self.runtime = runtime
272
+ def discover(self):
273
+ return self.runtime._call("a2aDiscover")
274
+ def call(self, agent_id, input):
275
+ return self.runtime._call("a2aJsonRpc", input, agentId=agent_id)
276
+ def get_task(self, task_id):
277
+ return self.runtime._call("a2aTaskGet", taskId=task_id)
278
+
279
+ class _Messaging:
280
+ def __init__(self, runtime: BeeOSAgentRuntime):
281
+ self.runtime = runtime
282
+ def token(self, input=None):
283
+ return self.runtime._call("messagingTokenCreate", input if input is not None else {})
@@ -0,0 +1 @@
1
+ """Generated by sdks/codegen/gen.mjs from sdks/products/harness/spec/harness.openapi.yaml. Do not edit."""
@@ -0,0 +1,97 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: harness.openapi.yaml
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Literal, NotRequired, TypeAlias, TypedDict
7
+
8
+
9
+ class JsonObject(TypedDict):
10
+ pass
11
+
12
+
13
+ RuntimeMethod: TypeAlias = Literal[
14
+ 'agent/create',
15
+ 'agent/update',
16
+ 'agent/delete',
17
+ 'agent/applyTemplate',
18
+ 'skills/list',
19
+ 'skills/install',
20
+ 'skills/update',
21
+ 'skills/uninstall',
22
+ 'models/list',
23
+ 'cron/list',
24
+ 'cron/status',
25
+ 'cron/add',
26
+ 'cron/update',
27
+ 'cron/remove',
28
+ 'cron/run',
29
+ 'cron/runs',
30
+ 'mcp/list',
31
+ 'mcp/prepare',
32
+ 'mcp/set',
33
+ 'mcp/unset',
34
+ 'session/set_mode',
35
+ 'session/set_model',
36
+ 'session/clear',
37
+ 'session/cancel',
38
+ 'session/prompt',
39
+ 'canvas/toggle',
40
+ 'canvas/clear',
41
+ 'canvas/reference',
42
+ 'canvas/dimensions',
43
+ ]
44
+
45
+
46
+ class RuntimeDeliveriesReadRequest(TypedDict):
47
+ schemaVersion: Literal[1]
48
+ maxCount: int
49
+ blockMs: int
50
+
51
+
52
+ class RuntimeDeliveryIdsRequest(TypedDict):
53
+ schemaVersion: Literal[1]
54
+ deliveryIds: list[str]
55
+
56
+
57
+ class RuntimeOperationAppendRequest(TypedDict):
58
+ schemaVersion: Literal[1]
59
+ type: str
60
+ payload: Any
61
+
62
+
63
+ class RuntimeConversationSyncRequest(TypedDict):
64
+ schemaVersion: Literal[1]
65
+ operationId: str
66
+ runtimeSessionId: str
67
+ runtimeSessionKey: NotRequired[str]
68
+ title: NotRequired[str]
69
+
70
+
71
+ class RuntimeMessagePatch(TypedDict):
72
+ body: NotRequired[str]
73
+ body_append: NotRequired[str]
74
+ body_from: NotRequired[int]
75
+ parts: NotRequired[list[JsonObject]]
76
+ state: NotRequired[str]
77
+ stop_reason: NotRequired[str]
78
+
79
+
80
+ class TerminalAgentAuthFrame(TypedDict):
81
+ type: Literal['agent_auth']
82
+ instance_id: str
83
+ service: Literal['terminal']
84
+ public_key: str
85
+ timestamp: int
86
+ nonce: str
87
+ signature: str
88
+ runtimeLeaseCredential: str
89
+ handlerIdentity: str
90
+ runtimeEpoch: str
91
+ leaseId: str
92
+
93
+
94
+ class CanvasRelayConnect(TypedDict):
95
+ path: Literal['/ws/agent']
96
+ agentIdQuery: NotRequired[str]
97
+ tokenQuery: NotRequired[str]
@@ -0,0 +1,70 @@
1
+ # Generated by sdks/codegen/gen.mjs from sdks/products/harness/spec/harness.openapi.yaml. Do not edit.
2
+ import re
3
+ from urllib.parse import quote
4
+
5
+ OPERATIONS: dict[str, tuple[str, str, str]] = {
6
+ "runtimeRegister": ("POST", "/api/v1/runtime/registrations", "agent-gateway"),
7
+ "runtimeHeartbeat": ("POST", "/api/v1/runtime/registrations/{registrationId}/heartbeat", "agent-gateway"),
8
+ "runtimeRealtimeTokenCreate": ("POST", "/api/v1/runtime/realtime/token", "message-service"),
9
+ "runtimeConversationSync": ("POST", "/api/v1/runtime/conversations", "message-service"),
10
+ "runtimeConversationMessagesList": ("GET", "/api/v1/runtime/conversations/{conversationId}/messages", "message-service"),
11
+ "runtimeConversationMessageCreate": ("POST", "/api/v1/runtime/conversations/{conversationId}/messages", "message-service"),
12
+ "runtimeConversationMessageGet": ("GET", "/api/v1/runtime/conversations/{conversationId}/messages/{messageId}", "message-service"),
13
+ "runtimeConversationMessagePatch": ("PATCH", "/api/v1/runtime/conversations/{conversationId}/messages/{messageId}", "message-service"),
14
+ "runtimeConversationRunStart": ("POST", "/api/v1/runtime/conversations/{conversationId}/runs/start", "message-service"),
15
+ "runtimeConversationRunFinish": ("POST", "/api/v1/runtime/conversations/{conversationId}/runs/finish", "message-service"),
16
+ "runtimeDeliveriesRead": ("POST", "/api/v1/runtime/deliveries/read", "message-service"),
17
+ "runtimeDeliveriesRenew": ("POST", "/api/v1/runtime/deliveries/renew", "message-service"),
18
+ "runtimeDeliveriesAck": ("POST", "/api/v1/runtime/deliveries/ack", "message-service"),
19
+ "runtimeOperationHistoryGet": ("GET", "/api/v1/runtime/operations/{operationId}/history", "message-service"),
20
+ "runtimeOperationMessageAppend": ("POST", "/api/v1/runtime/operations/{operationId}/messages", "message-service"),
21
+ "conversationMessagesList": ("GET", "/api/v2/conversations/{conversationId}/messages", "message-service"),
22
+ "conversationMessageSend": ("POST", "/api/v2/conversations/{conversationId}/messages", "message-service"),
23
+ "agentGet": ("GET", "/api/v1/agents/{agentId}", "agent-gateway"),
24
+ "agentUpdate": ("PATCH", "/api/v1/agents/{agentId}", "agent-gateway"),
25
+ "agentSync": ("POST", "/api/v1/agents/sync", "agent-gateway"),
26
+ "agentFilesList": ("GET", "/api/v1/agent/files", "agent-gateway"),
27
+ "agentFilePresign": ("POST", "/api/v1/agent/files/presign", "agent-gateway"),
28
+ "agentFileConfirm": ("POST", "/api/v1/agent/files/confirm", "agent-gateway"),
29
+ "agentFileResolve": ("GET", "/api/v1/agent/files/{fileId}/resolve", "agent-gateway"),
30
+ "canvasTokenCreate": ("POST", "/api/v1/canvas/token", "agent-gateway"),
31
+ "a2aDiscover": ("GET", "/api/v1/a2a/discover", "agent-gateway"),
32
+ "a2aJsonRpc": ("POST", "/api/v1/a2a/{agentId}/jsonrpc", "agent-gateway"),
33
+ "a2aTaskGet": ("GET", "/api/v1/a2a/tasks/{taskId}", "agent-gateway"),
34
+ "messagingTokenCreate": ("POST", "/api/v1/messaging/token", "agent-gateway"),
35
+ "runtimeHistoryBoundaryPrepare": ("POST", "/api/v1/runtime/conversations/{conversationId}/history-boundary/prepare", "message-service"),
36
+ "runtimeHistoryBoundaryResetting": ("POST", "/api/v1/runtime/conversations/{conversationId}/history-boundary/resetting", "message-service"),
37
+ "runtimeHistoryBoundaryResetDone": ("POST", "/api/v1/runtime/conversations/{conversationId}/history-boundary/reset_done", "message-service"),
38
+ "runtimeHistoryBoundaryCommit": ("POST", "/api/v1/runtime/conversations/{conversationId}/history-boundary/commit", "message-service"),
39
+ "runtimeHistoryBoundaryAbort": ("POST", "/api/v1/runtime/conversations/{conversationId}/history-boundary/abort", "message-service"),
40
+ "runtimeHistoryBoundaryReconcile": ("POST", "/api/v1/runtime/conversations/{conversationId}/history-boundary/reconcile", "message-service"),
41
+ "runtimeConversationMetadataModel": ("POST", "/api/v1/runtime/conversations/{conversationId}/metadata/model", "message-service"),
42
+ "runtimeConversationCronDelivery": ("POST", "/api/v1/runtime/conversations/{conversationId}/deliveries/cron", "message-service"),
43
+ "runtimeClaimNext": ("POST", "/api/v1/runtime/claims/next", "message-service"),
44
+ "runtimeClaimExact": ("POST", "/api/v1/runtime/operations/{operationId}/claims", "message-service"),
45
+ "runtimeClaimRenew": ("POST", "/api/v1/runtime/operations/{operationId}/claims/renew", "message-service"),
46
+ "runtimeClaimRelease": ("POST", "/api/v1/runtime/operations/{operationId}/claims/release", "message-service"),
47
+ "runtimeResourceRedeem": ("POST", "/api/v1/internal/runtime/operations/{operationId}/resources/{kind}/{resourceRef}/redeem", "agent-gateway"),
48
+ "runtimeOutputFilePresign": ("POST", "/api/v1/internal/runtime/operations/{operationId}/output-files/presign", "agent-gateway"),
49
+ "runtimeOutputFileConfirm": ("POST", "/api/v1/internal/runtime/operations/{operationId}/output-files/confirm", "agent-gateway"),
50
+ "runtimeInputFilesResolve": ("POST", "/api/v1/internal/runtime/operations/{operationId}/input-files/resolve", "agent-gateway"),
51
+ "agentTemplateOriginReport": ("POST", "/api/v1/agents/{agentId}/template-origin", "agent-gateway"),
52
+ "channelCreate": ("POST", "/api/v1/channels", "agent-gateway"),
53
+ "a2aTaskComplete": ("POST", "/api/v1/a2a/tasks/{taskId}/complete", "agent-gateway"),
54
+ "agentEventPublish": ("POST", "/api/v1/agent/events", "agent-gateway"),
55
+ "bridgeConfigGet": ("GET", "/api/v1/bridge/config", "agent-gateway"),
56
+ "agentConnectorMcp": ("POST", "/api/v1/agent/connectors/mcp", "agent-gateway"),
57
+ "automationList": ("GET", "/api/v1/automations", "agent-gateway"),
58
+ "automationCreate": ("POST", "/api/v1/automations", "agent-gateway"),
59
+ "automationWebhookCreate": ("POST", "/api/v1/automations/webhooks", "agent-gateway"),
60
+ "automationGet": ("GET", "/api/v1/automations/{automationId}", "agent-gateway"),
61
+ "automationRunsList": ("GET", "/api/v1/automations/{automationId}/runs", "agent-gateway"),
62
+ "automationRunCreate": ("POST", "/api/v1/automations/{automationId}/runs", "agent-gateway"),
63
+ "automationRunGet": ("GET", "/api/v1/automations/{automationId}/runs/{runId}", "agent-gateway"),
64
+ }
65
+
66
+
67
+ def operation_path(operation_id: str, **params: object) -> str:
68
+ """Expand an operation's path template; every value is percent-encoded."""
69
+ template = OPERATIONS[operation_id][1]
70
+ return re.sub(r"\{(\w+)\}", lambda match: quote(str(params[match.group(1)]), safe=""), template)
@@ -0,0 +1,112 @@
1
+ """Canvas relay WebSocket URL, upgrade headers, and frames. Socket I/O is injected.
2
+
3
+ Mirror of ``src/canvas-relay.ts``. Yjs payload bytes stay with the harness.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from collections.abc import Callable
9
+ from typing import Protocol
10
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
11
+
12
+ from beeos_cloud_harness_sdk.identity import AgentKeyPair, agent_auth_headers
13
+
14
+ CANVAS_RELAY_PATH = "/ws/agent"
15
+
16
+
17
+ def canvas_relay_websocket_url(
18
+ relay_url: str, *, agent_id: str | None = None, token: str | None = None,
19
+ ) -> str:
20
+ """``{relay}/ws/agent``. An existing path prefix is kept. Never ``/ws/{canvasId}``."""
21
+ base = relay_url.strip().rstrip("/")
22
+ if not base:
23
+ raise ValueError("empty canvas relay url")
24
+ if base.startswith("https://"):
25
+ base = "wss://" + base[len("https://"):]
26
+ elif base.startswith("http://"):
27
+ base = "ws://" + base[len("http://"):]
28
+ elif not (base.startswith("ws://") or base.startswith("wss://")):
29
+ base = "wss://" + base
30
+ if not base.endswith(CANVAS_RELAY_PATH):
31
+ base = base + CANVAS_RELAY_PATH
32
+ parts = urlsplit(base)
33
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
34
+ if agent_id:
35
+ query["agentId"] = agent_id
36
+ if token:
37
+ query["token"] = token
38
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), ""))
39
+
40
+
41
+ def canvas_relay_upgrade_headers(keys: AgentKeyPair) -> dict[str, str]:
42
+ return agent_auth_headers("GET", CANVAS_RELAY_PATH, keys)
43
+
44
+
45
+ def canvas_relay_join_frame(canvas_id: str) -> str:
46
+ return json.dumps({"type": "join", "canvasId": canvas_id}, separators=(",", ":"))
47
+
48
+
49
+ def canvas_relay_leave_frame(canvas_id: str) -> str:
50
+ return json.dumps({"type": "leave", "canvasId": canvas_id}, separators=(",", ":"))
51
+
52
+
53
+ def encode_canvas_relay_binary(canvas_id: str, payload: bytes) -> bytes:
54
+ encoded = canvas_id.encode("utf-8")
55
+ if not encoded or len(encoded) > 255:
56
+ raise ValueError("canvas id length must be 1..255 bytes")
57
+ return bytes((len(encoded),)) + encoded + payload
58
+
59
+
60
+ class CanvasRelaySocket(Protocol):
61
+ def send(self, data: str | bytes) -> None: ...
62
+ def close(self) -> None: ...
63
+ async def wait_open(self) -> None: ...
64
+
65
+
66
+ class CanvasRelayClient:
67
+ def __init__(
68
+ self,
69
+ *,
70
+ relay_url: str,
71
+ keys: AgentKeyPair,
72
+ agent_id: str | None = None,
73
+ token: str | None = None,
74
+ socket_factory: Callable[[str, dict[str, str]], CanvasRelaySocket] | None = None,
75
+ ) -> None:
76
+ self._relay_url = relay_url
77
+ self._keys = keys
78
+ self._agent_id = agent_id
79
+ self._token = token
80
+ self._socket_factory = socket_factory
81
+ self._socket: CanvasRelaySocket | None = None
82
+
83
+ async def connect(self) -> None:
84
+ headers = canvas_relay_upgrade_headers(self._keys)
85
+ url = canvas_relay_websocket_url(self._relay_url, agent_id=self._agent_id, token=self._token)
86
+ if self._socket_factory is None:
87
+ raise ImportError(
88
+ "canvas relay WebSocket requires an injected socket_factory; "
89
+ "beeos-cloud-harness-sdk does not depend on websockets"
90
+ )
91
+ socket = self._socket_factory(url, headers)
92
+ self._socket = socket
93
+ await socket.wait_open()
94
+
95
+ def join(self, canvas_id: str) -> None:
96
+ self._send(canvas_relay_join_frame(canvas_id))
97
+
98
+ def leave(self, canvas_id: str) -> None:
99
+ self._send(canvas_relay_leave_frame(canvas_id))
100
+
101
+ def send_binary(self, canvas_id: str, payload: bytes) -> None:
102
+ self._send(encode_canvas_relay_binary(canvas_id, payload))
103
+
104
+ def close(self) -> None:
105
+ if self._socket is not None:
106
+ self._socket.close()
107
+ self._socket = None
108
+
109
+ def _send(self, data: str | bytes) -> None:
110
+ if self._socket is None:
111
+ raise RuntimeError("canvas relay is not connected")
112
+ self._socket.send(data)
@@ -0,0 +1,74 @@
1
+ """Invocation claim lifecycle. Message Service does not register these routes yet.
2
+
3
+ Mirror of ``src/runtime-claim.ts``. Paths are the beeos-types contracts.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from beeos_cloud_harness_sdk.lease_http import CloudMessageRoutes, RuntimeLeaseCredential, RuntimeLeaseHttp
12
+ from beeos_cloud_harness_sdk.runtime_plane import HarnessProtocolError
13
+
14
+ _NEXT = frozenset({"claimed", "empty", "fenced"})
15
+ _EXACT = frozenset({"claimed", "already_claimed", "fenced", "epoch_handoff_required", "terminal"})
16
+ _RENEW = frozenset({"renewed", "fenced", "expired", "terminal"})
17
+
18
+
19
+ async def _read_status(response: httpx.Response, what: str) -> dict[str, Any]:
20
+ text = response.text
21
+ if response.status_code >= 400:
22
+ raise HarnessProtocolError(f"{what} failed ({response.status_code})", response.status_code, text)
23
+ try:
24
+ body = response.json()
25
+ except ValueError as exc:
26
+ raise HarnessProtocolError(f"{what} returned an invalid result") from exc
27
+ if not isinstance(body, dict) or not isinstance(body.get("status"), str):
28
+ raise HarnessProtocolError(f"{what} returned an invalid result")
29
+ return body
30
+
31
+
32
+ class RuntimeClaimClient:
33
+ def __init__(self, http: RuntimeLeaseHttp) -> None:
34
+ self._http = http
35
+
36
+ async def claim_next(self, credential: RuntimeLeaseCredential, body: dict[str, Any]) -> dict[str, Any]:
37
+ result = await _read_status(
38
+ await self._http.fetch("POST", CloudMessageRoutes.claim_next, credential, json_body=body),
39
+ "claim next",
40
+ )
41
+ if result["status"] not in _NEXT:
42
+ raise HarnessProtocolError("claim next returned an invalid status")
43
+ return result
44
+
45
+ async def claim_exact(self, credential: RuntimeLeaseCredential, body: dict[str, Any]) -> dict[str, Any]:
46
+ result = await _read_status(
47
+ await self._http.fetch(
48
+ "POST", CloudMessageRoutes.claim_exact(str(body["operationId"])), credential, json_body=body,
49
+ ),
50
+ "claim exact",
51
+ )
52
+ if result["status"] not in _EXACT:
53
+ raise HarnessProtocolError("claim exact returned an invalid status")
54
+ return result
55
+
56
+ async def renew_claim(self, credential: RuntimeLeaseCredential, body: dict[str, Any]) -> dict[str, Any]:
57
+ result = await _read_status(
58
+ await self._http.fetch(
59
+ "POST", CloudMessageRoutes.claim_renew(str(body["operationId"])), credential, json_body=body,
60
+ ),
61
+ "claim renew",
62
+ )
63
+ if result["status"] not in _RENEW:
64
+ raise HarnessProtocolError("claim renew returned an invalid status")
65
+ return result
66
+
67
+ async def release_claim(self, credential: RuntimeLeaseCredential, body: dict[str, Any]) -> None:
68
+ response = await self._http.fetch(
69
+ "POST", CloudMessageRoutes.claim_release(str(body["operationId"])), credential, json_body=body,
70
+ )
71
+ if response.status_code != 204:
72
+ raise HarnessProtocolError(
73
+ f"claim release failed ({response.status_code})", response.status_code, response.text,
74
+ )