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.
- beeos_cloud_harness_sdk/__init__.py +283 -0
- beeos_cloud_harness_sdk/_generated/__init__.py +1 -0
- beeos_cloud_harness_sdk/_generated/models.py +97 -0
- beeos_cloud_harness_sdk/_generated/operations.py +70 -0
- beeos_cloud_harness_sdk/canvas_relay.py +112 -0
- beeos_cloud_harness_sdk/claims.py +74 -0
- beeos_cloud_harness_sdk/gateway.py +411 -0
- beeos_cloud_harness_sdk/identity.py +137 -0
- beeos_cloud_harness_sdk/jcs.py +74 -0
- beeos_cloud_harness_sdk/lease_http.py +170 -0
- beeos_cloud_harness_sdk/realtime.py +54 -0
- beeos_cloud_harness_sdk/registration.py +668 -0
- beeos_cloud_harness_sdk/retry.py +113 -0
- beeos_cloud_harness_sdk/runtime_delivery_port.py +359 -0
- beeos_cloud_harness_sdk/runtime_plane.py +175 -0
- beeos_cloud_harness_sdk/terminal.py +160 -0
- beeos_cloud_harness_sdk-1.0.0.dist-info/METADATA +49 -0
- beeos_cloud_harness_sdk-1.0.0.dist-info/RECORD +21 -0
- beeos_cloud_harness_sdk-1.0.0.dist-info/WHEEL +5 -0
- beeos_cloud_harness_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- beeos_cloud_harness_sdk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|