Metadata-Version: 2.5
Name: voice-tool-dispatcher
Version: 0.1.1
Summary: A safety layer for voice-agent tool calls: HMAC auth, replay protection, exactly-once idempotency, and a hard dead-air budget for mid-call webhooks.
Project-URL: Homepage, https://readyto.talk
Author-email: "ReadyToTalk (Seven Olives)" <support@sevenolives.com>
License: MIT License
        
        Copyright (c) 2026 Seven Olives Inc. (ReadyToTalk)
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: agent,idempotency,llm,tool-calls,vapi,voice,webhook
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Telephony
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# voice-tool-dispatcher

**A safety layer for voice-agent tool calls.** When your AI agent takes real
actions mid-phone-call — placing orders, booking appointments, issuing
refunds — the tool-call webhook is where the money moves. This package makes
that webhook safe:

- **HMAC auth** — per-tenant shared secrets, constant-time compared, so a
  forged request never executes a tool.
- **Replay protection** — nonce consumption; a captured request can't be
  re-fired.
- **Exactly-once idempotency** — providers retry webhooks; a write-ahead
  reservation per `tool_call_id` guarantees a money-moving effect runs once.
  Duplicates replay the recorded result, or answer "still working on it"
  while the first execution is in flight.
- **A hard dead-air budget** — the caller is live on the phone. A handler
  that outruns the budget is abandoned and the caller hears a graceful
  degradation script, never silence, never a wrong order.
- **Batch resilience** — one bad call never sinks the rest of the batch, and
  batch items run concurrently: a batch of slow tools costs one budget of
  wall clock, not one per item. Handlers run on daemon threads, so an
  abandoned handler can never block process shutdown.

Zero dependencies, framework-agnostic (bring your own Django/Flask/FastAPI
view), built for [Vapi](https://vapi.ai)-style payloads with helpers included
— the parsing is provider-tolerant, the dispatcher is provider-neutral.

Extracted from production at [ReadyToTalk](https://readyto.talk), where an AI
receptionist takes real restaurant pickup orders by phone.

## Install

```
pip install voice-tool-dispatcher
```

## Quickstart

```python
from voice_tool_dispatcher import (
    Dispatcher, Registry, SecurityPolicy, ToolCallContext, ToolCallError,
    InMemoryNonceStore, InMemoryRateStore, extract_batch,
)

registry = Registry()

@registry.tool("record_order")
def record_order(tenant: str, ctx: ToolCallContext) -> str:
    items = ctx.arguments.get("items") or []
    if not items:
        # The agent re-prompts with this message instead of dying mid-call.
        raise ToolCallError("No items given — read the order back and try again.")
    total = place_order(tenant, items, phone=ctx.caller_number)  # your code
    return f"Order placed. The total is ${total/100:.2f}."

dispatcher = Dispatcher(
    registry,
    security=SecurityPolicy(
        secret_for=lambda tenant: lookup_tool_secret(tenant),  # your storage
        nonce_store=InMemoryNonceStore(),
        rate_store=InMemoryRateStore(max_per_window=60),
    ),
    budget_seconds=8.0,
)

# In your webhook view (any framework):
def tool_calls_webhook(request_json, headers):
    batch = extract_batch(request_json)                # Vapi-shape tolerant
    tenant = resolve_tenant(batch["assistant_id"])     # your mapping
    results = dispatcher.dispatch(
        tenant,
        batch["raw_tool_calls"],
        call_id=batch["call_id"],
        caller_number=batch["caller_number"],
        presented_secret=headers.get("x-ai-tool-secret"),
        nonce=batch["call_id"] + ":" + ":".join(c.get("id", "") for c in batch["raw_tool_calls"]),
    )
    return {"results": results}
```

## Failure semantics

| Situation | The caller hears | Wire result |
|---|---|---|
| Handler succeeds | your return string | `result` |
| Handler raises `ToolCallError("…")` | the agent re-prompts with your message | `error` |
| Handler raises `ProviderUnreachableError` | degradation script | `result` (degraded) |
| Handler exceeds the budget | degradation script | `result` (degraded) |
| Handler crashes (any other exception) | degradation script | `result` (degraded) |
| Duplicate `tool_call_id`, finished | the original result, replayed | as recorded |
| Duplicate `tool_call_id`, in flight | "still working on that" | `error` |
| Bad/missing secret, replayed nonce, oversized args | a polite decline | `error` (whole batch) |

## Production storage

The in-memory nonce/rate/reservation stores are correct for a single process.
For multi-process deployments implement the three tiny protocols
(`NonceStore.consume`, `RateStore.allow`, `ReservationStore.reserve/finalize`)
over your database or cache — each is one conditional write.

## License

MIT © Seven Olives Inc. (ReadyToTalk)
