velophex 0.2.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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,957 @@
1
+ """HTTP client for the Orchestrator Runtime API (``/api/v1/runtime/*``).
2
+
3
+ The client is built from the ``start`` context and nothing else. In particular the execution
4
+ token (``vpx_rt_…``) is **never** read from the environment and never written to one: it
5
+ authorises this attempt only, it is revoked the moment the attempt ends, and a token that
6
+ reaches a child process or a log line has left the blast radius the platform designed for it
7
+ (``docs/runner-sdk-protocol.md`` section 3.3).
8
+
9
+ Every call is already scoped to one attempt by the token, so no method names a tenant, a
10
+ workspace or a job.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from collections.abc import Iterator
17
+ from datetime import UTC, datetime
18
+ from typing import IO, Any, Final, NoReturn
19
+ from urllib.parse import quote
20
+ from uuid import UUID
21
+
22
+ import httpx
23
+
24
+ from velophex import _json
25
+ from velophex.contracts.executor import FAULT_ENVELOPE_VERSION, FaultInfo
26
+ from velophex.contracts.runtimes import FaultKinds
27
+ from velophex.errors import VelophexError
28
+ from velophex.runtime.models import (
29
+ ActionAppSchema,
30
+ ActionTaskResult,
31
+ AddQueueItemRequest,
32
+ CancellationState,
33
+ QueueItem,
34
+ ResolvedSecret,
35
+ RuntimeActionTask,
36
+ RuntimeAsset,
37
+ RuntimeContext,
38
+ RuntimeCredential,
39
+ RuntimeJob,
40
+ RuntimeQueue,
41
+ StartedJob,
42
+ StorageFile,
43
+ StorageFileList,
44
+ )
45
+
46
+ __all__ = [
47
+ "ACTION_TASKS_CAPABILITY",
48
+ "RUNTIME_API_PATH",
49
+ "STORAGE_BUCKETS_CAPABILITY",
50
+ "ActionAppNotFoundError",
51
+ "ActionHubDisabledError",
52
+ "ActionHubError",
53
+ "ActionHubRuntimeUnsupportedError",
54
+ "ActionTaskCorrelationConflictError",
55
+ "ActionTaskInputInvalidError",
56
+ "ActionTaskNotFoundError",
57
+ "ActionTaskNotOwnedError",
58
+ "ActionTaskTimeoutError",
59
+ "AgentsNotEnabledError",
60
+ "AiBudgetExhaustedError",
61
+ "AiError",
62
+ "AiPolicyDeniedError",
63
+ "AiProviderUnavailableError",
64
+ "AiRequestInvalidError",
65
+ "RuntimeApiError",
66
+ "RuntimeClient",
67
+ "StorageBucketsUnsupportedError",
68
+ "runtime_base_url",
69
+ "runtime_error",
70
+ ]
71
+
72
+ RUNTIME_API_PATH: Final = "/api/v1/runtime"
73
+
74
+ _DEFAULT_TIMEOUT: Final = httpx.Timeout(30.0, connect=10.0)
75
+
76
+ #: The runtime capability of the action-task routes (``GET /runtime/capabilities``).
77
+ ACTION_TASKS_CAPABILITY: Final = "action-tasks/1"
78
+
79
+ #: The runtime capability of the storage bucket routes (``RuntimeCapabilities.StorageBuckets``).
80
+ STORAGE_BUCKETS_CAPABILITY: Final = "storage-buckets/1"
81
+
82
+ #: Transport timeout of one upload or download: a file may be 100 MiB.
83
+ _FILE_TIMEOUT: Final = httpx.Timeout(300.0, connect=10.0)
84
+
85
+ #: The server holds one wait at most this long (``ActionTaskLimits.MaxWaitSeconds``).
86
+ _MAX_WAIT_SECONDS: Final = 25
87
+
88
+ #: Transport timeout of one wait: the server cap plus ten seconds (plan X3).
89
+ _WAIT_TIMEOUT: Final = httpx.Timeout(_MAX_WAIT_SECONDS + 10.0, connect=10.0)
90
+
91
+ #: Transient failures of one wait retried before giving up (1, 2, 4, 8, 16 s apart).
92
+ _WAIT_RETRIES: Final = 5
93
+
94
+
95
+ class RuntimeApiError(VelophexError):
96
+ """The Runtime API refused a call.
97
+
98
+ :attr:`status_code` and :attr:`problem` carry the RFC 9457 problem details the Orchestrator
99
+ returns, so an automation can tell ``no_capable_runner`` from a transport failure. The error
100
+ envelope (code, cause, fix, request id, retry scope, docs URL) is read from the same problem.
101
+ """
102
+
103
+ def __init__(self, status_code: int, problem: dict[str, Any] | None, message: str) -> None:
104
+ super().__init__(message)
105
+ self.status_code = status_code
106
+ self.problem = problem or {}
107
+
108
+ @property
109
+ def code(self) -> str | None:
110
+ """The Orchestrator's machine-readable error code, when it sent one."""
111
+ return self._text("code")
112
+
113
+ @property
114
+ def cause(self) -> str | None:
115
+ """Why it happened, in one line."""
116
+ return self._text("cause")
117
+
118
+ @property
119
+ def fix(self) -> str | None:
120
+ """What to do about it."""
121
+ return self._text("fix")
122
+
123
+ @property
124
+ def request_id(self) -> str | None:
125
+ """The Orchestrator's id of the refused request."""
126
+ return self._text("requestId") or self._text("traceId")
127
+
128
+ @property
129
+ def retry_scope(self) -> str | None:
130
+ """What a retry would repeat: ``none``, ``model-call`` or ``run``."""
131
+ return self._text("retryScope")
132
+
133
+ @property
134
+ def docs_url(self) -> str | None:
135
+ """The reference page of :attr:`code`."""
136
+ return self._text("docsUrl")
137
+
138
+ @property
139
+ def retryable(self) -> bool:
140
+ """Whether sending the same call again could succeed."""
141
+ value = self.problem.get("retryable")
142
+ return value if isinstance(value, bool) else self.status_code == 0 or self.status_code >= 500
143
+
144
+ def to_fault(self) -> FaultInfo | None:
145
+ """A refusal with a code is reported under that code; a bare transport failure is not."""
146
+ if self.code is None:
147
+ return None
148
+
149
+ infrastructure = self.status_code == 0 or self.status_code >= 500
150
+
151
+ return FaultInfo(
152
+ kind=FaultKinds.INFRASTRUCTURE if infrastructure else FaultKinds.APPLICATION,
153
+ code=self.code,
154
+ message=str(self),
155
+ retryable=self.retryable,
156
+ version=FAULT_ENVELOPE_VERSION,
157
+ cause=self.cause or self._text("detail"),
158
+ fix=self.fix,
159
+ retry_scope=self.retry_scope,
160
+ request_id=self.request_id,
161
+ docs_url=self.docs_url,
162
+ )
163
+
164
+ def _text(self, name: str) -> str | None:
165
+ value = self.problem.get(name)
166
+ return value if isinstance(value, str) else None
167
+
168
+
169
+ class ActionHubError(RuntimeApiError):
170
+ """An Action Hub refusal, named by its catalogue code (``docs/action-hub.md``).
171
+
172
+ :attr:`fix` is what to do about it, and :attr:`retryable` whether the same call could succeed.
173
+ """
174
+
175
+ @property
176
+ def retryable(self) -> bool:
177
+ """Whether sending the same call again could succeed."""
178
+ return self.problem.get("retryable") is True
179
+
180
+
181
+ class AiError(RuntimeApiError):
182
+ """A refusal of the LLM gateway, named by its ``ai_*`` code."""
183
+
184
+
185
+ class AiBudgetExhaustedError(AiError):
186
+ """``ai_budget_exhausted``: the workspace's AI budget is spent."""
187
+
188
+
189
+ class AiPolicyDeniedError(AiError):
190
+ """``ai_policy_denied``: the workspace's AI policy refused the call."""
191
+
192
+
193
+ class AiProviderUnavailableError(AiError):
194
+ """``ai_provider_unavailable``: the provider failed after the gateway's retries."""
195
+
196
+
197
+ class AiRequestInvalidError(AiError):
198
+ """``ai_request_invalid``: the call is invalid, e.g. a model that is not a binding."""
199
+
200
+
201
+ class AgentsNotEnabledError(AiError):
202
+ """``agents_not_enabled`` or ``agentic_feature_off``: an administrator turns Agents on."""
203
+
204
+
205
+ class ActionHubDisabledError(ActionHubError):
206
+ """``action_hub_disabled``: the tenant has not enabled Action Hub."""
207
+
208
+
209
+ class ActionAppNotFoundError(ActionHubError):
210
+ """``action_app_not_found`` or ``action_app_version_not_published``."""
211
+
212
+
213
+ class ActionTaskInputInvalidError(ActionHubError):
214
+ """``action_task_input_invalid``: :attr:`errors` pins each problem by JSON Pointer."""
215
+
216
+ @property
217
+ def errors(self) -> list[dict[str, Any]]:
218
+ """``[{code, path, message}]``, one per problem."""
219
+ value = self.problem.get("errors")
220
+ return value if isinstance(value, list) else []
221
+
222
+
223
+ class ActionTaskCorrelationConflictError(ActionHubError):
224
+ """``action_task_correlation_conflict``: the key was used with a different payload."""
225
+
226
+
227
+ class ActionTaskNotFoundError(ActionHubError):
228
+ """``action_task_not_found``."""
229
+
230
+
231
+ class ActionTaskNotOwnedError(ActionHubError):
232
+ """``action_task_not_owned_by_job``: a job reads only the tasks it created."""
233
+
234
+
235
+ class ActionHubRuntimeUnsupportedError(ActionHubError):
236
+ """``action_hub_runtime_unsupported``: the Orchestrator predates Action Hub automation."""
237
+
238
+
239
+ class ActionTaskTimeoutError(ActionHubError):
240
+ """The wait ran out with ``raise_on_timeout=True``. The task stays open."""
241
+
242
+
243
+ class StorageBucketsUnsupportedError(RuntimeApiError):
244
+ """The Orchestrator predates storage buckets (no ``storage-buckets/1`` capability)."""
245
+
246
+
247
+ _AI_ERRORS: Final[dict[str, type[AiError]]] = {
248
+ "ai_budget_exhausted": AiBudgetExhaustedError,
249
+ "ai_policy_denied": AiPolicyDeniedError,
250
+ "ai_provider_unavailable": AiProviderUnavailableError,
251
+ "ai_request_invalid": AiRequestInvalidError,
252
+ "agents_not_enabled": AgentsNotEnabledError,
253
+ "agentic_feature_off": AgentsNotEnabledError,
254
+ }
255
+
256
+ _ACTION_HUB_ERRORS: Final[dict[str, type[ActionHubError]]] = {
257
+ "action_hub_disabled": ActionHubDisabledError,
258
+ "action_app_not_found": ActionAppNotFoundError,
259
+ "action_app_version_not_published": ActionAppNotFoundError,
260
+ "action_task_input_invalid": ActionTaskInputInvalidError,
261
+ "action_task_correlation_conflict": ActionTaskCorrelationConflictError,
262
+ "action_task_not_found": ActionTaskNotFoundError,
263
+ "action_task_not_owned_by_job": ActionTaskNotOwnedError,
264
+ "action_hub_runtime_unsupported": ActionHubRuntimeUnsupportedError,
265
+ }
266
+
267
+
268
+ def runtime_base_url(orchestrator_url: str) -> str:
269
+ """Derive the Runtime API base from the URL the ``start`` context carries.
270
+
271
+ The Runner may pass the site root, the API base or the runtime base; all three appear in
272
+ ``docs/runner-sdk-protocol.md`` section 2 discovery. Normalising here keeps every caller
273
+ from guessing.
274
+ """
275
+ trimmed = orchestrator_url.rstrip("/")
276
+
277
+ if trimmed.endswith(RUNTIME_API_PATH):
278
+ return trimmed
279
+
280
+ if trimmed.endswith("/api/v1"):
281
+ return trimmed + "/runtime"
282
+
283
+ return trimmed + RUNTIME_API_PATH
284
+
285
+
286
+ class RuntimeClient:
287
+ """Synchronous client for the Runtime API.
288
+
289
+ A handler declared ``async def`` should call it through :func:`asyncio.to_thread`: the
290
+ automation owns the process, so a blocking call costs nothing but a thread, and one HTTP
291
+ surface is easier to keep in step with the protocol than two.
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ base_url: str,
297
+ token: str,
298
+ *,
299
+ timeout: httpx.Timeout | float = _DEFAULT_TIMEOUT,
300
+ transport: httpx.BaseTransport | None = None,
301
+ trace_parent: str | None = None,
302
+ ) -> None:
303
+ headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
304
+
305
+ if trace_parent:
306
+ headers["traceparent"] = trace_parent
307
+
308
+ self._capabilities: frozenset[str] | None = None
309
+ self._client = httpx.Client(
310
+ base_url=runtime_base_url(base_url),
311
+ headers=headers,
312
+ timeout=timeout,
313
+ transport=transport,
314
+ )
315
+
316
+ def __enter__(self) -> RuntimeClient:
317
+ return self
318
+
319
+ def __exit__(self, *exception: object) -> None:
320
+ self.close()
321
+
322
+ def close(self) -> None:
323
+ """Release the connection pool."""
324
+ self._client.close()
325
+
326
+ # ---------------------------------------------------------------- context
327
+
328
+ def context(self) -> RuntimeContext:
329
+ """Job, attempt, workspace, automation, entry point, inputs and deadline."""
330
+ return RuntimeContext.model_validate(self._request("GET", "/context"))
331
+
332
+ def cancellation(self) -> CancellationState:
333
+ """Whether a stop was requested for this attempt, and why."""
334
+ return CancellationState.model_validate(self._request("GET", "/cancellation"))
335
+
336
+ # ---------------------------------------------------------------- logs
337
+
338
+ def write_logs(self, entries: list[dict[str, Any]]) -> dict[str, Any]:
339
+ """Send a batch of log entries (at most 500, de-duplicated on sequence)."""
340
+ return self._request("POST", "/logs", body={"entries": entries}) or {}
341
+
342
+ # ---------------------------------------------------------------- assets
343
+
344
+ def asset(self, name: str) -> RuntimeAsset:
345
+ """Read an asset of the job's workspace, including its decrypted secret."""
346
+ return RuntimeAsset.model_validate(self._request("GET", f"/assets/{_segment(name)}"))
347
+
348
+ def set_asset(
349
+ self,
350
+ name: str,
351
+ *,
352
+ text: str | None = None,
353
+ integer: int | None = None,
354
+ boolean: bool | None = None,
355
+ secret: str | None = None,
356
+ user_name: str | None = None,
357
+ password: str | None = None,
358
+ ) -> None:
359
+ """Set the value of an asset the job's workspace owns."""
360
+ body = {
361
+ "text": text,
362
+ "integer": integer,
363
+ "boolean": boolean,
364
+ "secret": secret,
365
+ "userName": user_name,
366
+ "password": password,
367
+ }
368
+
369
+ self._request("PUT", f"/assets/{_segment(name)}", body={k: v for k, v in body.items() if v is not None})
370
+
371
+ def resolve_secret(self, name: str) -> ResolvedSecret:
372
+ """Resolve a secret asset, or ``"<credential>.password"`` for a credential's password."""
373
+ return ResolvedSecret.model_validate(self._request("POST", "/secrets/resolve", body={"name": name}))
374
+
375
+ def credential(self, name: str) -> RuntimeCredential:
376
+ """The account name of a credential asset. The password comes from :meth:`resolve_secret`."""
377
+ return RuntimeCredential.model_validate(self._request("GET", f"/credentials/{_segment(name)}"))
378
+
379
+ # ---------------------------------------------------------------- queues
380
+
381
+ def queue(self, name: str) -> RuntimeQueue:
382
+ """Queue metadata by name: retry budget, unique-reference flag and SLA."""
383
+ return RuntimeQueue.model_validate(self._request("GET", f"/queues/{_segment(name)}"))
384
+
385
+ def add_queue_item(self, queue: str, item: AddQueueItemRequest) -> QueueItem:
386
+ """Add an item to a queue of the job's workspace."""
387
+ body = item.model_dump(by_alias=True, exclude_none=True, mode="json")
388
+ return QueueItem.model_validate(self._request("POST", f"/queues/{_segment(queue)}/items", body=body))
389
+
390
+ def claim_queue_item(self, queue: str) -> QueueItem | None:
391
+ """Claim the next item, or ``None`` when the queue has nothing due."""
392
+ payload = self._request("POST", f"/queues/{_segment(queue)}/items/claim")
393
+ return QueueItem.model_validate(payload) if payload is not None else None
394
+
395
+ def complete_queue_item(self, item_id: UUID | str, output: Any | None = None) -> QueueItem:
396
+ """Mark an item successful."""
397
+ return QueueItem.model_validate(
398
+ self._request("POST", f"/queue-items/{item_id}/complete", body={"output": output})
399
+ )
400
+
401
+ def fail_queue_item(
402
+ self,
403
+ item_id: UUID | str,
404
+ *,
405
+ kind: str,
406
+ reason: str | None = None,
407
+ output: Any | None = None,
408
+ ) -> QueueItem:
409
+ """Mark an item failed. ``kind`` is ``Business`` (final) or ``Application`` (retried)."""
410
+ return QueueItem.model_validate(
411
+ self._request(
412
+ "POST",
413
+ f"/queue-items/{item_id}/fail",
414
+ body={"kind": kind, "reason": reason, "output": output},
415
+ )
416
+ )
417
+
418
+ def extend_queue_item_lease(self, item_id: UUID | str) -> QueueItem:
419
+ """Extend the lease of an item this attempt holds."""
420
+ return QueueItem.model_validate(self._request("POST", f"/queue-items/{item_id}/lease"))
421
+
422
+ def postpone_queue_item(
423
+ self,
424
+ item_id: UUID | str,
425
+ defer_until: datetime,
426
+ deadline: datetime | None = None,
427
+ ) -> QueueItem:
428
+ """Give an item back to the queue until *defer_until*, without spending a retry."""
429
+ body = {"deferUntil": defer_until.isoformat(), "deadline": deadline.isoformat() if deadline else None}
430
+ return QueueItem.model_validate(self._request("POST", f"/queue-items/{item_id}/postpone", body=body))
431
+
432
+ def report_queue_item_progress(self, item_id: UUID | str, progress: str | None) -> QueueItem:
433
+ """Record progress text on an item this attempt holds."""
434
+ return QueueItem.model_validate(
435
+ self._request("PUT", f"/queue-items/{item_id}/progress", body={"progress": progress})
436
+ )
437
+
438
+ # ---------------------------------------------------------------- child jobs
439
+
440
+ def start_job(
441
+ self,
442
+ *,
443
+ automation_id: UUID | str | None = None,
444
+ automation_name: str | None = None,
445
+ inputs: Any | None = None,
446
+ priority: int | None = None,
447
+ ) -> StartedJob:
448
+ """Start a child automation in the parent's own workspace.
449
+
450
+ The chain depth is bounded by the server (``Jobs:MaxChainDepth``); inputs are
451
+ validated against the child's entry point schema, so a bad call is ``422`` rather
452
+ than a job that fails on the robot.
453
+ """
454
+ body = {
455
+ "automationId": str(automation_id) if automation_id else None,
456
+ "automationName": automation_name,
457
+ "inputs": inputs,
458
+ "priority": priority,
459
+ }
460
+
461
+ return StartedJob.model_validate(self._request("POST", "/jobs", body=body))
462
+
463
+ def list_jobs(
464
+ self,
465
+ *,
466
+ automation_name: str | None = None,
467
+ statuses: list[str] | None = None,
468
+ top: int = 50,
469
+ ) -> list[RuntimeJob]:
470
+ """The newest jobs of this workspace (at most 100), optionally of one automation and in some statuses."""
471
+ params = {"top": str(max(1, min(top, 100)))}
472
+
473
+ if automation_name:
474
+ params["automationName"] = automation_name
475
+
476
+ if statuses:
477
+ params["status"] = ",".join(statuses)
478
+
479
+ answer = self._request("GET", "/jobs", params=params)
480
+
481
+ return [RuntimeJob.model_validate(item) for item in answer.get("items") or []]
482
+
483
+ def stop_job(self, job_id: UUID | str, *, kill: bool = False) -> None:
484
+ """Ask a job of this workspace to stop, or with ``kill`` end it at once; a pending job is canceled."""
485
+ self._request("POST", f"/jobs/{_segment(str(job_id))}/stop", body={"strategy": "Kill" if kill else "SoftStop"})
486
+
487
+ def raise_alert(self, message: str, *, severity: str = "Info") -> None:
488
+ """Raise an alert (``Info``, ``Warning`` or ``Error``): a workspace notification tied to this job."""
489
+ self._request("POST", "/alerts", body={"severity": severity, "message": message})
490
+
491
+ # ---------------------------------------------------------------- action tasks
492
+
493
+ def create_action_task(
494
+ self,
495
+ app_key: str,
496
+ version: int,
497
+ input: dict[str, Any] | None = None,
498
+ *,
499
+ correlation_key: str | None = None,
500
+ title: str | None = None,
501
+ assignee_user_id: UUID | str | None = None,
502
+ assignee_group: str | None = None,
503
+ due_at: datetime | None = None,
504
+ priority: str | None = None,
505
+ ) -> UUID:
506
+ """Raise an Action Hub task from ``app_key`` at a published ``version``; returns its id.
507
+
508
+ The input is validated against the version's input schema
509
+ (:class:`ActionTaskInputInvalidError`). With the same ``correlation_key`` and payload a
510
+ second call in this job returns the same task, so a retry never raises two.
511
+ """
512
+ self._ensure_action_tasks()
513
+
514
+ body = {
515
+ "appKey": app_key,
516
+ "version": version,
517
+ "title": title,
518
+ "input": input,
519
+ "correlationKey": correlation_key,
520
+ "assigneeUserId": str(assignee_user_id) if assignee_user_id else None,
521
+ "assigneeGroup": assignee_group,
522
+ "dueAt": due_at.isoformat() if due_at else None,
523
+ "priority": priority,
524
+ }
525
+
526
+ created = self._request("POST", "/action-tasks", body=body)
527
+ return UUID(str(created["taskId"]))
528
+
529
+ def get_action_task(self, task_id: UUID | str) -> RuntimeActionTask:
530
+ """The task's status, and its outcome and output once completed."""
531
+ self._ensure_action_tasks()
532
+ return RuntimeActionTask.model_validate(self._request("GET", f"/action-tasks/{task_id}"))
533
+
534
+ def wait_for_action_task(
535
+ self,
536
+ task_id: UUID | str,
537
+ *,
538
+ timeout: float = 900.0,
539
+ raise_on_timeout: bool = False,
540
+ deadline: datetime | None = None,
541
+ stop_requested: Any | None = None,
542
+ ) -> ActionTaskResult:
543
+ """Wait for the task's decision, one server long poll (at most 25 s) at a time.
544
+
545
+ Returns ``completed`` or ``canceled``; after ``timeout`` seconds (never past
546
+ ``deadline``) the task stays open and the result is ``timedOut``, or
547
+ :class:`ActionTaskTimeoutError` with ``raise_on_timeout=True``. ``stop_requested``, a
548
+ callable, ends the wait within one poll when it turns true. Transient failures are
549
+ retried with a bounded backoff. It always waits in the job: a Python job cannot suspend.
550
+ For waits of hours or days, use the app's continuation.
551
+ """
552
+ self._ensure_action_tasks()
553
+
554
+ answer = self._long_poll(
555
+ f"/action-tasks/{task_id}/wait",
556
+ ("completed", "canceled"),
557
+ _until(timeout, deadline),
558
+ stop_requested,
559
+ lambda error: isinstance(error, ActionHubError),
560
+ )
561
+
562
+ if answer is None:
563
+ if raise_on_timeout:
564
+ raise ActionTaskTimeoutError(0, None, f"Action task {task_id} was not decided in time; it stays open.")
565
+
566
+ return ActionTaskResult(taskId=task_id, status="timedOut")
567
+
568
+ return ActionTaskResult.model_validate(answer)
569
+
570
+ def action_app_schema(self, app_key: str, version: int) -> ActionAppSchema:
571
+ """The input and output JSON Schema of a published app version."""
572
+ self._ensure_action_tasks()
573
+ return ActionAppSchema.model_validate(
574
+ self._request("GET", f"/action-apps/{_segment(app_key)}/versions/{version}/schema")
575
+ )
576
+
577
+ # ---------------------------------------------------------------- agents
578
+
579
+ def agent_candidate(self, agent_key: str, candidate_id: str) -> dict[str, Any]:
580
+ """The immutable candidate an agent run executes: ``{agentKey, candidateId, definition, ...}``."""
581
+ return self._request("GET", f"/agents/{_segment(agent_key)}/candidates/{_segment(candidate_id)}")
582
+
583
+ def write_spans(self, spans: list[dict[str, Any]], trace_id: UUID | str | None = None) -> None:
584
+ """Record trace spans of this job (``traces/spans``)."""
585
+ self._request("POST", "/traces/spans", body={"traceId": str(trace_id) if trace_id else None, "spans": spans})
586
+
587
+ def search_vector_store(self, store: str, query: str, top_k: int | None = None) -> list[dict[str, Any]]:
588
+ """The closest documents of a workspace vector store: ``[{documentId, content, metadata, score}]``."""
589
+ answer = self._request("POST", f"/vector-stores/{_segment(store)}/search", body={"query": query, "topK": top_k})
590
+ return list((answer or {}).get("results") or [])
591
+
592
+ def call_mcp_tool(
593
+ self,
594
+ server: str,
595
+ tool: str,
596
+ payload: Any | None = None,
597
+ *,
598
+ idempotency_key: str | None = None,
599
+ timeout_seconds: int | None = None,
600
+ ) -> dict[str, Any]:
601
+ """Call a tool of a workspace MCP server: ``{success, data, error, statusCode, durationMs}``.
602
+
603
+ The same ``idempotency_key`` replays the first answer instead of calling the server twice.
604
+ """
605
+ return self._request(
606
+ "POST",
607
+ f"/mcp/servers/{_segment(server)}/call",
608
+ body={"toolName": tool, "payload": payload, "timeoutSeconds": timeout_seconds},
609
+ headers={"Idempotency-Key": idempotency_key} if idempotency_key else None,
610
+ )
611
+
612
+ def invoke_tool(
613
+ self, name: str, arguments: dict[str, Any], *, idempotency_key: str | None = None
614
+ ) -> dict[str, Any]:
615
+ """Invoke a workspace tool by name: ``{success, tool, data, error, durationMs}``."""
616
+ return self._request(
617
+ "POST",
618
+ f"/tools/{_segment(name)}/invoke",
619
+ body=arguments,
620
+ headers={"Idempotency-Key": idempotency_key} if idempotency_key else None,
621
+ )
622
+
623
+ def request_tool_approval(
624
+ self,
625
+ *,
626
+ tool_name: str,
627
+ tool_kind: str,
628
+ tool_ref: str,
629
+ arguments: dict[str, Any],
630
+ idempotency_key: str,
631
+ ) -> dict[str, Any]:
632
+ """Ask a person to approve one tool call with its exact arguments: ``{approvalId, status}``.
633
+
634
+ The same ``idempotency_key`` returns the same approval, so a retried request never asks twice.
635
+ """
636
+ return self._request(
637
+ "POST",
638
+ "/agents/tool-approvals",
639
+ body={
640
+ "toolName": tool_name,
641
+ "toolKind": tool_kind,
642
+ "toolRef": tool_ref,
643
+ "arguments": arguments,
644
+ "idempotencyKey": idempotency_key,
645
+ },
646
+ )
647
+
648
+ def wait_for_tool_approval(
649
+ self,
650
+ approval_id: str,
651
+ *,
652
+ timeout: float,
653
+ deadline: datetime | None = None,
654
+ stop_requested: Any | None = None,
655
+ ) -> str:
656
+ """``approved``, ``rejected`` or ``expired``; ``timedOut`` when nobody decided within ``timeout``.
657
+
658
+ A bounded wait in the job, like :meth:`wait_for_action_task`: never past ``deadline``.
659
+ """
660
+ answer = self._long_poll(
661
+ f"/agents/tool-approvals/{_segment(approval_id)}/wait",
662
+ ("approved", "rejected", "expired"),
663
+ _until(timeout, deadline),
664
+ stop_requested,
665
+ lambda error: False,
666
+ )
667
+
668
+ return "timedOut" if answer is None else str(answer["status"])
669
+
670
+ def _long_poll(
671
+ self,
672
+ path: str,
673
+ finished: tuple[str, ...],
674
+ until: float,
675
+ stop_requested: Any | None,
676
+ final: Any,
677
+ ) -> dict[str, Any] | None:
678
+ """Poll ``path?timeoutSeconds=`` until its ``status`` is one of ``finished``; ``None`` at ``until``.
679
+
680
+ Transient failures are retried with a bounded backoff (1, 2, 4, 8, 16 s); a refusal the
681
+ caller calls ``final``, or any 4xx, is raised at once.
682
+ """
683
+ failures = 0
684
+
685
+ while True:
686
+ remaining = until - time.monotonic()
687
+
688
+ if remaining <= 0 or (stop_requested is not None and stop_requested()):
689
+ return None
690
+
691
+ seconds = int(min(remaining, _MAX_WAIT_SECONDS))
692
+
693
+ try:
694
+ answer = self._request("GET", f"{path}?timeoutSeconds={seconds}", timeout=_WAIT_TIMEOUT) or {}
695
+ failures = 0
696
+ except RuntimeApiError as error:
697
+ failures += 1
698
+
699
+ if final(error) or 0 < error.status_code < 500 or failures > _WAIT_RETRIES:
700
+ raise
701
+
702
+ time.sleep(2 ** (failures - 1))
703
+ continue
704
+
705
+ if answer.get("status") in finished:
706
+ return answer
707
+
708
+ # ---------------------------------------------------------------- storage buckets
709
+
710
+ def list_files(
711
+ self,
712
+ bucket: str,
713
+ prefix: str | None = None,
714
+ *,
715
+ pattern: str | None = None,
716
+ recursive: bool = False,
717
+ cursor: str | None = None,
718
+ ) -> StorageFileList:
719
+ """One page of a bucket's files under ``prefix`` (the root when ``None``).
720
+
721
+ ``pattern`` is ``*``/``?`` wildcards on the file name, several separated by ``;``. Pass the
722
+ page's ``next_cursor`` as ``cursor`` for the next page; :meth:`iter_files` does that for you.
723
+ """
724
+ self._ensure_storage()
725
+
726
+ params: dict[str, str] = {"recursive": "true" if recursive else "false"}
727
+
728
+ for name, value in (("prefix", prefix), ("pattern", pattern), ("cursor", cursor)):
729
+ if value:
730
+ params[name] = value
731
+
732
+ return StorageFileList.model_validate(
733
+ self._request("GET", f"/storage-buckets/{_segment(bucket)}/files", params=params)
734
+ )
735
+
736
+ def iter_files(
737
+ self,
738
+ bucket: str,
739
+ prefix: str | None = None,
740
+ *,
741
+ pattern: str | None = None,
742
+ recursive: bool = False,
743
+ ) -> Iterator[StorageFile]:
744
+ """Every file of a listing, page after page."""
745
+ cursor: str | None = None
746
+
747
+ while True:
748
+ page = self.list_files(bucket, prefix, pattern=pattern, recursive=recursive, cursor=cursor)
749
+ yield from page.files
750
+
751
+ if not page.next_cursor:
752
+ return
753
+
754
+ cursor = page.next_cursor
755
+
756
+ def upload_file(
757
+ self,
758
+ bucket: str,
759
+ path: str,
760
+ content: bytes | IO[bytes],
761
+ *,
762
+ content_type: str | None = None,
763
+ ) -> StorageFile:
764
+ """Store ``content`` at ``path`` (a new version when the path exists in an internal bucket)."""
765
+ self._ensure_storage()
766
+
767
+ response = self._send(
768
+ "PUT",
769
+ f"/storage-buckets/{_segment(bucket)}/files/content",
770
+ params={"path": path},
771
+ content=content,
772
+ headers={"Content-Type": content_type or "application/octet-stream"},
773
+ )
774
+
775
+ return StorageFile.model_validate(_json.loads(response.content))
776
+
777
+ def download_file(self, bucket: str, path: str, destination: IO[bytes]) -> None:
778
+ """Copy a file's bytes into ``destination`` (an open binary file or buffer)."""
779
+ self._ensure_storage()
780
+
781
+ url = f"/storage-buckets/{_segment(bucket)}/files/content"
782
+
783
+ try:
784
+ with self._client.stream("GET", url, params={"path": path}, timeout=_FILE_TIMEOUT) as response:
785
+ if not response.is_success:
786
+ response.read()
787
+ _raise_for(response, "GET", url)
788
+
789
+ for chunk in response.iter_bytes():
790
+ destination.write(chunk)
791
+ except httpx.HTTPError as error:
792
+ raise RuntimeApiError(0, None, f"The Runtime API could not be reached: {error}") from error
793
+
794
+ def read_file(self, bucket: str, path: str) -> bytes:
795
+ """A file's bytes."""
796
+ self._ensure_storage()
797
+
798
+ return self._send("GET", f"/storage-buckets/{_segment(bucket)}/files/content", params={"path": path}).content
799
+
800
+ def delete_file(self, bucket: str, path: str) -> None:
801
+ """Delete one file (``storage_file_not_found`` when there is none)."""
802
+ self._ensure_storage()
803
+ self._request("DELETE", f"/storage-buckets/{_segment(bucket)}/files", params={"path": path})
804
+
805
+ def _ensure_storage(self) -> None:
806
+ if STORAGE_BUCKETS_CAPABILITY not in self._capabilities_offered():
807
+ raise StorageBucketsUnsupportedError(
808
+ 501,
809
+ {"code": "storage_buckets_runtime_unsupported"},
810
+ "The Orchestrator does not offer storage buckets "
811
+ f"(runtime capability {STORAGE_BUCKETS_CAPABILITY}); upgrade it.",
812
+ )
813
+
814
+ def _capabilities_offered(self) -> frozenset[str]:
815
+ """The server's runtime capabilities, asked once per client (plan X7)."""
816
+ if self._capabilities is None:
817
+ try:
818
+ capabilities = self._request("GET", "/capabilities") or {}
819
+ self._capabilities = frozenset(capabilities.get("capabilities") or [])
820
+ except RuntimeApiError as error:
821
+ if error.status_code != httpx.codes.NOT_FOUND:
822
+ raise
823
+
824
+ # An Orchestrator that predates the capability route.
825
+ self._capabilities = frozenset()
826
+
827
+ return self._capabilities
828
+
829
+ def _ensure_action_tasks(self) -> None:
830
+ """Checks the server's action-task capability once per client (plan X7)."""
831
+ if ACTION_TASKS_CAPABILITY not in self._capabilities_offered():
832
+ raise ActionHubRuntimeUnsupportedError(
833
+ 501,
834
+ {"code": "action_hub_runtime_unsupported"},
835
+ "The Orchestrator does not offer Action Hub automation (runtime capability "
836
+ f"{ACTION_TASKS_CAPABILITY}); upgrade it to a version in the Action Hub compatibility matrix.",
837
+ )
838
+
839
+ # ---------------------------------------------------------------- plumbing
840
+
841
+ def _request(
842
+ self,
843
+ method: str,
844
+ path: str,
845
+ body: Any | None = None,
846
+ timeout: httpx.Timeout | None = None,
847
+ params: dict[str, str] | None = None,
848
+ headers: dict[str, str] | None = None,
849
+ ) -> Any:
850
+ content = None
851
+ headers = dict(headers or {})
852
+
853
+ if body is not None:
854
+ # The platform writer, not httpx's json=, so payloads escape like every other
855
+ # VeloPhex document and a datetime never sneaks through unformatted.
856
+ content = _json.encode_wire(_strip_none(body))
857
+ headers["Content-Type"] = "application/json"
858
+
859
+ try:
860
+ if timeout is None:
861
+ response = self._client.request(method, path, content=content, headers=headers, params=params)
862
+ else:
863
+ response = self._client.request(
864
+ method, path, content=content, headers=headers, params=params, timeout=timeout
865
+ )
866
+ except httpx.HTTPError as error:
867
+ raise RuntimeApiError(0, None, f"The Runtime API could not be reached: {error}") from error
868
+
869
+ if response.status_code == httpx.codes.NO_CONTENT or not response.content:
870
+ if not response.is_success:
871
+ _raise_for(response, method, path)
872
+
873
+ return None
874
+
875
+ if response.is_success:
876
+ return _json.loads(response.content)
877
+
878
+ _raise_for(response, method, path)
879
+
880
+ def _send(
881
+ self,
882
+ method: str,
883
+ path: str,
884
+ *,
885
+ params: dict[str, str],
886
+ content: bytes | IO[bytes] | None = None,
887
+ headers: dict[str, str] | None = None,
888
+ ) -> httpx.Response:
889
+ """A request whose body or answer is a file's bytes rather than a JSON document."""
890
+ try:
891
+ response = self._client.request(
892
+ method, path, params=params, content=content, headers=headers, timeout=_FILE_TIMEOUT
893
+ )
894
+ except httpx.HTTPError as error:
895
+ raise RuntimeApiError(0, None, f"The Runtime API could not be reached: {error}") from error
896
+
897
+ if not response.is_success:
898
+ _raise_for(response, method, path)
899
+
900
+ return response
901
+
902
+
903
+ def _raise_for(response: httpx.Response, method: str, path: str) -> NoReturn:
904
+ """Raise the typed error for a refused call, from its RFC 9457 problem details."""
905
+ problem = None
906
+
907
+ try:
908
+ parsed = _json.loads(response.content)
909
+ problem = parsed if isinstance(parsed, dict) else None
910
+ except ValueError:
911
+ problem = None
912
+
913
+ detail = (problem or {}).get("detail") or (problem or {}).get("title") or response.reason_phrase
914
+
915
+ raise runtime_error(response.status_code, problem, f"{method} {path} failed with {response.status_code}: {detail}")
916
+
917
+
918
+ def runtime_error(status_code: int, problem: dict[str, Any] | None, message: str) -> RuntimeApiError:
919
+ """The typed error of a refusal: an Action Hub or AI gateway class by its ``code``, else the base."""
920
+ code = (problem or {}).get("code")
921
+ error_type: type[RuntimeApiError] = RuntimeApiError
922
+
923
+ if isinstance(code, str):
924
+ if _is_action_hub(code):
925
+ error_type = _ACTION_HUB_ERRORS.get(code, ActionHubError)
926
+ elif code in _AI_ERRORS:
927
+ error_type = _AI_ERRORS[code]
928
+ elif code.startswith("ai_"):
929
+ error_type = AiError
930
+
931
+ return error_type(status_code, problem, message)
932
+
933
+
934
+ def _until(timeout: float, deadline: datetime | None) -> float:
935
+ """The monotonic instant a wait ends: ``timeout`` from now, and 30 s before ``deadline``."""
936
+ until = time.monotonic() + max(timeout, 0.0)
937
+
938
+ if deadline is not None:
939
+ until = min(until, time.monotonic() + (deadline - datetime.now(UTC)).total_seconds() - 30.0)
940
+
941
+ return until
942
+
943
+
944
+ def _is_action_hub(code: str) -> bool:
945
+ return code.startswith("action_") or code in ("self_approval_forbidden", "continuation_start_failed")
946
+
947
+
948
+ def _strip_none(value: Any) -> Any:
949
+ if isinstance(value, dict):
950
+ return {name: _strip_none(member) for name, member in value.items() if member is not None}
951
+
952
+ return value
953
+
954
+
955
+ def _segment(value: str) -> str:
956
+ """Percent-encode one path segment; asset and queue names may contain spaces."""
957
+ return quote(value, safe="")