coval-agent-sdk 0.1.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,29 @@
1
+ """Connect Python voice agents to Coval over outbound WebSockets."""
2
+
3
+ from coval_agent_sdk.audio import AudioConnection
4
+ from coval_agent_sdk.client import CovalVoiceClient
5
+ from coval_agent_sdk.config import RunConfig
6
+ from coval_agent_sdk.errors import CovalConnectionError
7
+ from coval_agent_sdk.errors import CovalError
8
+ from coval_agent_sdk.errors import CovalHttpError
9
+ from coval_agent_sdk.errors import CovalTraceError
10
+ from coval_agent_sdk.errors import RunError
11
+ from coval_agent_sdk.results import ConversationResult
12
+ from coval_agent_sdk.results import RunResult
13
+ from coval_agent_sdk.results import TraceResult
14
+ from coval_agent_sdk.telemetry import TraceExporter
15
+
16
+ __all__ = [
17
+ "AudioConnection",
18
+ "ConversationResult",
19
+ "CovalConnectionError",
20
+ "CovalError",
21
+ "CovalHttpError",
22
+ "CovalTraceError",
23
+ "CovalVoiceClient",
24
+ "RunConfig",
25
+ "RunError",
26
+ "RunResult",
27
+ "TraceExporter",
28
+ "TraceResult",
29
+ ]
@@ -0,0 +1,101 @@
1
+ """Validate and pace serialized outbound PCM16 audio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import collections
7
+ import time
8
+
9
+ from websockets.asyncio import client
10
+
11
+ from coval_agent_sdk import _defaults
12
+ from coval_agent_sdk import errors
13
+ from coval_agent_sdk import results
14
+
15
+
16
+ def validate_audio(audio: object) -> None:
17
+ """Reject invalid or oversized application audio before any wire write.
18
+
19
+ Args:
20
+ audio: PCM16 little-endian mono 16 kHz bytes.
21
+
22
+ Raises:
23
+ errors.CovalError: Audio is malformed or exceeds the 2 MiB limit.
24
+ """
25
+ if (
26
+ not isinstance(audio, bytes)
27
+ or len(audio) % _defaults.VOICE_SAMPLE_WIDTH_BYTES
28
+ ):
29
+ raise errors.CovalError("Audio must be even-length PCM16 bytes.")
30
+ if len(audio) > _defaults.MAX_SEND_BYTES:
31
+ raise errors.CovalError(
32
+ "Audio exceeds the 2 MiB limit; send smaller chunks."
33
+ )
34
+
35
+
36
+ class PcmWriter:
37
+ """Own output serialization, cross-call pacing, and exact sent counts."""
38
+
39
+ def __init__(self) -> None:
40
+ self._lock = asyncio.Lock()
41
+ self._next_send_at = 0.0
42
+ self.sent_bytes = 0
43
+
44
+ async def send(
45
+ self, websocket: client.ClientConnection, audio: bytes
46
+ ) -> None:
47
+ """Send validated chunks while retaining pacing between callers."""
48
+ async with self._lock:
49
+ for offset in range(0, len(audio), _defaults.VOICE_CHUNK_BYTES):
50
+ chunk = audio[offset : offset + _defaults.VOICE_CHUNK_BYTES]
51
+ await asyncio.sleep(
52
+ max(0.0, self._next_send_at - time.monotonic())
53
+ )
54
+ await websocket.send(chunk)
55
+ self.sent_bytes += len(chunk)
56
+ self._next_send_at = max(
57
+ self._next_send_at, time.monotonic()
58
+ ) + len(chunk) / (
59
+ _defaults.VOICE_SAMPLE_RATE_HZ
60
+ * _defaults.VOICE_CHANNEL_COUNT
61
+ * _defaults.VOICE_SAMPLE_WIDTH_BYTES
62
+ )
63
+
64
+
65
+ class ConnectionState:
66
+ """Private shared transport state; never exported by the public facade."""
67
+
68
+ def __init__(self, simulation_id: str) -> None:
69
+ self.simulation_id = simulation_id
70
+ self.websocket: client.ClientConnection | None = None
71
+ self.writer = PcmWriter()
72
+ self.input: collections.deque[bytes] = collections.deque()
73
+ self.buffered_bytes = 0
74
+ self.input_changed = asyncio.Event()
75
+ self.receiving = False
76
+ self.ready = asyncio.Event()
77
+ self.closed = asyncio.Event()
78
+ self.error: BaseException | None = None
79
+ self.completed = False
80
+ self.received_bytes = 0
81
+ self.result: results.ConversationResult | None = None
82
+
83
+ def feed_audio(self, audio: bytes) -> None:
84
+ """Bound unread audio without blocking terminal control frames."""
85
+ if self.buffered_bytes + len(audio) > _defaults.MAX_RECEIVE_BYTES:
86
+ raise errors.CovalError(
87
+ "Unread audio exceeded the 64 KiB receive buffer; "
88
+ "drain receive_audio() promptly into a bounded pipeline."
89
+ )
90
+ self.received_bytes += len(audio)
91
+ self.buffered_bytes += len(audio)
92
+ if audio:
93
+ self.input.append(audio)
94
+ self.input_changed.set()
95
+
96
+ def finish(self, error: BaseException | None = None) -> None:
97
+ """Wake readers and writers without manufacturing success."""
98
+ if self.error is None:
99
+ self.error = error
100
+ self.closed.set()
101
+ self.input_changed.set()
@@ -0,0 +1,165 @@
1
+ """Discover and claim one-use outbound relay connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import time
8
+ from urllib import parse
9
+
10
+ from websockets import exceptions
11
+ from websockets.asyncio import client
12
+
13
+ from coval_agent_sdk import _defaults
14
+ from coval_agent_sdk import _http
15
+ from coval_agent_sdk import errors
16
+
17
+
18
+ @dataclasses.dataclass(frozen=True)
19
+ class ConnectionOffer:
20
+ """Hold one short-lived, attempt-scoped relay credential.
21
+
22
+ Attributes:
23
+ wss_url: Secure relay WebSocket URL.
24
+ token: One-use credential, sent only in the upgrade header.
25
+ expires_at: Expiry as Unix epoch seconds.
26
+ """
27
+
28
+ wss_url: str
29
+ token: str
30
+ expires_at: float
31
+
32
+
33
+ async def wait_for_offer(
34
+ api: _http.CovalApi, simulation_id: str
35
+ ) -> ConnectionOffer:
36
+ """Wait until the worker publishes a reachable relay and one-use token."""
37
+ deadline = time.monotonic() + _defaults.CONNECT_WAIT_SECONDS
38
+ path = f"simulations/{parse.quote(simulation_id, safe='')}/connect"
39
+ while time.monotonic() < deadline:
40
+ try:
41
+ connection = await api.get(path)
42
+ except (
43
+ errors.CovalHttpError,
44
+ errors.CovalConnectionError,
45
+ ) as exception:
46
+ if (
47
+ isinstance(exception, errors.CovalHttpError)
48
+ and exception.status_code
49
+ not in _defaults.RETRYABLE_HTTP_STATUSES
50
+ ):
51
+ raise
52
+ await asyncio.sleep(_defaults.POLL_INTERVAL_SECONDS)
53
+ continue
54
+ status = connection.get("status")
55
+ if status == "READY":
56
+ url = connection.get("wss_url")
57
+ token = connection.get("token")
58
+ expires_at = connection.get("expires_at")
59
+ if not isinstance(url, str) or not url.startswith("wss://"):
60
+ raise errors.CovalError(
61
+ f"Simulation {simulation_id} returned "
62
+ f"READY without a WSS URL."
63
+ )
64
+ if not isinstance(token, str) or not token:
65
+ raise errors.CovalError(
66
+ f"Simulation {simulation_id} returned "
67
+ f"READY without a token."
68
+ )
69
+ if (
70
+ not isinstance(expires_at, (int, float))
71
+ or expires_at <= time.time()
72
+ ):
73
+ raise errors.CovalError(
74
+ f"Simulation {simulation_id} returned an "
75
+ f"expired connection offer."
76
+ )
77
+ return ConnectionOffer(
78
+ wss_url=url, token=token, expires_at=float(expires_at)
79
+ )
80
+ if status in _defaults.TERMINAL_CONNECTION_STATUSES:
81
+ raise errors.CovalError(
82
+ f"Simulation {simulation_id} ended before connecting: {status}."
83
+ )
84
+ # An unattached attempt can expire while the worker prepares its
85
+ # replacement.
86
+ if status not in {"PENDING", "PROVISIONING", "EXPIRED"}:
87
+ raise errors.CovalError(
88
+ f"Simulation {simulation_id} returned "
89
+ f"unknown connection status {status!r}."
90
+ )
91
+ await asyncio.sleep(_defaults.POLL_INTERVAL_SECONDS)
92
+ raise errors.CovalError(
93
+ f"Simulation {simulation_id} did not "
94
+ f"publish a relay connection within "
95
+ f"{_defaults.CONNECT_WAIT_SECONDS} "
96
+ f"seconds."
97
+ )
98
+
99
+
100
+ async def open_voice_connection(
101
+ api: _http.CovalApi, simulation_id: str
102
+ ) -> client.ClientConnection:
103
+ """Retry a live offer through transient WebSocket handshake failures."""
104
+ deadline = time.monotonic() + _defaults.CONNECT_WAIT_SECONDS
105
+ retry_delay_seconds = _defaults.POLL_INTERVAL_SECONDS
106
+ last_error: Exception | None = None
107
+ while time.monotonic() < deadline:
108
+ remaining_seconds = deadline - time.monotonic()
109
+ try:
110
+ offer = await asyncio.wait_for(
111
+ wait_for_offer(api, simulation_id), timeout=remaining_seconds
112
+ )
113
+ except asyncio.TimeoutError as exception:
114
+ raise errors.CovalError(
115
+ f"Simulation {simulation_id} did not "
116
+ f"offer a usable relay before its "
117
+ f"connect deadline."
118
+ ) from exception
119
+ while time.time() < offer.expires_at and time.monotonic() < deadline:
120
+ upgrade_timeout = min(
121
+ _defaults.HTTP_TIMEOUT_SECONDS,
122
+ offer.expires_at - time.time(),
123
+ deadline - time.monotonic(),
124
+ )
125
+ if upgrade_timeout <= 0:
126
+ break
127
+ try:
128
+ return await client.connect(
129
+ offer.wss_url,
130
+ additional_headers={
131
+ "Authorization": f"Bearer {offer.token}"
132
+ },
133
+ open_timeout=upgrade_timeout,
134
+ ping_interval=_defaults.WEBSOCKET_PING_INTERVAL_SECONDS,
135
+ ping_timeout=_defaults.WEBSOCKET_PING_TIMEOUT_SECONDS,
136
+ max_size=_defaults.VOICE_SAMPLE_RATE_HZ
137
+ * _defaults.VOICE_SAMPLE_WIDTH_BYTES
138
+ * 10,
139
+ )
140
+ except (
141
+ EOFError,
142
+ OSError,
143
+ asyncio.TimeoutError,
144
+ exceptions.WebSocketException,
145
+ ) as exception:
146
+ if client.process_exception(exception) is not None:
147
+ raise errors.CovalError(
148
+ f"Simulation {simulation_id} relay upgrade failed."
149
+ ) from exception
150
+ last_error = exception
151
+ remaining_seconds = min(
152
+ offer.expires_at - time.time(), deadline - time.monotonic()
153
+ )
154
+ if remaining_seconds <= 0:
155
+ break
156
+ await asyncio.sleep(min(retry_delay_seconds, remaining_seconds))
157
+ retry_delay_seconds = min(
158
+ retry_delay_seconds * 2, _defaults.CONNECT_RETRY_MAX_SECONDS
159
+ )
160
+ retry_delay_seconds = _defaults.POLL_INTERVAL_SECONDS
161
+ raise errors.CovalError(
162
+ f"Simulation {simulation_id} could not "
163
+ f"connect to its voice relay "
164
+ f"before the deadline."
165
+ ) from last_error
@@ -0,0 +1,33 @@
1
+ """Internal API, relay, audio, and buffering defaults."""
2
+
3
+ DEFAULT_API_BASE_URL = "https://api.coval.dev/v1"
4
+ HTTP_TIMEOUT_SECONDS = 15
5
+ POLL_INTERVAL_SECONDS = 2
6
+ CONNECT_WAIT_SECONDS = 300
7
+ CONNECT_RETRY_MAX_SECONDS = 8
8
+ RUN_WAIT_SECONDS = 3_600
9
+ MONITORING_RETRY_SECONDS = 30
10
+ WEBSOCKET_PING_INTERVAL_SECONDS = 20
11
+ WEBSOCKET_PING_TIMEOUT_SECONDS = 20
12
+ VOICE_SAMPLE_RATE_HZ = 16_000
13
+ VOICE_CHANNEL_COUNT = 1
14
+ VOICE_SAMPLE_WIDTH_BYTES = 2
15
+ VOICE_CHUNK_DURATION_SECONDS = 0.02
16
+ VOICE_CHUNK_BYTES = int(
17
+ VOICE_SAMPLE_RATE_HZ
18
+ * VOICE_CHANNEL_COUNT
19
+ * VOICE_SAMPLE_WIDTH_BYTES
20
+ * VOICE_CHUNK_DURATION_SECONDS
21
+ )
22
+ MAX_SEND_BYTES = 2 * 1024 * 1024
23
+ MAX_RECEIVE_BYTES = 64 * 1024
24
+ CLOSE_CONFIRM_TIMEOUT_SECONDS = 10
25
+ TERMINAL_CONNECTION_STATUSES = frozenset(
26
+ {"CONNECTED", "CLOSED", "FAILED", "CANCELLED"}
27
+ )
28
+ TERMINAL_RUN_STATUSES = frozenset({"COMPLETED", "FAILED", "CANCELLED"})
29
+ TERMINAL_SIMULATION_STATUSES = TERMINAL_RUN_STATUSES | {"SKIPPED", "DELETED"}
30
+ QUEUED_SIMULATION_STATUSES = frozenset({"PENDING", "IN QUEUE"})
31
+ RETRYABLE_HTTP_STATUSES = frozenset({409, 429, 500, 502, 503, 504})
32
+
33
+ TRACE_FLUSH_TIMEOUT_MILLISECONDS = 5_000
@@ -0,0 +1,15 @@
1
+ """Preserve primary exceptions while retaining secondary cleanup diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def add_cleanup_note(exception: BaseException, note: str) -> None:
7
+ """Attach secondary cleanup context where the runtime supports notes.
8
+
9
+ Args:
10
+ exception: Primary failure whose identity must be preserved.
11
+ note: Diagnostic describing a secondary cleanup failure.
12
+ """
13
+ add_note = getattr(exception, "add_note", None)
14
+ if callable(add_note):
15
+ add_note(note)
@@ -0,0 +1,196 @@
1
+ """Private Coval API SDK transport with JSON and protobuf request support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+ from typing import cast
9
+ from urllib import parse
10
+
11
+ from coval_sdk import api_client
12
+ from coval_sdk import configuration
13
+ from coval_sdk import exceptions
14
+ from urllib3 import exceptions as http_exceptions
15
+
16
+ from coval_agent_sdk import _defaults
17
+ from coval_agent_sdk import errors
18
+
19
+
20
+ class CovalApi:
21
+ """Private adapter around the Coval API SDK request client."""
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ api_base_url: str,
27
+ api_key: str,
28
+ workspace_id: str | None = None,
29
+ ) -> None:
30
+ """Configure the API root and authentication headers.
31
+
32
+ Args:
33
+ api_base_url: API root including the version path.
34
+ api_key: Organization API key.
35
+ workspace_id: Optional workspace selector.
36
+ """
37
+ if parse.urlsplit(api_base_url).scheme not in {"http", "https"}:
38
+ raise ValueError("api_base_url must use http or https.")
39
+ self.api_base_url = api_base_url.rstrip("/")
40
+ self.api_key = api_key
41
+ self.workspace_id = workspace_id
42
+
43
+ async def get(
44
+ self, path: str, *, query: dict[str, str] | None = None
45
+ ) -> dict[str, Any]:
46
+ """Fetch an object resource without blocking the audio event loop.
47
+
48
+ Args:
49
+ path: Resource path relative to the configured API root.
50
+ query: Optional URL query parameters.
51
+
52
+ Returns:
53
+ The decoded JSON object.
54
+
55
+ Raises:
56
+ errors.CovalError: The request fails or returns non-object JSON.
57
+ """
58
+ return await asyncio.to_thread(
59
+ self._request_json, "GET", path, query, None
60
+ )
61
+
62
+ async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
63
+ """Post JSON once without automatically retrying mutations.
64
+
65
+ Args:
66
+ path: Resource path relative to the configured API root.
67
+ payload: JSON object to send.
68
+
69
+ Returns:
70
+ The decoded JSON response object.
71
+
72
+ Raises:
73
+ errors.CovalError: The request fails or returns non-object JSON.
74
+ """
75
+ return await asyncio.to_thread(
76
+ self._request_json, "POST", path, None, payload
77
+ )
78
+
79
+ def post_protobuf(
80
+ self, path: str, payload: bytes, *, headers: dict[str, str]
81
+ ) -> None:
82
+ """Post protobuf once from a worker thread, releasing request resources.
83
+
84
+ Args:
85
+ path: Resource path relative to the configured API root.
86
+ payload: Encoded protobuf request body.
87
+ headers: Additional endpoint-specific headers.
88
+
89
+ Raises:
90
+ errors.CovalError: The API rejects or cannot receive the request.
91
+ """
92
+ self._request(
93
+ "POST",
94
+ path,
95
+ None,
96
+ payload,
97
+ content_type="application/x-protobuf",
98
+ headers=headers,
99
+ )
100
+
101
+ def _request_json(
102
+ self,
103
+ method: str,
104
+ path: str,
105
+ query: dict[str, str] | None,
106
+ payload: dict[str, Any] | None,
107
+ ) -> dict[str, Any]:
108
+ body = self._request(method, path, query, payload)
109
+ try:
110
+ response_payload: object = json.loads(body)
111
+ except (ValueError, UnicodeError):
112
+ raise errors.CovalError(
113
+ f"Coval {method} {path} returned invalid JSON."
114
+ ) from None
115
+ if not isinstance(response_payload, dict):
116
+ raise errors.CovalError(
117
+ f"Coval {method} {path} returned a non-object JSON response."
118
+ )
119
+ return cast(dict[str, Any], response_payload)
120
+
121
+ def _request(
122
+ self,
123
+ method: str,
124
+ path: str,
125
+ query: dict[str, str] | None,
126
+ payload: dict[str, Any] | bytes | None,
127
+ *,
128
+ content_type: str = "application/json",
129
+ headers: dict[str, str] | None = None,
130
+ ) -> bytes:
131
+ settings = configuration.Configuration(
132
+ host=self.api_base_url,
133
+ api_key={"Coval_Agents_API_ApiKeyAuth": self.api_key},
134
+ retries=0,
135
+ )
136
+ # The generated client leaves request/response members untyped. Keep
137
+ # that dynamic boundary here and validate JSON before returning it.
138
+ sdk: Any = api_client.ApiClient(settings)
139
+ request_headers = dict(headers or {})
140
+ request_headers.update(
141
+ {
142
+ "Accept": "application/json",
143
+ "Content-Type": content_type,
144
+ }
145
+ )
146
+ if self.workspace_id is not None:
147
+ request_headers["X-Coval-Workspace-Id"] = self.workspace_id
148
+ try:
149
+ # Discovery is not in the generated endpoint set. Keep request
150
+ # serialization and transport in the API SDK; models stay private.
151
+ parameters = sdk.param_serialize(
152
+ method=method,
153
+ resource_path=f"/{path.lstrip('/')}",
154
+ query_params=list((query or {}).items()),
155
+ header_params=request_headers,
156
+ body=payload,
157
+ auth_settings=["Coval_Agents_API_ApiKeyAuth"],
158
+ )
159
+ response = sdk.call_api(
160
+ *parameters,
161
+ _request_timeout=_defaults.HTTP_TIMEOUT_SECONDS,
162
+ )
163
+ try:
164
+ body = response.read()
165
+ if not 200 <= response.status <= 299:
166
+ detail = body[:500].decode("utf-8", errors="replace")
167
+ raise errors.CovalHttpError(
168
+ status_code=response.status,
169
+ message=(
170
+ f"Coval {method} {path} returned HTTP "
171
+ f"{response.status}: {detail}"
172
+ ),
173
+ )
174
+ return cast(bytes, body)
175
+ finally:
176
+ response.response.close()
177
+ response.response.release_conn()
178
+ except exceptions.ApiException as exception:
179
+ status: object = cast(Any, exception).status
180
+ if isinstance(status, int) and status != 0:
181
+ raise errors.CovalHttpError(
182
+ status_code=status,
183
+ message=(f"Coval {method} {path} returned HTTP {status}."),
184
+ ) from None
185
+ raise errors.CovalConnectionError(
186
+ f"Coval {method} {path} could not reach the API."
187
+ ) from None
188
+ except (http_exceptions.HTTPError, OSError) as exception:
189
+ raise errors.CovalConnectionError(
190
+ f"Coval {method} {path} could not reach the API: "
191
+ f"{type(exception).__name__}."
192
+ ) from None
193
+ finally:
194
+ # A cancelled coroutine cannot stop its blocking worker. The worker
195
+ # retains ownership and releases its pool after the bounded request.
196
+ sdk.rest_client.pool_manager.clear()