Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,87 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Changed

- **Async control-event HTTP client** — `account_service.send_event`, `Account` event helpers, and case event-reporting methods now use `httpx.AsyncClient` and must be awaited in async agents. Explicit `_sync` shims remain for CLI, startup, and synchronous controller methods. See [RFC: async-control-event-http-client](/docs/rfc/async-control-event-http-client.md) for details.

### Tests

`just test`

| Status | Count |
| ----------- | ----- |
| ✅ Passed | 559 |
| 🤔 Skipped | 0 |
| 🔴 Failed | 0 |
| ⏱️ in | |
| ⏱️ with cov | 102s |

### Coverage

| Name | Stmts | Miss | Cover |
| ------------------------------------------------- | ----- | ---- | ----- |
| src/supervaizer/**init**.py | 23 | 3 | 87% |
| src/supervaizer/**version**.py | 3 | 0 | 100% |
| src/supervaizer/access/**init**.py | 4 | 0 | 100% |
| src/supervaizer/access/api_auth.py | 38 | 1 | 97% |
| src/supervaizer/access/client_ip.py | 37 | 7 | 81% |
| src/supervaizer/access/tailscale.py | 23 | 2 | 91% |
| src/supervaizer/account.py | 122 | 15 | 88% |
| src/supervaizer/account_service.py | 80 | 2 | 98% |
| src/supervaizer/admin/routes.py | 549 | 190 | 65% |
| src/supervaizer/admin/workbench_routes.py | 380 | 256 | 33% |
| src/supervaizer/agent.py | 354 | 80 | 77% |
| src/supervaizer/case.py | 267 | 64 | 76% |
| src/supervaizer/cli.py | 210 | 75 | 64% |
| src/supervaizer/common.py | 142 | 4 | 97% |
| src/supervaizer/contracts.py | 168 | 3 | 98% |
| src/supervaizer/data_resource.py | 70 | 0 | 100% |
| src/supervaizer/data_routes.py | 98 | 21 | 79% |
| src/supervaizer/deploy/**init**.py | 2 | 0 | 100% |
| src/supervaizer/deploy/cli.py | 104 | 46 | 56% |
| src/supervaizer/deploy/commands/**init**.py | 2 | 0 | 100% |
| src/supervaizer/deploy/commands/clean.py | 158 | 17 | 89% |
| src/supervaizer/deploy/commands/down.py | 61 | 12 | 80% |
| src/supervaizer/deploy/commands/local.py | 214 | 15 | 93% |
| src/supervaizer/deploy/commands/plan.py | 75 | 7 | 91% |
| src/supervaizer/deploy/commands/status.py | 100 | 41 | 59% |
| src/supervaizer/deploy/commands/up.py | 113 | 8 | 93% |
| src/supervaizer/deploy/docker.py | 186 | 23 | 88% |
| src/supervaizer/deploy/driver_factory.py | 20 | 0 | 100% |
| src/supervaizer/deploy/drivers/**init**.py | 13 | 4 | 69% |
| src/supervaizer/deploy/drivers/aws_app_runner.py | 221 | 157 | 29% |
| src/supervaizer/deploy/drivers/base.py | 82 | 8 | 90% |
| src/supervaizer/deploy/drivers/cloud_run.py | 205 | 125 | 39% |
| src/supervaizer/deploy/drivers/do_app_platform.py | 164 | 125 | 24% |
| src/supervaizer/deploy/health.py | 161 | 12 | 93% |
| src/supervaizer/deploy/state.py | 115 | 9 | 92% |
| src/supervaizer/deploy/utils.py | 24 | 0 | 100% |
| src/supervaizer/event.py | 40 | 1 | 98% |
| src/supervaizer/examples/local_server.py | 17 | 3 | 82% |
| src/supervaizer/instructions.py | 56 | 1 | 98% |
| src/supervaizer/job.py | 166 | 28 | 83% |
| src/supervaizer/job_service.py | 34 | 0 | 100% |
| src/supervaizer/lifecycle.py | 153 | 3 | 98% |
| src/supervaizer/parameter.py | 79 | 7 | 91% |
| src/supervaizer/protocol/**init**.py | 2 | 0 | 100% |
| src/supervaizer/protocol/a2a/**init**.py | 3 | 0 | 100% |
| src/supervaizer/protocol/a2a/model.py | 37 | 5 | 86% |
| src/supervaizer/protocol/a2a/routes.py | 35 | 3 | 91% |
| src/supervaizer/routers/**init**.py | 4 | 0 | 100% |
| src/supervaizer/routers/api.py | 18 | 1 | 94% |
| src/supervaizer/routers/private.py | 10 | 0 | 100% |
| src/supervaizer/routers/public.py | 22 | 5 | 77% |
| src/supervaizer/routes.py | 333 | 60 | 82% |
| src/supervaizer/server.py | 282 | 84 | 70% |
| src/supervaizer/server_utils.py | 25 | 0 | 100% |
| src/supervaizer/storage.py | 160 | 12 | 92% |
| src/supervaizer/telemetry.py | 40 | 0 | 100% |
| src/supervaizer/utils/**init**.py | 2 | 0 | 100% |
| src/supervaizer/utils/version_check.py | 27 | 6 | 78% |
| ------------------------------------------------- | ----- | ---- | ----- |
| TOTAL | 6133 | 1551 | 75% |

## [0.16.0] - 2026-04-25

### Added
Expand Down
137 changes: 137 additions & 0 deletions docs/rfc/002-async-http-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# RFC-002: Async HTTP Client for `account_service.send_event`

**Status:** Proposed
**Date:** 2026-04-27
**Affects:** `supervaizer` SDK (published PyPI package)

---

## Problem

Every call from an agent to the Supervaize Control API — reporting a step, starting or closing a case — goes through `account_service.send_event()`, which issues a synchronous `httpx.Client.post()`. This is a blocking network call.

For agents built on Python's asyncio (FastAPI, Pipecat, etc.), any synchronous blocking call made from within the event loop freezes the entire server for the duration of that call. All concurrent tasks are suspended until it returns.

The SDK's `async def` hooks give the appearance of being async-safe, but they contain no real `await` points. Any agent that calls them from an asyncio context — directly or as a background task — blocks the event loop.

### Call chain (current)

```
Account.send_update_case() # sync
account_service.send_event() # sync
httpx.Client.post() # ← BLOCKS the event loop
```

`Case.update()`, `Account.send_start_case()`, and `Account.send_register_agent()` all follow the same path.

### Consequences for async agents

An agent that calls `report_step()` or `close_case()` from an asyncio background task — even fire-and-forget — blocks the event loop for the full duration of the HTTP round-trip. Observed latencies range from 2s (fast network) to 34s (slow or retried requests). During that window the agent's audio pipeline, WebSocket transport, and all other async work is frozen.

The only workaround available to consumers is to wrap every SDK call in `asyncio.to_thread(...)`, which is error-prone, requires duplicating logic, and must be applied at every call site across the agent's lifecycle.

---

## Proposed Change

Make `account_service.send_event()` async and replace `httpx.Client` with `httpx.AsyncClient`. Cascade the `async`/`await` change up through all SDK methods that call it.

This is a **minor breaking change** for callers that invoke SDK methods from synchronous code (CLI tools, scripts). A sync shim covers those cases — see Backward Compatibility.

### `account_service.py`

**Before:**
```python
_httpx_transport = httpx.HTTPTransport(retries=int(os.getenv("SUPERVAIZE_HTTP_MAX_RETRIES", 2)))
_httpx_client = httpx.Client(transport=_httpx_transport)

def send_event(account, sender, event) -> ApiResult:
...
response = _httpx_client.post(url_event, headers=headers, json=payload)
response.raise_for_status()
...
```

**After:**
```python
_httpx_transport = httpx.AsyncHTTPTransport(retries=int(os.getenv("SUPERVAIZE_HTTP_MAX_RETRIES", 2)))
_httpx_client = httpx.AsyncClient(transport=_httpx_transport)

async def send_event(account, sender, event) -> ApiResult:
...
response = await _httpx_client.post(url_event, headers=headers, json=payload)
response.raise_for_status()
...
```

`httpx.AsyncClient` must remain a module-level singleton to reuse the connection pool. It must be closed gracefully on server shutdown (via a lifespan handler or `atexit`).

### `account.py` — cascade

```python
async def send_update_case(self, case, update) -> ApiResult:
from supervaizer.event import CaseUpdateEvent
event = CaseUpdateEvent(case=case, update=update, account=self)
return await account_service.send_event(update, event)

async def send_start_case(self, case) -> ApiResult:
from supervaizer.event import CaseStartEvent
event = CaseStartEvent(case=case, account=self)
return await account_service.send_event(case, event)

async def send_register_agent(self, agent, polling) -> ApiResult:
from supervaizer.event import AgentRegisterEvent
event = AgentRegisterEvent(agent=agent, account=self, polling=polling)
return await account_service.send_event(agent, event)
```

### `case.py`

`Case.update()` currently calls `self.account.send_update_case(self, ...)` synchronously:

```python
async def update(self, update: CaseNodeUpdate) -> ApiResult:
return await self.account.send_update_case(self, update)
```

---

## Backward Compatibility

Callers that invoke SDK methods from synchronous code (CLI registration, telemetry scripts) will break if they call the async methods directly. Provide a sync shim in `account_service.py`:

```python
def send_event_sync(account, sender, event) -> ApiResult:
"""Sync entry point for environments without a running event loop (CLI, scripts)."""
import asyncio
try:
asyncio.get_running_loop()
# Already inside a loop — run in a thread to avoid deadlock
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, send_event(account, sender, event)).result()
except RuntimeError:
return asyncio.run(send_event(account, sender, event))
```

All existing sync callers (CLI, `send_telemetry`) switch to `send_event_sync`. Async agent callers use `await send_event(...)`.

---

## What Does NOT Change

- The public API contract to the Supervaize Control API (URL, headers, payload shape) is unchanged.
- The outbox retry path runs from a scheduled background job, not from the agent hot path, and can remain sync.
- Error handling, retry configuration (`SUPERVAIZE_HTTP_MAX_RETRIES`), and local-mode short-circuit logic are unchanged.

---

## Acceptance Criteria

1. `account_service.send_event` is `async def` and uses `httpx.AsyncClient`.
2. `Account.send_update_case`, `send_start_case`, `send_register_agent` are `async def`.
3. `Case.update()` is `async def`.
4. `account_service.send_event_sync` provides a safe sync entry point for CLI and script callers.
5. All existing SDK tests pass without modification.
6. New tests verify that `send_event` can be `await`-ed from an async test without blocking.
7. The SDK example agents (FastAPI, Pipecat) register and report steps correctly after the change.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ mypy_path = "src"
disallow_any_expr = false

[tool.bumpversion]
current_version = "0.16.0"
current_version = "0.16.0.dev0"
commit = true
tag = true
tag_name = "v{new_version}"
Expand Down
2 changes: 1 addition & 1 deletion src/supervaizer/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
# https://mozilla.org/MPL/2.0/.


VERSION = "0.16.0"
VERSION = "0.16.0.dev0"
API_VERSION = "v1"
TELEMETRY_VERSION = "v1"
65 changes: 56 additions & 9 deletions src/supervaizer/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def get_url(self, pattern_name: str, **kwargs: Any) -> str:

return pattern.format(**url_params)

def send_event(
async def send_event(
self,
sender: Union["Agent", "Job", "Server", "Case", "CaseNodeUpdate"],
event: "Event",
Expand All @@ -189,9 +189,19 @@ def send_event(
# Import here to avoid circular imports
from supervaizer.account_service import send_event as service_send_event

return await service_send_event(self, sender, event)

def send_event_sync(
self,
sender: Union["Agent", "Job", "Server", "Case", "CaseNodeUpdate"],
event: "Event",
) -> ApiResult:
"""Send an event from sync-only contexts."""
from supervaizer.account_service import send_event_sync as service_send_event

return service_send_event(self, sender, event)

def register_server(self, server: "Server") -> ApiResult:
async def register_server(self, server: "Server") -> ApiResult:
"""Register a server with the Supervaize Control API.

Args:
Expand All @@ -208,7 +218,18 @@ def register_server(self, server: "Server") -> ApiResult:
from supervaizer.event import ServerRegisterEvent

event = ServerRegisterEvent(server=server, account=self)
result = self.send_event(sender=server, event=event)
result = await self.send_event(sender=server, event=event)
return self._log_registration_result(result)

def register_server_sync(self, server: "Server") -> ApiResult:
"""Register a server from sync-only startup code."""
from supervaizer.event import ServerRegisterEvent

event = ServerRegisterEvent(server=server, account=self)
result = self.send_event_sync(sender=server, event=event)
return self._log_registration_result(result)

def _log_registration_result(self, result: ApiResult) -> ApiResult:
if isinstance(result, ApiSuccess):
log.success(result.message)
# TODO: Update server with the server ID from the response. store this ID in env variable.
Expand Down Expand Up @@ -266,7 +287,7 @@ def get_agent_by(
exception=e,
)

def register_agent(self, agent: "Agent", polling: bool = True) -> ApiResult:
async def register_agent(self, agent: "Agent", polling: bool = True) -> ApiResult:
"""Send a registration event to the Supervaize Control API.
This will be used for polling, when the agent is registered without a server.
Args:
Expand All @@ -281,24 +302,50 @@ def register_agent(self, agent: "Agent", polling: bool = True) -> ApiResult:
from supervaizer.event import AgentRegisterEvent

event = AgentRegisterEvent(agent=agent, account=self, polling=polling)
return self.send_event(agent, event)
return await self.send_event(agent, event)

def register_agent_sync(self, agent: "Agent", polling: bool = True) -> ApiResult:
"""Send a registration event from sync-only contexts."""
from supervaizer.event import AgentRegisterEvent

event = AgentRegisterEvent(agent=agent, account=self, polling=polling)
return self.send_event_sync(agent, event)

def send_start_case(self, case: "Case") -> ApiResult:
async def send_start_case(self, case: "Case") -> ApiResult:
# Import here to avoid circular imports
from supervaizer.event import CaseStartEvent

event = CaseStartEvent(case=case, account=self)
return self.send_event(case, event)
return await self.send_event(case, event)

def send_start_case_sync(self, case: "Case") -> ApiResult:
from supervaizer.event import CaseStartEvent

def send_update_case(self, case: "Case", update: "CaseNodeUpdate") -> ApiResult:
event = CaseStartEvent(case=case, account=self)
return self.send_event_sync(case, event)

async def send_update_case(
self, case: "Case", update: "CaseNodeUpdate"
) -> ApiResult:
# Import here to avoid circular imports
log.debug(f"[send_update_case] CaseRef {case} with update {update}")
log.debug(f"[send_update_case] {type(case)}")
log.debug(f"[send_update_case] {type(update)}")
from supervaizer.event import CaseUpdateEvent

event = CaseUpdateEvent(case=case, update=update, account=self)
return self.send_event(update, event)
return await self.send_event(update, event)

def send_update_case_sync(
self, case: "Case", update: "CaseNodeUpdate"
) -> ApiResult:
log.debug(f"[send_update_case] CaseRef {case} with update {update}")
log.debug(f"[send_update_case] {type(case)}")
log.debug(f"[send_update_case] {type(update)}")
from supervaizer.event import CaseUpdateEvent

event = CaseUpdateEvent(case=case, update=update, account=self)
return self.send_event_sync(update, event)

def send_telemetry(self, telemetry: Telemetry) -> ApiResult:
"""Send telemetry data to the Supervaize Control API.
Expand Down
Loading
Loading