Parity with the Python SDK

Copy Markdown View Source

Ground truth is the Python guava-sdk (v0.36.0). This Elixir port keeps the same concepts and wire protocol but adapts the public API to idiomatic Elixir (there are no users, so this was a deliberate design choice, not a constraint).

Mapping

PythonElixirNotes
guava.ClientGuava.Clientnew/1/new!/1; all ops return {:ok, _} | {:error, %Guava.Error{}} with ! bang variants
guava.Agent (decorators)Guava.Agent behaviouruse Guava.Agent; implement @impl callbacks; per-call threaded state
guava.CallGuava.Callhandle passed to callbacks; imperative actions (set_task, transfer, get_field, …); reads are ETS-backed
guava.RunnerGuava.Channel + Guava.run/1supervised channel child specs; blocking helpers on Guava
decorators (on_question, on_action, …)callbacks (handle_question/3, handle_action/3, …)per-key handlers are one pattern-matched callback
Agent.test / test_roleplayGuava.Testing.session/3 / roleplay/3module-based
guava.testing.MockCallGuava.Testing.MockCalloffline handler unit tests; a %Guava.Call{} wired to a recorder process + seeded ETS, not a Call subclass
guava.Field/Say/TodoGuava.Field/Guava.Say/Guava.Todo
CallInfo, IncomingCallAction, SuggestedActionsame under Guava.*handle_call_received returns bare :accept/:decline
events / commandsGuava.Events.* / Guava.Commands.*byte-identical wire format
GuavaSocketGuava.Socket + Guava.Socket.Reliable (pure state machine) + Conn + Protocolunchanged from the first port
campaigns.pyGuava.Campaigns / Campaign / Contacttuple + bang; every function takes client first, and covers Client.get_campaign / Client.list_campaigns too
helpers/llm.pyGuava.LLM, Guava.IntentRecognizer, Guava.DatetimeFilter, Guava.DateRangeParsertuple + bang
helpers/rag.pyGuava.RAG, Guava.RAG.ServerRAG, Guava.DocumentQA (+ behaviours)ask tuple + bang; server mode default
auth.py (AuthStrategy)Guava.Auth.{APIKey,Deploy,CLI}+ config :guava resolution
telemetry.py (usage upload)not ported; :telemetry spans instead

Intentional deviations

API shape and conventions

  • Agent is a behaviour with threaded state. The Python Agent registers callbacks on a mutable object; the Elixir Guava.Agent is a use-able behaviour whose callbacks thread a per-call state (like GenServer/LiveView). This is compile-time-checked, needs no external per-call store, and lets you implement only the callbacks you use.

  • Errors are {:ok, _} | {:error, %Guava.Error{}} with ! variants across the Client, campaigns, and LLM/RAG helpers — the Elixir norm. ArgumentError is used instead for caller bugs (e.g. passing a campaign code where an id is required), which should raise even from the tuple-returning variant — as File.read/1 does for a non-binary path.

  • Campaigns are identified by code only; the legacy id is not public. Per the platform team, the campaign code is the identifier going forward and the id exists for backwards compatibility, so no Guava.Campaigns function accepts one — each takes a code or a %Guava.Campaign{}. The struct still carries :id (documented as internal) because a few endpoints remain keyed on it; given a bare code the SDK resolves it with one extra lookup. Both the field and the lookup delete cleanly once those endpoints move to v2.

  • Campaigns take the client explicitly, not embedded in the handle. Upstream's Campaign holds a _client; here %Guava.Campaign{} stays plain data and every function takes client first, matching Ecto/Req/Tesla. Single-field upstream result models (UploadContactsResult, CampaignStatus) are unwrapped to a bare integer and a plain map, as Client.create_outbound! already returns a bare call id. ReachPersonOutcome is likewise a plain map (:key, :description, :next_action_preview) rather than a struct — it is data the caller supplies, and all three fields behave as upstream's do.

  • Callbacks run serially per call, reads are ETS-backed. Guarantees state can't race and lets a handler read fields without deadlocking. Long work is offloaded via a Task + handle_info/3.

  • Config via Application env (config :guava, api_key:/base_url:) in addition to env vars.

  • :telemetry spans ([:guava, :http, :request, …], [:guava, :command, :sent]) for standard observability.

  • Vendor usage reporting is not ported. Upstream's telemetry.py decorates Agent/Client/Call/Campaigns with track_class, queueing a method-call or exception-raised event per public method and uploading batches to v1/upload-telemetry. Reproducing that feed in Elixir means either macro-wrapping every public function — invasive, and fragile as the surface grows — or inventing event shapes for an analytics endpoint whose contract lives on the server. The port emits :telemetry spans instead and leaves the decision to the host, which is the Elixir norm. A faithful uploader existed here (Guava.Usage: same envelope, same 100-event queue, same 10s interval) but nothing ever fed it, so opting in silently uploaded nothing; it was removed in favour of documenting the gap. Add it back deliberately if the data is wanted, alongside the backend contract.

  • Socket errors fold into Guava.Error — upstream's GuavaSocketClosedError / GuavaSocketConnectionFailed are :closed and :transport error types rather than separate exception structs.

Transport, retry and reconnect

  • Transient GET failures are retried; upstream retries nothing. The HTTP layer passes retry: :safe_transient, so safe methods retry with backoff and honour Retry-After. The SMS polling loop opts out with retry: false — it is already a retry loop. httpx has no equivalent. Both phases of the per-attempt budget match httpx's Timeout(5.0), which covers connect and read alike: @receive_timeout 5_000 bounds the wait for response data and @connect_timeout 5_000 the TCP/TLS connect, the latter because Mint's default is 30_000. So four attempts cost up to ~27s against upstream's 5s. RAG and LLM calls pass their own longer timeouts, exactly where upstream passes an explicit one.

    A Retry-After value Req cannot parse — 0.5, or anything non-numeric — used to raise out of its retry step, and the rescue in request!/4 turned that into %Guava.Error{type: :transport}, hiding the real status and body. A response step prepended ahead of Req's own drops such a header, so backoff falls back to the exponential schedule and the caller sees the actual status. Upstream ignores Retry-After entirely and raises with the status, so this only differs in still honouring well-formed values.

  • A stray open-ack outside the handshake is dropped, not fatal. Upstream leaves GuavaOpenAck out of its mid-session frame union, so the validation error kills the socket permanently. Ignoring it is deliberately gentler: that outcome is a side effect of how upstream validates rather than a designed policy, and tearing down a socket carrying a live call because one duplicated frame arrived is the worse failure. What the port refuses to do is act on it — re-running the open path mid-session retransmitted past a stale sequence, re-emitted readiness, and cleared the reconnect budget, which let a server looping acks mask a failing connection indefinitely. Symmetrically, a message frame arriving before the open-ack is still delivered, where upstream's single-frame handshake read rejects it; both cases require a non-conforming server.

  • A mid-session drop costs one unit of the reconnect budget — 9 attempts where upstream gets 10, and the 5s backoff tier one attempt earlier. Upstream re-initialises its attempt counter on every entry to _establish_socket, so an established connection dropping is free. An off-by-one in a retry budget, on a port that also retries at the channel level.

  • The open-rate circuit breaker trips when reconnecting, not on a successful open. Upstream checks _open_counter.count() >= 15 immediately after establishing, so a 15th connection that is healthy and stays up is killed anyway. Here the check sits in the reconnect path — it fires only when the client is about to open again, which is when a flapping loop is actually still running. The breaker exists to stop a client hammering a bad server; a settled connection means the loop ended.

  • Sockets close abruptly rather than sending a close frame. Upstream passes close_timeout=10 and completes the WebSocket close handshake (peers see 1000); teardown here exits the connection process, so peers see 1006. Visible only in server-side connection metrics, and avoiding a second teardown path is worth more than the cosmetic difference.

  • The opening handshake gets upstream's 10s as a real overall deadline, enforced by Guava.Socket rather than the transport. ws_connect(open_timeout=10) is a single budget over TCP + TLS + request + response. WebSockex offers only per-step timeouts and re-arms socket_recv_timeout on every recv, and open_connection/3 waits on its handshake task with no timeout at all — so a peer trickling upgrade bytes could extend a connect without bound. It used to do that while Guava.Socket was blocked inside connect/1, so the socket counted no failure, never escalated backoff, never reported "reconnection-failed", and could not service :client_close — meaning Guava.drain/1 was silently ignored against such a peer. The connect now runs in a short-lived linked helper with a 10s deadline: on overrun the helper is killed and the link takes any half-open transport with it, and on success its :normal exit does not propagate, so the established connection survives and the socket adopts it on {:conn_up, pid}. The link never reaches the socket, so a later transport crash still cannot take it down.

  • A socket that gives up is retried at the channel level, and what happens next depends on how fast it fails. Upstream raises GuavaSocketConnectionFailed and its listen loop exits, ending the process. Here the worker stops and Guava.Channel's supervisor rebuilds it, and there are two regimes worth knowing:

    • An unreachable peer is retried indefinitely. The socket spends its full attempt budget first (~53s), so worker restarts land far outside the supervisor's 5s intensity window and never exhaust it. A node self-heals whenever the server returns.
    • A peer that closes immediately on connect produces sub-second restart cycles, exhausts the window, and the failure propagates to whatever supervises Guava.Channel. That surfaces a permanent misconfiguration rather than hiding it.

    Backoff escalates within each socket's life. A peer-initiated close frame is treated as reconnectable here, where upstream treats an explicit peer close as terminal.

  • A dead peer is detected by read inactivity, not a protocol-level keepalive. Upstream leaves the websockets keepalive at its defaults (ping_interval/ping_timeout of 20s each), which fails the connection on a missing pong. Here Guava.Socket sends its application-level %Ping{} on an idle timer and treats a second idle interval with nothing inbound as a dead peer, reconnecting. Detection lands in ~20s against upstream's ~40s, and unlike upstream it costs one unit of the reconnect budget — consistent with how a mid-session drop is already charged here.

  • Auth headers are recomputed on every connection attempt, matching upstream's _get_headers() call inside its retry loop, so a reconnect picks up a refreshed CLI access token or a rotated deploy token file.

Call and channel lifecycle

  • Guava.Channel resolves its mode inside the supervisor's init/1, which means the campaign lookup (and WebRTC code creation) is HTTP done at start-up. A transient network failure there fails the child spec rather than just that channel. Kept deliberately: it fails fast on a bad campaign code exactly as the adjacent Client.new!() fails fast on missing credentials.

  • A command issued from a process other than the call's is not ordered against commands issued inside a handler. Handler commands are written directly (so they keep their order relative to the runtime's own replies); a command cast from a spawned Task is queued and may follow them. Cross-process emission ordering was never guaranteed, and restoring it would mean an ordering queue. A command cast during the terminal event's handler is dropped rather than merely reordered: the runtime stops when that handler returns, and the queued cast dies with the mailbox. Upstream's separate drain thread still sends it, because _shutdown_queue() only runs after the dispatch loop breaks. Flushing them would mean selectively receiving GenServer's internal {:"$gen_cast", …} shape out of the mailbox before stopping — reaching into another module's private representation for work the call has already finished with.

  • An on_action handler's return value is not turned into a follow-up instruction. Upstream interpolates a truthy return into send_instruction("Responding to action execution <key>: <response>"). handle_action/3 returns {:noreply, state}; call Guava.Call.send_instruction/2 yourself when you want that, which is explicit rather than a templated string.

  • The call socket opens before handle_start/2 finishes. Upstream runs on_call_start before opening, which it can do because its commands go through a queue drained by a background thread. Here emission is synchronous and Guava.Socket already buffers frames sent before the handshake, so the difference is not observable.

  • A raising handle_validate/4 is treated as passing validation, where upstream lets the exception end the call. Consistent with keeping a call alive through handler bugs, but note the consequence: a validator that crashes accepts the field it was meant to reject.

  • A raising handle_start/2 leaves the call running half-initialised, where upstream never connects the call socket at all. Same trade — the caller is already on the line, so the port would rather run a degraded call than drop one.

  • Events already off the wire are dispatched after a close frame, where upstream discards whatever it had not yet consumed. Deterministic here by design.

  • reach_person carries no _voicemail_handler variable. Upstream sets it there and reads it in set_voicemail_action to detect a conflict between the two across a call. This port has no set_voicemail_action — voicemail handling is folded into reach_person's :voicemail_message / :voicemail_hangup options, which raise ArgumentError when combined — so with one entry point there is no cross-call conflict to coordinate and the variable would be bookkeeping nothing reads.

  • Call-state reads after the call has ended return the default, not the collected value. The per-call ETS table is owned by the call runtime and dies with it, so a Task a handler spawned that reads Guava.Call.get_field/3 afterwards sees nil rather than raising — and rather than the value, which is what upstream's in-memory Call would give. The failure is silent, so read call state while the call is live and send results back via handle_info/3. Writes after the call has ended raise instead: the asymmetry is deliberate, since a read has a sensible fallback and a write cannot be honoured at all.

  • A raising handle_call_received/1 declines the call. Upstream logs the exception and answers nothing, leaving the caller on a call it already claimed until the server times out. Declining tells the server immediately so it can route elsewhere. Either way the listener stays up.

  • The Guava.Testing transcript orders agent speech by arrival, upstream by consumption. Affects the transcript's ordering only.

Not ported

  • Legacy CallController not ported — deprecated in Python; Guava.Agent supersedes it (maintainers confirmed out of scope).

  • call_local / curses chat / webrtc_helper omitted — platform-specific local-dev tools. webrtc_helper in particular downloads a per-OS/arch binary (ManifestEntry, sha256-pinned) and runs it as a subprocess. Use Guava.Testing.

  • Edge / on-device wake features omittededge_wake.py, on_wakeword / on_wake / on_press_enter, listen_for_wake, and the bundled wakeword ONNX models are gated behind GUAVA_EDGE and marked "unavailable for public use" upstream. Same class as the local-dev tools above; no wire impact.

  • Health-check HTTP server omitted, but readiness and drain are provided (HealthServer, HealthContext, MultiHealthContext). What's dropped is specifically the listener: Plug/Bandit are test-only deps here, and starting an HTTP server inside the host's supervision tree isn't the Elixir norm when the host already has an endpoint. The state upstream's health.py tracks is exposed as functions instead — Guava.ready?/0 for a readiness probe you mount yourself, and Guava.drain/1, which runs automatically on shutdown. The four upstream states are derivable from channel status, so they aren't modelled explicitly. Elixir's drain goes further than upstream's HealthContext.draining(), which only flips a flag: it actually waits for in-flight calls, generalizing what upstream's campaign loop does for Ctrl-C. Liveness has no equivalent and needs none — terminal failure on the BEAM exits the VM, which an orchestrator already detects.

    Guava.ready?/0 reflects intake channels only: listen and campaign channels register, outbound ones don't. This matches upstream, where Runner builds a HealthContext for each listener but call_phone builds none, so placing a call can't affect /ready. It also has to be this way here — every channel used to register as :connecting, so one outbound call turned the whole node unready for the duration of its create-outbound request and a readiness probe would pull a pod whose listener was perfectly healthy. Shutdown is unaffected: drain/1 waits on Guava.CallRegistry, so an outbound call in progress is still awaited.

    One narrower gap remains, and it follows from where channels live. A channel becomes visible to ready?/0 only once its worker starts, which is after the Guava.Channel supervisor has resolved a campaign code over HTTP. During that window the channel is invisible rather than not-ready, so a node with one ready listener and one still starting reports ready, where upstream builds every HealthContext at registration time in state "starting" and answers 503.

    Upstream can do that because Runner holds the list of channels it is about to start. Here channels are children of the host's supervision tree, so the SDK has no record of intended-but-not-yet-started ones — which is the point of that design, but it means readiness can only ever reflect channels that exist. A placeholder registration by the supervisor is not available either: Registry.unregister/2 only affects the calling process's keys, so nothing could retract it once the worker registered. Resolving the mode inside the worker would close the window at the cost of turning a typo'd campaign code from an immediate start_link/2 error into a :permanent restart loop. The zero-channel case is already handled, so this is confined to partial startup on a multi-channel node.

  • Upstream internals with no public counterpart. CommandQueueEnd is a sentinel CallController pushes to unblock its command queue; the port has no such queue, since a GenServer mailbox closes when the process stops. InboundTunnelCommand and InboundTunnelEvent are defined but never constructed or dispatched anywhere upstream, so there is no behaviour to match — they are listed here only so a parity census doesn't keep surfacing them.

  • Local RAG vector-store backends are expressed as the Guava.RAG.VectorStore/GenerationModel behaviours; server-mode RAG is fully ported.

Keeping in sync with the Python SDK

The Python version this port currently matches is recorded in .upstream-sync.json. Two Claude skills (in .claude/skills/) drive the update workflow:

  1. Report — run check-upstream-parity. It diffs the tracked version against the latest guava-sdk on PyPI (handling being several releases behind), empirically checks for wire-protocol drift by regenerating fixtures, and writes a prioritized, read-only report under sync/. It never edits the SDK.
  2. Reconcile — make the actual lib//test/ changes, guided by the report and the mapping above (manually or interactively with an agent). This is the deliberate, human-in-the-loop step: decide what applies, adapt it to idiomatic Elixir, and add tests.
  3. Release — run release. It bumps the version everywhere, regenerates fixtures, runs a blocking verification gate (compile/test/docs/hex.build), commits, tags, and cuts the GitHub release — then hands off the Hex publish (mix hex.publish is run by a human because it needs an interactive 2FA OTP). This step is what bumps .upstream-sync.json.