ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc
+76
View File
@@ -0,0 +1,76 @@
# 附加项目:完整音频链路的 WebRTC 电话 Agent
本实验把“呼叫用户”实现为用户主动加入的本地浏览器 WebRTC 通话,不要求 PSTN、E.164 号码、电话运营商账户或公网 webhook。浏览器授权麦克风后向本地 `aiortc` peer 发送音频 RTP;服务端把实际收到的音频重采样并交给 Whisper ASR,把 ASR transcript 交给真实外部 LLM,再把 Agent 回复通过系统 TTS 合成为 PCM,送进 WebRTC 下行音轨。data channel 只传“音频提交”控制事件及 ASR/TTS 的无障碍字幕镜像,不提供用户语义。
完整链路是:
```text
browser microphone → WebRTC RTP → aiortc PCM buffer → Whisper ASR
→ external LLM dialogue / complete_task → real TTS PCM → WebRTC RTP → browser audio
```
服务端不提供 local planner、正则 parser、mock 或模型失败 fallback。ReAct 规划和通话后的结构化对话都必须收到 provider response ID、精确模型、usage、finish status 和正 latency;任何字段缺失都会中止。交互通话的音频和 transcript 只在本地进程内存在,不写盘。自动验收只使用明确标记为非隐私的合成语音 fixture,因此可以保留其音频、transcript 和 hash 做复核。
## 直接调用与 ReAct 对照
两组使用相同的浏览器麦克风 → ASR → LLM → TTS → 下行 RTP 路径,唯一实验变量是规划方式:
| 组别 | 调用者输入 | 规划行为 |
| --- | --- | --- |
| 直接组(control) | 姓名、目标、上下文、指令四项全部填写 | 不调用规划 LLM,直接建立固定参数会话 |
| ReAct 组(treatment) | 一段故意漏掉时间与确认码的自然语言任务 | 真实外部 LLM 留下 observation/reason/action 摘要,识别缺失字段并生成澄清话语 |
两组在 Whisper 转录用户明确确认的时间和确认码后,都由真实外部 LLM 生成 `complete_task` 结构及最终播报。实验只保存“本地确认记录”,不会声称诊所或其他外部系统已经完成预约。
## 安装与运行
```bash
# 仓库根目录;uv.lock 同时固定 aiortc、Playwright、Torch 和 Whisper
uv sync --locked --python 3.12 --extra ch6 --extra dev
cd chapter6/phone-agent
cp env.example .env
# 填入 ARK_API_KEY;也可按 env.example 显式改用 OpenAI/OpenRouter
# ReAct treatment
uv run --extra ch6 python demo.py \
--task "Call me about a dental checkup; ask for the missing exact time and confirmation code"
# Direct control
uv run --extra ch6 python direct_call.py \
--name "Jane Doe" \
--goal "Confirm a dental-checkup time and code" \
--context "Tuesday 2pm to 4pm is available" \
--instructions "Ask for one time and code, require explicit confirmation, then complete_task"
```
也可以运行 `uv run --extra ch6 uvicorn webrtc_app:app --host 127.0.0.1 --port 8765`,再打开 <http://127.0.0.1:8765>。听到 Agent 通过远端音轨播放的澄清问题后,对麦克风说出时间、确认码及明确确认,然后点击“Finish speaking”。页面不提供 typed semantic fallback 或浏览器 speech recognition;字幕只是服务端 ASR/TTS 结果的镜像。
默认配置使用 ARK `doubao-seed-1-6-flash-250615`、锁定的 `openai-whisper==20231106` tiny checkpoint,以及本机 `say`macOS)或 eSpeakLinuxTTS。可用 `PHONE_*``WHISPER_*` 环境变量显式覆盖;所选路径仍然 fail closed,不会换 provider 重试。`localhost` 可以直接使用麦克风;部署到其他主机时浏览器要求 HTTPS。本实验验证 localhost host-candidate 路径,不代表跨 NAT/TURN 或生产电话网络。
## Canonical 验收与复核
```bash
# 必须存在所选外部 LLM credential;其值不会写入证据
uv run --extra ch6 python run_acceptance.py \
--output validation/runs/phone-agent-webrtc-audio-20260731-v1
uv run --extra ch6 python verify_acceptance.py \
validation/runs/phone-agent-webrtc-audio-20260731-v1
uv run --extra ch6 --extra dev ruff check .
uv run --extra ch6 --extra dev pytest -q
node --check static/app.js
```
验收分别用 Chrome 的 one-shot fake microphone device 播放两条安全合成 WAV。它不是文本注入:语音仍经过 `getUserMedia`、Opus/RTP、服务端解码、PCM 缓冲和真实 Whisper inference。每组必须同时通过 20 个门禁,包括 SDP/ICE、data channel、双向音轨与 RTP packet/byte、RTP-derived WAV、Whisper checkpoint hash、真实外部 LLM raw receipt、两条真实 TTS asset 的 hash 与完整下行发送、媒体 transcript source、缺失字段澄清、明确确认、结构化完成、无 fallback 及隐私边界。
[`validation/runs/phone-agent-webrtc-audio-20260731-v1/`](validation/runs/phone-agent-webrtc-audio-20260731-v1/) 保留 direct/react 原始记录、对照结论、安全 fixture、服务端收到的 ASR WAV、Agent TTS WAV、日志与 manifest。`verify_acceptance.py` 独立重算源码/产物/LLM raw receipt hash,并拒绝缺文件、改媒体、改 response ID、无 usage、错误 transcript source 或任何 gate 降级。安全合成验收证明技术链路,不等同于真人可用性研究。
---
## English
This unnumbered add-on uses the stable `phone-agent` project identifier. It calls a consenting participant in a local browser rather than dialing the PSTN. Browser microphone audio is sent over RTP to aiortc, buffered and transcribed by real local Whisper. Only that ASR transcript enters the real external LLM dialogue. Every Agent utterance is synthesized by a real system TTS engine and queued on the WebRTC downlink audio track; the data channel carries only audio-commit control and accessibility mirrors.
The direct control requires four fixed parameters. The ReAct treatment accepts one incomplete task and requires a no-fallback external planning receipt with the credential-free raw request/response, provider response ID, exact model, usage, finish status, latency, and hashes. The retained safe campaign and standalone validator are in the directory linked above. PSTN and E.164 are intentionally outside this local “call the user” acceptance scope.
+370
View File
@@ -0,0 +1,370 @@
"""Fail-closed LLM planning and dialogue contracts for Phone Agent add-on.
The direct arm receives a fixed call plan. The ReAct arm asks a real external
OpenAI-compatible provider to observe an incomplete task, identify missing facts,
and choose the browser-call action. Both arms use the same external model for the
post-ASR dialogue turn. Provider errors are surfaced; this module has no local
planner, parser, mock, or fallback path.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
from openai import OpenAI
DEFAULT_ARK_MODEL = "doubao-seed-1-6-flash-250615"
ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
@dataclass(frozen=True)
class CallPlan:
mode: str
callee_name: str
goal: str
context: str
instructions: str
opening_line: str
missing_information: list[str] = field(default_factory=list)
trace: list[dict[str, str]] = field(default_factory=list)
planner_model: str | None = None
planner_receipt: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ProviderConfig:
name: str
api_key: str
base_url: str | None
model: str
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _required(label: str, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError(f"{label} is required")
return cleaned
def _canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _sha256_json(value: Any) -> str:
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
def _redact_secrets(value: Any) -> Any:
"""Remove credential values before any provider request/response is retained."""
serialized = json.dumps(value, ensure_ascii=False, default=str)
for name, secret in os.environ.items():
if (
any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
and len(secret) >= 8
):
serialized = serialized.replace(secret, "[REDACTED]")
serialized = re.sub(r"\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b", "[REDACTED]", serialized)
return json.loads(serialized)
def _provider_config(model: str | None = None) -> ProviderConfig:
provider = os.getenv("PHONE_MODEL_PROVIDER", "ark").casefold()
if provider == "ark":
key = os.getenv("ARK_API_KEY", "")
if not key:
raise RuntimeError("PHONE_MODEL_PROVIDER=ark requires ARK_API_KEY")
return ProviderConfig(
name="ark",
api_key=key,
base_url=os.getenv("ARK_BASE_URL", ARK_BASE_URL),
model=model or os.getenv("PHONE_PLANNER_MODEL", DEFAULT_ARK_MODEL),
)
if provider == "openai":
key = os.getenv("OPENAI_API_KEY", "")
if not key:
raise RuntimeError("PHONE_MODEL_PROVIDER=openai requires OPENAI_API_KEY")
return ProviderConfig(
name="openai",
api_key=key,
base_url=os.getenv("OPENAI_BASE_URL") or None,
model=model or os.getenv("PHONE_PLANNER_MODEL", "gpt-4.1-mini"),
)
if provider == "openrouter":
key = os.getenv("OPENROUTER_API_KEY", "")
if not key:
raise RuntimeError("PHONE_MODEL_PROVIDER=openrouter requires OPENROUTER_API_KEY")
return ProviderConfig(
name="openrouter",
api_key=key,
base_url="https://openrouter.ai/api/v1",
model=model or os.getenv("PHONE_PLANNER_MODEL", "openai/gpt-4.1-mini"),
)
raise RuntimeError("PHONE_MODEL_PROVIDER must be ark, openai, or openrouter")
def _json_object(text: str) -> dict[str, Any]:
value = json.loads(text)
if not isinstance(value, dict):
raise TypeError("model response must be a JSON object")
return value
def _real_json_completion(
*,
purpose: str,
messages: list[dict[str, str]],
model: str | None = None,
client: OpenAI | None = None,
provider_name: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Make one real completion and retain a credential-free raw receipt."""
config = _provider_config(model)
active_client = client or OpenAI(
api_key=config.api_key,
base_url=config.base_url,
timeout=120,
max_retries=0,
)
request = {
"model": model or config.model,
"messages": messages,
"response_format": {"type": "json_object"},
"temperature": 0,
"max_tokens": 700,
}
sanitized_request = _redact_secrets(request)
started_at = _now()
started = time.monotonic()
response = active_client.chat.completions.create(**request)
latency = time.monotonic() - started
finished_at = _now()
if not response.id:
raise RuntimeError(f"{purpose} response omitted its provider response ID")
if not response.choices:
raise RuntimeError(f"{purpose} response contained no choices")
choice = response.choices[0]
content = (choice.message.content or "").strip()
if not content:
raise RuntimeError(f"{purpose} response contained no text")
if not choice.finish_reason:
raise RuntimeError(f"{purpose} response omitted finish status")
usage = response.usage.model_dump(exclude_none=True) if response.usage else None
if not usage or int(usage.get("total_tokens", 0)) <= 0:
raise RuntimeError(f"{purpose} response omitted token usage")
raw_response = _redact_secrets(response.model_dump(exclude_none=True))
parsed = _json_object(content)
receipt = {
"schema_version": 1,
"purpose": purpose,
"execution": "real_external_llm",
"provider": provider_name or config.name,
"requested_model": request["model"],
"provider_model": response.model,
"provider_response_id": response.id,
"finish_reason": choice.finish_reason,
"usage": usage,
"started_at_utc": started_at,
"finished_at_utc": finished_at,
"latency_seconds": round(latency, 6),
"request": sanitized_request,
"request_sha256": _sha256_json(sanitized_request),
"raw_response": raw_response,
"raw_response_sha256": _sha256_json(raw_response),
"response_content": content,
"response_content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
"external_request_completed": True,
"mock": False,
"probe_only": False,
"fallback_used": False,
"credential_fields_retained": False,
}
return parsed, receipt
def direct_plan(
*,
callee_name: str,
goal: str,
context: str,
instructions: str,
) -> CallPlan:
"""Build the fixed-parameter control without an LLM planning call."""
callee = _required("callee_name", callee_name)
return CallPlan(
mode="direct",
callee_name=callee,
goal=_required("goal", goal),
context=_required("context", context),
instructions=_required("instructions", instructions),
opening_line=(
f"Hello {callee}. Please state the exact appointment time and confirmation code, "
"then explicitly confirm both."
),
trace=[
{"stage": "observation", "summary": "Caller supplied all call parameters."},
{
"stage": "action",
"summary": "Open a WebRTC voice session with the fixed parameters.",
},
],
)
def react_plan(
task: str,
*,
client: OpenAI | None = None,
model: str | None = None,
provider_name: str | None = None,
) -> CallPlan:
"""Use a real external LLM to create the ReAct call plan; never fall back."""
task = _required("task", task)
data, receipt = _real_json_completion(
purpose="react_planning",
client=client,
model=model,
provider_name=provider_name,
messages=[
{
"role": "system",
"content": (
"Plan a local browser WebRTC voice call. Observe the user's task, identify every missing "
"task-critical fact, reason briefly about what must be collected, and choose the call action. "
"Never invent facts. Return only JSON with callee_name, goal, context, instructions, "
"opening_line, missing_information (array), and decision_summary. opening_line must ask aloud "
"for the missing appointment time and confirmation code. instructions must require the voice "
"Agent to repeat the facts, obtain explicit confirmation, and complete_task only with confirmed "
"values. This local experiment records a confirmation but performs no external booking."
),
},
{"role": "user", "content": task},
],
)
missing = data.get("missing_information")
if (
not isinstance(missing, list)
or not missing
or not all(isinstance(item, str) and item.strip() for item in missing)
):
raise ValueError("ReAct planner must return a non-empty missing_information string array")
decision = _required("decision_summary", str(data.get("decision_summary", "")))
return CallPlan(
mode="react",
callee_name=_required("callee_name", str(data.get("callee_name", ""))),
goal=_required("goal", str(data.get("goal", ""))),
context=_required("context", str(data.get("context", ""))),
instructions=_required("instructions", str(data.get("instructions", ""))),
opening_line=_required("opening_line", str(data.get("opening_line", ""))),
missing_information=[item.strip() for item in missing],
trace=[
{"stage": "observation", "summary": task},
{"stage": "reason", "summary": decision},
{
"stage": "action",
"summary": "Open a WebRTC call and collect the missing facts by voice.",
},
],
planner_model=f"{receipt['provider']}:{receipt['provider_model']}",
planner_receipt=receipt,
)
def conversation_turn(
plan: CallPlan,
transcript: list[dict[str, Any]],
user_text: str,
*,
client: OpenAI | None = None,
model: str | None = None,
provider_name: str | None = None,
) -> dict[str, Any]:
"""Use the ASR transcript in one real dialogue/completion call; never fall back."""
user_text = _required("ASR transcript", user_text)
dialogue_model = model or os.getenv("PHONE_DIALOGUE_MODEL")
data, receipt = _real_json_completion(
purpose="post_asr_dialogue",
client=client,
model=dialogue_model,
provider_name=provider_name,
messages=[
{
"role": "system",
"content": (
"You are the voice Agent in a short local browser call. The user text below came only from ASR "
"over the browser microphone RTP track. Return only JSON with assistant_message, "
"explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing "
"result, appointment_time, confirmation_number, notes. If the user states an exact time, a "
"confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious "
"spoken code words/digits into a concise code, and repeat both details in assistant_message. "
"Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an "
"external action occurred. For a completed turn, completion.result must be exactly "
"'Local confirmation recorded.' and completion.notes must be exactly "
"'No external organization was contacted or booking made.' "
f"Goal: {plan.goal}\nContext: {plan.context}\nInstructions: {plan.instructions}"
),
},
{
"role": "user",
"content": _canonical_json(
{
"prior_audio_transcript": transcript,
"latest_user_asr_transcript": user_text,
}
),
},
],
)
completion = data.get("completion")
required = {"result", "appointment_time", "confirmation_number", "notes"}
if not isinstance(completion, dict) or not required.issubset(completion):
raise ValueError("dialogue completion object is incomplete")
assistant_message = _required("assistant_message", str(data.get("assistant_message", "")))
should_complete = data.get("should_complete") is True
explicit = data.get("explicit_confirmation_observed") is True
if should_complete and not explicit:
raise ValueError("model attempted completion without explicit confirmation")
if should_complete and (
not str(completion.get("appointment_time") or "").strip()
or not str(completion.get("confirmation_number") or "").strip()
):
raise ValueError("model attempted completion without both critical fields")
if should_complete and (
str(completion.get("result", "")).strip() != "Local confirmation recorded."
or str(completion.get("notes", "")).strip()
!= "No external organization was contacted or booking made."
):
raise ValueError(
"model attempted completion without the required no-external-action boundary"
)
return {
"assistant_message": assistant_message,
"explicit_confirmation_observed": explicit,
"should_complete": should_complete,
"completion": {key: str(completion.get(key, "")).strip() for key in sorted(required)},
"dialogue_model": f"{receipt['provider']}:{receipt['provider_model']}",
"llm_receipt": receipt,
}
__all__ = [
"CallPlan",
"conversation_turn",
"direct_plan",
"react_plan",
]
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Launch the ReAct arm of the local Phone Agent add-on browser call."""
from __future__ import annotations
import argparse
import threading
import urllib.parse
import webbrowser
import uvicorn
DEFAULT_TASK = (
"Call me to arrange a dental checkup. I did not include the exact time or confirmation code, "
"so ask me for both by voice, repeat the details, and save only what I explicitly confirm."
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--task", default=DEFAULT_TASK)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--no-open", action="store_true", help="Do not open the browser automatically"
)
args = parser.parse_args()
query = urllib.parse.urlencode({"mode": "react", "task": args.task})
url = f"http://{args.host}:{args.port}/?{query}"
print(f"Phone Agent add-on ReAct endpoint: {url}")
if not args.no_open:
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
uvicorn.run("webrtc_app:app", host=args.host, port=args.port)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Launch the fixed-parameter control arm of Phone Agent add-on."""
from __future__ import annotations
import argparse
import threading
import urllib.parse
import webbrowser
import uvicorn
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--name", required=True, help="Name shown in the AI's opening line")
parser.add_argument("--goal", required=True)
parser.add_argument("--context", required=True)
parser.add_argument("--instructions", required=True)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--no-open", action="store_true", help="Do not open the browser automatically"
)
args = parser.parse_args()
query = urllib.parse.urlencode(
{
"mode": "direct",
"callee_name": args.name,
"goal": args.goal,
"context": args.context,
"instructions": args.instructions,
}
)
url = f"http://{args.host}:{args.port}/?{query}"
print(f"Phone Agent add-on direct endpoint: {url}")
if not args.no_open:
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
uvicorn.run("webrtc_app:app", host=args.host, port=args.port)
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
# Canonical external LLM provider. Credentials remain in the local server
# process and are redacted before any request/response receipt is retained.
ARK_API_KEY=your_ark_api_key
PHONE_MODEL_PROVIDER=ark
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
PHONE_PLANNER_MODEL=doubao-seed-1-6-flash-250615
PHONE_DIALOGUE_MODEL=doubao-seed-1-6-flash-250615
# Explicit alternatives; there is no automatic provider fallback.
# PHONE_MODEL_PROVIDER=openai
# OPENAI_API_KEY=your_openai_api_key
# PHONE_PLANNER_MODEL=gpt-4.1-mini
# PHONE_DIALOGUE_MODEL=gpt-4.1-mini
# PHONE_MODEL_PROVIDER=openrouter
# OPENROUTER_API_KEY=your_openrouter_api_key
# PHONE_PLANNER_MODEL=openai/gpt-4.1-mini
# PHONE_DIALOGUE_MODEL=openai/gpt-4.1-mini
# Local speech runtime. openai-whisper and torch are locked in the ch9 extra.
WHISPER_MODEL=tiny
# WHISPER_PYTHON=/absolute/path/to/python
PHONE_TTS_ENGINE=auto
# PHONE_TTS_VOICE=Samantha
# These are set only by run_acceptance.py. Do not enable media retention for
# real/private participant speech.
# PHONE_SAFE_SYNTHETIC_ACCEPTANCE=1
# PHONE_EVIDENCE_DIR=/absolute/path/to/safe-evidence-directory
+12
View File
@@ -0,0 +1,12 @@
# Prefer the repository's locked Chapter 9 environment:
# uv sync --locked --extra ch6 --extra dev
openai>=1.68
python-dotenv>=1.0
fastapi>=0.110
uvicorn[standard]>=0.27
httpx>=0.27
playwright>=1.40
aiortc>=1.10
openai-whisper==20231106
torch>=2.2
pytest>=8.3
+476
View File
@@ -0,0 +1,476 @@
#!/usr/bin/env python3
"""Run the safe, full-audio direct-vs-ReAct Phone Agent add-on campaign.
Each arm gives Chrome a non-private synthesized microphone WAV. Chrome sends that
fixture through getUserMedia and RTP to aiortc; the server buffers the received RTP
audio, runs real Whisper ASR, invokes a real external dialogue model, synthesizes the
Agent's speech, and transmits it on the downlink RTP track. No semantic user text is
sent over the data channel and no PSTN destination is contacted.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.metadata
import json
import os
import platform
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from playwright.sync_api import Page, sync_playwright
from speech import make_synthetic_speech_fixture, sha256_file
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
CANONICAL_MODEL = "doubao-seed-1-6-flash-250615"
def json_request(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=10) as response:
return json.loads(response.read())
def wait_for(check: Callable[[], Any], timeout: float, label: str) -> Any:
deadline = time.monotonic() + timeout
last: Any = None
while time.monotonic() < deadline:
try:
last = check()
if last:
return last
except (urllib.error.URLError, json.JSONDecodeError):
pass
time.sleep(0.5)
raise TimeoutError(f"timed out waiting for {label}; last={last!r}")
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def chrome_path() -> str:
candidates = [
os.getenv("CHROME_PATH", ""),
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
shutil.which("google-chrome") or "",
shutil.which("chromium") or "",
shutil.which("chromium-browser") or "",
]
for candidate in candidates:
if candidate and Path(candidate).is_file():
return candidate
raise RuntimeError("Chrome/Chromium was not found; set CHROME_PATH")
def require_canonical_runtime() -> None:
if not os.getenv("ARK_API_KEY"):
raise RuntimeError("canonical acceptance requires ARK_API_KEY")
whisper_python = os.getenv("WHISPER_PYTHON", sys.executable)
if not whisper_python or not Path(whisper_python).is_file():
raise RuntimeError("canonical acceptance requires explicit WHISPER_PYTHON")
check = subprocess.run(
[whisper_python, "-c", "import torch, whisper"],
capture_output=True,
timeout=30,
check=False,
)
if check.returncode != 0:
raise RuntimeError("WHISPER_PYTHON cannot import torch and whisper")
if not shutil.which("ffmpeg") or not (shutil.which("say") or shutil.which("espeak")):
raise RuntimeError("canonical acceptance requires ffmpeg and say/espeak")
def _arm_request(page: Page, arm: str) -> None:
page.check(f'input[name="mode"][value="{arm}"]')
if arm == "direct":
page.fill("#callee-name", "Jane Doe")
page.fill(
"#goal",
"Collect and confirm Jane Doe's preferred dental-checkup time and confirmation code.",
)
page.fill(
"#context",
"Tuesday afternoon from 2pm to 4pm is available; the user must supply the exact time and code by voice.",
)
page.fill(
"#instructions",
"Ask for one exact time and a confirmation code. Repeat both, require explicit confirmation, then call "
"complete_task with only the ASR-confirmed details.",
)
else:
page.fill(
"#task",
"Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation "
"code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm.",
)
def run_arm(page: Page, base_url: str, arm: str, fixture_duration: float) -> dict[str, Any]:
page.goto(base_url, wait_until="networkidle")
_arm_request(page, arm)
page.click("#start")
page.wait_for_function(
"() => window.exp92?.state?.dc?.readyState === 'open' && "
"['connected','completed'].includes(window.exp92.state.pc.iceConnectionState)",
timeout=120_000,
)
call_id = page.locator("#call-id").inner_text()
def record() -> dict[str, Any]:
return json_request(f"{base_url}/api/calls/{call_id}")
wait_for(
lambda: (
(value := record())["models"]["tts_receipts"]
and value["models"]["tts_receipts"][0].get("delivery_complete")
),
120,
f"{arm} synthesized opening speech on downlink RTP",
)
# The fake device starts at getUserMedia. Wait through its one-shot safe
# fixture before sending a control-only commit event.
page.wait_for_timeout(int((fixture_duration + 1.0) * 1000))
page.evaluate("async () => await window.exp92.commitAudio()")
def completion_or_error() -> dict[str, Any] | None:
value = record()
return value if value.get("completion") or value.get("errors") else None
completed = wait_for(completion_or_error, 300, f"{arm} microphone ASR -> LLM -> completion")
if completed["errors"]:
raise AssertionError(f"{arm} runtime errors: {completed['errors']}")
wait_for(
lambda: (
(value := record())["models"]["tts_receipts"]
and len(value["models"]["tts_receipts"]) >= 2
and all(item.get("delivery_complete") for item in value["models"]["tts_receipts"])
),
180,
f"{arm} all Agent TTS transmitted on downlink RTP",
)
page.evaluate("async () => await window.exp92.collectStats()")
wait_for(
lambda: (
(value := record())["transport"]["rtc_stats"]["inbound_packets"] > 0
and value["transport"]["rtc_stats"]["outbound_packets"] > 0
),
45,
f"{arm} bidirectional RTP counters",
)
final = page.evaluate("async () => await window.exp92.hangup('automated_safe_acceptance')")
if not final["acceptance"]["passed"]:
raise AssertionError(f"{arm} acceptance failed: {final['acceptance']}")
return final
def _credential_scan(paths: list[Path]) -> dict[str, Any]:
secrets = [
secret.encode()
for name, secret in os.environ.items()
if any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
and len(secret) >= 8
]
failures = []
for path in paths:
data = path.read_bytes()
if any(secret in data for secret in secrets):
failures.append(str(path))
return {
"scanned_file_count": len(paths),
"environment_credential_value_matches": failures,
"passed": not failures,
}
def _package_version(name: str) -> str:
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return "not-installed"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--output", type=Path, help="Run directory (default: timestamped under validation/runs)"
)
args = parser.parse_args()
require_canonical_runtime()
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
output = (
args.output or HERE / "validation" / "runs" / f"phone-agent-webrtc-audio-{stamp}"
).resolve()
output.mkdir(parents=True, exist_ok=False)
fixture_text = {
"direct": (
"Tuesday at three P M works. The confirmation code is Maple seven. "
"I explicitly confirm Tuesday at three P M and confirmation code Maple seven."
),
"react": (
"Tuesday at three P M works. The confirmation code is Cedar eight. "
"I explicitly confirm Tuesday at three P M and confirmation code Cedar eight."
),
}
fixtures: dict[str, dict[str, Any]] = {}
fixture_paths: dict[str, Path] = {}
for arm, text in fixture_text.items():
path = output / "fixtures" / f"{arm}_microphone.wav"
fixtures[arm] = make_synthetic_speech_fixture(text, path)
fixtures[arm]["artifact_path"] = str(path.relative_to(output))
fixtures[arm]["text_sha256"] = hashlib.sha256(text.encode("utf-8")).hexdigest()
fixture_paths[arm] = path
port = free_port()
base_url = f"http://127.0.0.1:{port}"
server_log = output / "server.log"
env = dict(os.environ)
env.update(
{
"PYTHONDONTWRITEBYTECODE": "1",
"PHONE_MODEL_PROVIDER": "ark",
"PHONE_PLANNER_MODEL": CANONICAL_MODEL,
"PHONE_DIALOGUE_MODEL": CANONICAL_MODEL,
"PHONE_TTS_ENGINE": "say" if shutil.which("say") else "espeak",
"WHISPER_MODEL": "tiny",
"WHISPER_PYTHON": os.getenv("WHISPER_PYTHON", sys.executable),
"PHONE_SAFE_SYNTHETIC_ACCEPTANCE": "1",
"PHONE_EVIDENCE_DIR": str(output),
}
)
server_cleaned = False
browsers_closed = False
with server_log.open("w", encoding="utf-8") as log:
server = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"webrtc_app:app",
"--host",
"127.0.0.1",
"--port",
str(port),
],
cwd=HERE,
env=env,
stdout=log,
stderr=subprocess.STDOUT,
text=True,
)
try:
health = wait_for(lambda: json_request(base_url + "/api/health"), 45, "local server")
if not health.get("model_credential_present"):
raise RuntimeError("server did not observe the model credential")
executable = chrome_path()
with sync_playwright() as playwright:
records: dict[str, dict[str, Any]] = {}
for arm in ("direct", "react"):
browser = playwright.chromium.launch(
executable_path=executable,
headless=True,
args=[
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
f"--use-file-for-fake-audio-capture={fixture_paths[arm]}%noloop",
"--autoplay-policy=no-user-gesture-required",
"--no-default-browser-check",
],
)
context = browser.new_context(permissions=["microphone"])
try:
records[arm] = run_arm(
context.new_page(),
base_url,
arm,
float(fixtures[arm]["duration_seconds"]),
)
finally:
context.close()
browser.close()
browsers_closed = True
finally:
server.terminate()
try:
server.wait(timeout=15)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=5)
server_cleaned = server.poll() is not None
direct = records["direct"]
react = records["react"]
comparison_checks = {
"same_browser_aiortc_webrtc_transport": direct["transport"]["kind"]
== react["transport"]["kind"]
== "webrtc",
"no_pstn_or_e164": all(
record["transport"]["pstn_used"] is False
and record["transport"]["e164_required"] is False
for record in (direct, react)
),
"direct_required_fixed_parameters": direct["input_contract"]["fields_supplied_by_caller"]
== ["callee_name", "goal", "context", "instructions"],
"react_accepted_only_natural_language_task": react["input_contract"][
"fields_supplied_by_caller"
]
== ["task"],
"react_detected_missing_information": bool(react["plan"]["missing_information"]),
"react_has_observe_reason_act_trace": [step["stage"] for step in react["plan"]["trace"]]
== ["observation", "reason", "action"],
"react_used_real_external_planner": any(
item["purpose"] == "react_planning" and item["execution"] == "real_external_llm"
for item in react["models"]["llm_receipts"]
),
"both_used_microphone_rtp_asr": all(
record["models"]["asr_receipts"][0]["input_source"] == "browser_microphone_rtp"
for record in (direct, react)
),
"both_used_real_downlink_tts": all(
len(record["models"]["tts_receipts"]) >= 2
and all(item["delivery_complete"] for item in record["models"]["tts_receipts"])
for record in (direct, react)
),
"both_used_external_post_asr_dialogue": all(
any(item["purpose"] == "post_asr_dialogue" for item in record["models"]["llm_receipts"])
for record in (direct, react)
),
"data_channel_never_supplied_user_semantics": all(
record["event_counts"].get("semantic_user_messages", 0) == 0
for record in (direct, react)
),
"both_completed_all_audio_gates": direct["acceptance"]["passed"]
and react["acceptance"]["passed"],
"both_saved_confirmed_structured_fields": all(
record["completion"]["appointment_time"] and record["completion"]["confirmation_number"]
for record in (direct, react)
),
}
comparison = {
"schema_version": 2,
"experiment": "phone-agent",
"executed_at_utc": datetime.now(timezone.utc).isoformat(),
"control": "fixed parameters -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
"treatment": "natural task -> real LLM ReAct plan -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
"checks": comparison_checks,
"passed": all(comparison_checks.values()),
"conclusion": (
"Both arms completed the same real bidirectional browser/aiortc audio path. The treatment additionally "
"used an external ARK ReAct planning receipt to detect missing facts; the control used fixed parameters."
),
}
if not comparison["passed"]:
raise AssertionError(f"comparison failed: {comparison_checks}")
artifacts = {"direct.json": direct, "react.json": react, "comparison.json": comparison}
for name, value in artifacts.items():
(output / name).write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
source_paths = [
HERE / "agent.py",
HERE / "speech.py",
HERE / "webrtc_app.py",
HERE / "run_acceptance.py",
HERE / "verify_acceptance.py",
HERE / "demo.py",
HERE / "direct_call.py",
HERE / "env.example",
HERE / "requirements.txt",
HERE / "test_agent.py",
HERE / "test_speech.py",
HERE / "test_webrtc_app.py",
HERE / "test_verify_acceptance.py",
HERE / "static" / "index.html",
HERE / "static" / "app.js",
HERE / "static" / "style.css",
HERE / "README.md",
ROOT / "chapter9" / "README.md",
ROOT / "book" / "chapter9.md",
ROOT / "pyproject.toml",
ROOT / "uv.lock",
]
evidence_files = sorted(path for path in output.rglob("*") if path.is_file())
scan = _credential_scan(evidence_files)
if not scan["passed"]:
raise RuntimeError(
f"credential values found in evidence: {scan['environment_credential_value_matches']}"
)
executable = Path(chrome_path())
manifest = {
"schema_version": 2,
"experiment": "phone-agent",
"run_id": output.name,
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"result": "passed",
"execution": "live_browser_aiortc_asr_external_llm_tts_webrtc",
"canonical_safe_synthetic_fixture": True,
"pstn_used": False,
"e164_required": False,
"credentials_saved": False,
"private_audio_or_transcripts_saved": False,
"safe_fixture_provenance": fixtures,
"environment": {
"platform": platform.platform(),
"python": platform.python_version(),
"chrome_version": subprocess.check_output(
[str(executable), "--version"], text=True
).strip(),
"chrome_executable_sha256": sha256_file(executable),
"planner_provider": "ark",
"planner_model": CANONICAL_MODEL,
"dialogue_model": CANONICAL_MODEL,
"media_peer": "aiortc",
"packages": {
"aiortc": _package_version("aiortc"),
"av": _package_version("av"),
"openai": _package_version("openai"),
"playwright": _package_version("playwright"),
},
},
"source_sha256": {str(path.relative_to(ROOT)): sha256_file(path) for path in source_paths},
"artifact_sha256": {
str(path.relative_to(output)): sha256_file(path) for path in evidence_files
},
"redaction": scan,
"cleanup": {
"browser_contexts_closed": browsers_closed,
"server_process_terminated": server_cleaned,
"raw_private_media_created": False,
},
"acceptance": {
"direct": direct["acceptance"],
"react": react["acceptance"],
"comparison_passed": comparison["passed"],
},
}
if (
manifest["cleanup"]["browser_contexts_closed"] is not True
or manifest["cleanup"]["server_process_terminated"] is not True
or manifest["cleanup"]["raw_private_media_created"] is not False
):
raise AssertionError(f"cleanup gate failed: {manifest['cleanup']}")
(output / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({"run_dir": str(output), "passed": True}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+307
View File
@@ -0,0 +1,307 @@
"""Real speech synthesis and microphone-audio ASR for Phone Agent add-on."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Any
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def pcm16_wav(pcm: bytes, sample_rate: int) -> bytes:
with tempfile.SpooledTemporaryFile() as handle:
with wave.open(handle, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(pcm)
handle.seek(0)
return handle.read()
def read_pcm16_wav(value: bytes) -> tuple[bytes, int]:
with tempfile.SpooledTemporaryFile() as handle:
handle.write(value)
handle.seek(0)
with wave.open(handle, "rb") as wav:
if wav.getnchannels() != 1 or wav.getsampwidth() != 2 or wav.getcomptype() != "NONE":
raise RuntimeError("speech synthesizer output must be mono PCM16 WAV")
return wav.readframes(wav.getnframes()), wav.getframerate()
def _command_receipt(path: str) -> dict[str, Any]:
resolved = Path(path).resolve()
return {
"name": resolved.name,
"sha256": sha256_file(resolved),
}
@dataclass(frozen=True)
class SynthesizedSpeech:
pcm: bytes
wav: bytes
receipt: dict[str, Any]
class SystemSpeechSynthesizer:
"""Synthesize actual speech with an explicitly selected local speech engine."""
def __init__(self) -> None:
requested = os.getenv("PHONE_TTS_ENGINE", "auto").casefold()
say = shutil.which("say")
espeak = shutil.which("espeak-ng") or shutil.which("espeak")
if requested == "say" and not say:
raise RuntimeError("PHONE_TTS_ENGINE=say but the say executable is unavailable")
if requested == "espeak" and not espeak:
raise RuntimeError("PHONE_TTS_ENGINE=espeak but espeak is unavailable")
if requested not in {"auto", "say", "espeak"}:
raise RuntimeError("PHONE_TTS_ENGINE must be auto, say, or espeak")
self.engine = say if requested in {"auto", "say"} and say else espeak
self.ffmpeg = shutil.which("ffmpeg")
if not self.engine or not self.ffmpeg:
raise RuntimeError("real local TTS requires say/espeak and ffmpeg")
self.kind = "say" if Path(self.engine).name == "say" else "espeak"
self.voice = os.getenv("PHONE_TTS_VOICE", "Samantha" if self.kind == "say" else "en-us")
def synthesize(self, text: str, *, sample_rate: int = 8_000) -> SynthesizedSpeech:
cleaned = text.strip()
if not cleaned:
raise ValueError("TTS text must not be empty")
started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="phone-agent-tts-") as directory:
directory_path = Path(directory)
source = directory_path / ("speech.aiff" if self.kind == "say" else "speech.wav")
target = directory_path / "speech.wav"
if self.kind == "say":
command = [self.engine, "-v", self.voice, "-o", str(source), cleaned]
else:
command = [self.engine, "-v", self.voice, "-w", str(source), cleaned]
subprocess.run(command, check=True, capture_output=True, timeout=90)
subprocess.run(
[
self.ffmpeg,
"-nostdin",
"-loglevel",
"error",
"-y",
"-i",
str(source),
"-ac",
"1",
"-ar",
str(sample_rate),
"-acodec",
"pcm_s16le",
str(target),
],
check=True,
capture_output=True,
timeout=90,
)
wav = target.read_bytes()
pcm, actual_rate = read_pcm16_wav(wav)
if actual_rate != sample_rate or not pcm:
raise RuntimeError("TTS produced empty audio or the wrong sample rate")
receipt = {
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say" if self.kind == "say" else "eSpeak",
"model": "operating-system speech synthesizer",
"voice": self.voice,
"sample_rate_hz": sample_rate,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": len(pcm) // 2,
"duration_seconds": round(len(pcm) / 2 / sample_rate, 6),
"wav_bytes": len(wav),
"wav_sha256": sha256_bytes(wav),
"pcm_sha256": sha256_bytes(pcm),
"latency_seconds": round(time.monotonic() - started, 6),
"engine": _command_receipt(self.engine),
"decoder": _command_receipt(self.ffmpeg),
"network_used": False,
"mock": False,
"probe_only": False,
"fallback_used": False,
}
return SynthesizedSpeech(pcm=pcm, wav=wav, receipt=receipt)
class WhisperASR:
"""Run a real cached OpenAI Whisper checkpoint over exact RTP-derived PCM."""
def __init__(self) -> None:
requested = os.getenv("WHISPER_PYTHON")
candidates = [requested] if requested else [sys.executable, shutil.which("python3")]
self.python = next(
(candidate for candidate in candidates if candidate and self._available(candidate)),
None,
)
if not self.python:
raise RuntimeError(
"local ASR requires torch and openai-whisper; set WHISPER_PYTHON to that Python executable"
)
self.model = os.getenv("WHISPER_MODEL", "tiny")
@staticmethod
def _available(python: str) -> bool:
try:
return (
subprocess.run(
[python, "-c", "import torch, whisper"],
capture_output=True,
timeout=20,
check=False,
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
return False
def transcribe(
self, pcm: bytes, *, retained_wav_path: Path | None = None
) -> tuple[str, dict[str, Any]]:
if len(pcm) < 16_000:
raise RuntimeError("microphone RTP buffer is too short for ASR")
wav = pcm16_wav(pcm, 16_000)
if retained_wav_path is not None:
retained_wav_path.parent.mkdir(parents=True, exist_ok=True)
retained_wav_path.write_bytes(wav)
source_path = retained_wav_path
else:
with tempfile.NamedTemporaryFile(
prefix="phone-agent-asr-", suffix=".wav", delete=False
) as temporary:
temporary.write(wav)
source_path = Path(temporary.name)
script = """import hashlib, json, pathlib, sys, time
import torch, whisper
model_name, audio = sys.argv[1:3]
checkpoint = pathlib.Path.home()/'.cache'/'whisper'/(model_name+'.pt')
started=time.perf_counter(); model=whisper.load_model(model_name); loaded=time.perf_counter()
result=model.transcribe(audio, language='en', fp16=False, temperature=0, verbose=False, condition_on_previous_text=False)
finished=time.perf_counter()
payload={'text':str(result.get('text') or '').strip(),'language':result.get('language'),
'checkpoint_name':checkpoint.name,'checkpoint_sha256':hashlib.sha256(checkpoint.read_bytes()).hexdigest() if checkpoint.exists() else None,
'python':sys.version.split()[0],'torch':torch.__version__,'whisper':getattr(whisper,'__version__','unknown'),
'model_load_seconds':loaded-started,'inference_seconds':finished-loaded}
print('EXPERIMENT_JSON='+json.dumps(payload,ensure_ascii=False))
"""
started = time.monotonic()
try:
process = subprocess.run(
[self.python, "-c", script, self.model, str(source_path)],
check=True,
capture_output=True,
text=True,
timeout=240,
)
finally:
if retained_wav_path is None:
source_path.unlink(missing_ok=True)
marker = next(
(line for line in process.stdout.splitlines() if line.startswith("EXPERIMENT_JSON=")),
None,
)
if not marker:
raise RuntimeError("Whisper returned no structured result")
result = json.loads(marker.split("=", 1)[1])
transcript = str(result.get("text") or "").strip()
checkpoint_sha = str(result.get("checkpoint_sha256") or "")
if not transcript or len(checkpoint_sha) != 64:
raise RuntimeError("Whisper returned an empty transcript or missing checkpoint hash")
receipt = {
"schema_version": 1,
"operation": "asr",
"execution": "real_local_inference",
"provider": "local OpenAI Whisper",
"model": f"whisper-{self.model}",
"checkpoint_name": result["checkpoint_name"],
"checkpoint_sha256": checkpoint_sha,
"runtime": {
"python": result["python"],
"torch": result["torch"],
"openai_whisper": result["whisper"],
},
"input_source": "browser_microphone_rtp",
"input_sample_rate_hz": 16_000,
"input_channels": 1,
"input_pcm_bytes": len(pcm),
"input_wav_bytes": len(wav),
"input_wav_sha256": sha256_bytes(wav),
"input_duration_seconds": round(len(pcm) / 2 / 16_000, 6),
"language": result.get("language") or "unknown",
"transcript": transcript,
"transcript_sha256": hashlib.sha256(transcript.encode("utf-8")).hexdigest(),
"model_load_seconds": round(float(result["model_load_seconds"]), 6),
"inference_seconds": round(float(result["inference_seconds"]), 6),
"latency_seconds": round(time.monotonic() - started, 6),
"retained_safe_fixture_path": (
str(retained_wav_path.name) if retained_wav_path is not None else None
),
"external_request": False,
"mock": False,
"probe_only": False,
"fallback_used": False,
}
return transcript, receipt
def make_synthetic_speech_fixture(
text: str,
output_path: Path,
*,
leading_silence_seconds: float = 4.0,
trailing_silence_seconds: float = 3.0,
) -> dict[str, Any]:
"""Generate a non-private browser microphone WAV for automated acceptance."""
synthesizer = SystemSpeechSynthesizer()
speech = synthesizer.synthesize(text, sample_rate=16_000)
leading = b"\x00\x00" * int(16_000 * leading_silence_seconds)
trailing = b"\x00\x00" * int(16_000 * trailing_silence_seconds)
wav = pcm16_wav(leading + speech.pcm + trailing, 16_000)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(wav)
return {
"kind": "safe_synthetic_browser_microphone_fixture",
"contains_private_data": False,
"sample_rate_hz": 16_000,
"duration_seconds": round((len(leading) + len(speech.pcm) + len(trailing)) / 2 / 16_000, 6),
"wav_bytes": len(wav),
"wav_sha256": sha256_bytes(wav),
"leading_silence_seconds": leading_silence_seconds,
"trailing_silence_seconds": trailing_silence_seconds,
"synthesis": speech.receipt,
}
__all__ = [
"SystemSpeechSynthesizer",
"WhisperASR",
"make_synthetic_speech_fixture",
"pcm16_wav",
"sha256_bytes",
"sha256_file",
]
+243
View File
@@ -0,0 +1,243 @@
const $ = (selector) => document.querySelector(selector);
const state = {
callId: null,
plan: null,
pc: null,
dc: null,
stream: null,
statsTimer: null,
remoteAudioTrack: false,
localAudioTrack: false,
lastStats: null,
audioCommitted: false,
};
function mode() {
return document.querySelector('input[name="mode"]:checked').value;
}
function setStatus(value) {
$('#status').textContent = value;
document.body.dataset.status = value;
}
function appendTurn(speaker, text) {
if (!text) return;
const p = document.createElement('p');
p.className = 'turn';
const b = document.createElement('b');
b.textContent = speaker === 'agent' ? 'Agent: ' : 'You: ';
p.append(b, document.createTextNode(text));
$('#transcript').appendChild(p);
$('#transcript').scrollTop = $('#transcript').scrollHeight;
}
async function jsonFetch(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})},
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);
return data;
}
async function saveEvent(event) {
if (!state.callId) return;
try {
await jsonFetch(`/api/calls/${state.callId}/events`, {
method: 'POST',
body: JSON.stringify({event}),
});
} catch (error) {
console.warn('event receipt failed', error);
}
}
function sendControl(event) {
if (!state.dc || state.dc.readyState !== 'open') throw new Error('data channel is not open');
state.dc.send(JSON.stringify(event));
}
async function publishReady() {
if (!state.pc) return;
const open = state.dc?.readyState === 'open';
const connected = ['connected', 'completed'].includes(state.pc.iceConnectionState);
$('#transport').textContent = `WebRTC · ICE ${state.pc.iceConnectionState} · data ${state.dc?.readyState || 'new'}`;
if (open && connected) setStatus('connected · listening');
await saveEvent({
type: 'rtc.ready',
ice_connection_state: state.pc.iceConnectionState,
data_channel_open: open,
local_audio_track: state.localAudioTrack,
remote_audio_track: state.remoteAudioTrack,
});
}
async function collectStats() {
if (!state.pc) return null;
const totals = {
type: 'rtc.stats',
ice_connection_state: state.pc.iceConnectionState,
inbound_packets: 0,
inbound_bytes: 0,
outbound_packets: 0,
outbound_bytes: 0,
};
const reports = await state.pc.getStats();
reports.forEach((report) => {
const kind = report.kind || report.mediaType;
if (kind !== 'audio') return;
if (report.type === 'inbound-rtp' && !report.isRemote) {
totals.inbound_packets += report.packetsReceived || 0;
totals.inbound_bytes += report.bytesReceived || 0;
}
if (report.type === 'outbound-rtp' && !report.isRemote) {
totals.outbound_packets += report.packetsSent || 0;
totals.outbound_bytes += report.bytesSent || 0;
}
});
state.lastStats = totals;
await saveEvent(totals);
return totals;
}
async function handleServerEvent(event) {
if (event.type === 'agent.caption') appendTurn('agent', event.text);
if (event.type === 'user.caption') appendTurn('user', event.text);
if (event.type === 'tool.result') {
setStatus('task completed · Agent audio playing');
$('#evidence').textContent = JSON.stringify(event, null, 2);
}
if (event.type === 'error') {
setStatus('error');
$('#evidence').textContent = JSON.stringify(event.error || event, null, 2);
}
}
function createRequest() {
if (mode() === 'react') return {mode: 'react', task: $('#task').value};
return {
mode: 'direct',
callee_name: $('#callee-name').value,
goal: $('#goal').value,
context: $('#context').value,
instructions: $('#instructions').value,
};
}
async function startCall() {
$('#start').disabled = true;
setStatus('real LLM planning');
try {
const created = await jsonFetch('/api/calls', {method: 'POST', body: JSON.stringify(createRequest())});
state.callId = created.call_id;
state.plan = created.plan;
state.audioCommitted = false;
$('#call-id').textContent = state.callId;
$('#evidence').textContent = JSON.stringify({plan: state.plan}, null, 2);
const pc = new RTCPeerConnection();
state.pc = pc;
const audio = $('#remote-audio');
pc.ontrack = async (event) => {
audio.srcObject = event.streams[0];
state.remoteAudioTrack = event.track.kind === 'audio';
try { await audio.play(); } catch (error) { console.warn('autoplay pending', error); }
await publishReady();
};
pc.oniceconnectionstatechange = publishReady;
pc.onconnectionstatechange = publishReady;
setStatus('requesting microphone');
state.stream = await navigator.mediaDevices.getUserMedia({audio: true, video: false});
const track = state.stream.getAudioTracks()[0];
if (!track) throw new Error('no microphone audio track was returned');
state.localAudioTrack = true;
pc.addTrack(track, state.stream);
const dc = pc.createDataChannel('accessibility-and-control');
state.dc = dc;
dc.onmessage = (message) => handleServerEvent(JSON.parse(message.data)).catch(console.error);
dc.onclose = publishReady;
dc.onopen = async () => {
await publishReady();
sendControl({type: 'client.ready'});
$('#commit-audio').disabled = false;
};
setStatus('negotiating WebRTC');
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const answerResponse = await fetch(`/api/calls/${state.callId}/session`, {
method: 'POST',
headers: {'Content-Type': 'application/sdp'},
body: offer.sdp,
});
const answerSdp = await answerResponse.text();
if (!answerResponse.ok) throw new Error(answerSdp);
await pc.setRemoteDescription({type: 'answer', sdp: answerSdp});
state.statsTimer = setInterval(() => collectStats().catch(console.warn), 750);
$('#hangup').disabled = false;
} catch (error) {
setStatus('error');
$('#evidence').textContent = String(error);
$('#start').disabled = false;
throw error;
}
}
async function commitAudio() {
if (state.audioCommitted) throw new Error('microphone audio was already committed');
state.audioCommitted = true;
$('#commit-audio').disabled = true;
setStatus('Whisper ASR · real LLM dialogue');
sendControl({type: 'client.audio.commit'});
}
async function hangup(reason = 'user_hangup') {
if (!state.callId) return null;
if (state.statsTimer) clearInterval(state.statsTimer);
await collectStats();
state.stream?.getTracks().forEach((track) => track.stop());
state.dc?.close();
state.pc?.close();
const record = await jsonFetch(`/api/calls/${state.callId}/finish`, {
method: 'POST',
body: JSON.stringify({reason}),
});
setStatus(record.acceptance.passed ? 'completed' : 'ended');
$('#evidence').textContent = JSON.stringify(record, null, 2);
$('#hangup').disabled = true;
$('#commit-audio').disabled = true;
return record;
}
document.querySelectorAll('input[name="mode"]').forEach((radio) => {
radio.addEventListener('change', () => {
const react = mode() === 'react';
$('#react-fields').hidden = !react;
$('#direct-fields').hidden = react;
});
});
$('#start').addEventListener('click', () => startCall().catch(console.error));
$('#commit-audio').addEventListener('click', () => commitAudio().catch(console.error));
$('#hangup').addEventListener('click', () => hangup().catch(console.error));
window.exp92 = {state, startCall, commitAudio, hangup, collectStats};
const params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'direct') {
document.querySelector('input[name="mode"][value="direct"]').click();
}
const queryFields = {
task: '#task',
callee_name: '#callee-name',
goal: '#goal',
context: '#context',
instructions: '#instructions',
};
Object.entries(queryFields).forEach(([key, selector]) => {
if (params.has(key)) $(selector).value = params.get(key);
});
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Phone Agent add-on · WebRTC Call Agent</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<header>
<p class="eyebrow">AI Agent Book · Phone Agent add-on</p>
<h1>A voice agent that calls you in the browser</h1>
<p class="lede">No phone number or PSTN account is needed. You explicitly join this local WebRTC session and can hang up at any time.</p>
</header>
<section class="card setup">
<div class="mode-row">
<label><input type="radio" name="mode" value="react" checked> ReAct plan</label>
<label><input type="radio" name="mode" value="direct"> Direct parameters</label>
</div>
<div id="react-fields">
<label for="task">Natural-language task</label>
<textarea id="task" rows="4">Call me to arrange a dental checkup for Jane Doe. I did not include a time, so ask me for it, repeat the details, and save the confirmation I provide.</textarea>
</div>
<div id="direct-fields" hidden>
<label for="callee-name">Participant name</label>
<input id="callee-name" value="Jane Doe">
<label for="goal">Goal</label>
<textarea id="goal" rows="2">Collect and confirm Jane Doe's preferred dental-checkup time.</textarea>
<label for="context">Context</label>
<textarea id="context" rows="2">Tuesday afternoon from 2pm to 4pm is available.</textarea>
<label for="instructions">Instructions</label>
<textarea id="instructions" rows="3">Ask for one exact time, repeat it, request confirmation, and save the confirmed time and confirmation number with complete_task.</textarea>
</div>
<div class="actions">
<button id="start" type="button">Join call and enable microphone</button>
<button id="hangup" class="secondary" type="button" disabled>Hang up</button>
</div>
<p class="privacy">Provider credentials stay on the local server. The browser receives only the SDP answer; no credential is embedded in this page.</p>
</section>
<section class="card status-card">
<div><span class="label">Status</span><strong id="status">idle</strong></div>
<div><span class="label">Call ID</span><code id="call-id"></code></div>
<div><span class="label">Transport</span><span id="transport">WebRTC · not connected</span></div>
</section>
<section class="card conversation">
<h2>Audio transcript captions</h2>
<div id="transcript" aria-live="polite"></div>
<div class="actions">
<button id="commit-audio" class="secondary" type="button" disabled>Finish speaking</button>
</div>
<p class="privacy">Speak into the microphone, then choose “Finish speaking.” User meaning comes only from server ASR over the microphone RTP track. The data channel carries this control event and accessibility captions; it never supplies the canonical user transcript.</p>
</section>
<section class="card evidence">
<h2>Session evidence</h2>
<pre id="evidence">Start a call to see the negotiated local aiortc transport record.</pre>
</section>
<audio id="remote-audio" autoplay></audio>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #14221d;
background: #edf3ef;
}
* { box-sizing: border-box; }
body { margin: 0; }
main { width: min(880px, calc(100% - 32px)); margin: 48px auto 72px; }
header { margin-bottom: 28px; }
h1 { margin: 6px 0 12px; font-size: clamp(2rem, 6vw, 4.2rem); line-height: 0.98; letter-spacing: -0.045em; }
h2 { margin-top: 0; font-size: 1.15rem; }
.eyebrow, .label { color: #356450; text-transform: uppercase; letter-spacing: 0.09em; font-size: 0.76rem; font-weight: 750; }
.lede { max-width: 700px; color: #41534b; font-size: 1.08rem; line-height: 1.55; }
.card { background: #fff; border: 1px solid #cad8d0; border-radius: 18px; padding: 22px; margin: 14px 0; box-shadow: 0 12px 32px rgba(29, 67, 50, 0.06); }
.mode-row, .actions, .text-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.mode-row { margin-bottom: 18px; }
label { display: block; margin: 12px 0 6px; font-weight: 650; }
input, textarea, button { font: inherit; }
input:not([type="radio"]), textarea { width: 100%; border: 1px solid #aebfb6; border-radius: 10px; padding: 11px 12px; background: #fbfdfc; color: inherit; }
textarea { resize: vertical; }
button { border: 0; border-radius: 999px; padding: 11px 18px; background: #176b49; color: white; font-weight: 720; cursor: pointer; }
button.secondary { background: #dde8e2; color: #254537; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.actions { margin-top: 20px; }
.privacy { margin-bottom: 0; color: #68776f; font-size: 0.86rem; }
.status-card { display: grid; grid-template-columns: 0.7fr 1fr 1.2fr; gap: 16px; }
.status-card div { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
code { overflow-wrap: anywhere; }
#transcript { min-height: 110px; max-height: 330px; overflow: auto; padding: 12px; background: #f3f7f4; border-radius: 12px; }
.turn { margin: 0 0 10px; line-height: 1.45; }
.turn b { color: #176b49; }
.text-row { flex-wrap: nowrap; }
.text-row input { flex: 1; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; color: #31463d; font-size: 0.82rem; }
@media (max-width: 680px) {
main { margin-top: 24px; }
.status-card { grid-template-columns: 1fr; }
.text-row { flex-wrap: wrap; }
}
+173
View File
@@ -0,0 +1,173 @@
import json
from types import SimpleNamespace
import pytest
from agent import conversation_turn, direct_plan, react_plan
class FakeUsage:
def model_dump(self, **_kwargs):
return {"prompt_tokens": 40, "completion_tokens": 20, "total_tokens": 60}
class FakeResponse:
def __init__(self, content):
self.id = "provider-response-123"
self.model = "planner-test"
self.created = 123456
self.usage = FakeUsage()
self.choices = [
SimpleNamespace(
message=SimpleNamespace(content=content),
finish_reason="stop",
)
]
def model_dump(self, **_kwargs):
return {
"id": self.id,
"model": self.model,
"created": self.created,
"choices": [
{
"finish_reason": "stop",
"message": {"role": "assistant", "content": self.choices[0].message.content},
}
],
"usage": self.usage.model_dump(),
}
class FakeCompletions:
def __init__(self, values):
self.values = iter(values)
def create(self, **kwargs):
assert kwargs["response_format"] == {"type": "json_object"}
assert kwargs["temperature"] == 0
return FakeResponse(json.dumps(next(self.values)))
class FakeClient:
def __init__(self, values):
self.chat = SimpleNamespace(completions=FakeCompletions(values))
@pytest.fixture(autouse=True)
def provider_environment(monkeypatch):
monkeypatch.setenv("PHONE_MODEL_PROVIDER", "ark")
monkeypatch.setenv("ARK_API_KEY", "test-key-not-retained")
def test_direct_plan_requires_fixed_parameters_and_has_no_planner_receipt():
with pytest.raises(ValueError, match="context"):
direct_plan(callee_name="Jane", goal="Confirm", context="", instructions="Ask")
plan = direct_plan(callee_name="Jane", goal="Confirm", context="Tuesday", instructions="Ask")
assert plan.planner_receipt is None
assert "confirmation code" in plan.opening_line
def test_react_plan_retains_real_raw_receipt_and_trace():
client = FakeClient(
[
{
"callee_name": "Jane",
"goal": "Confirm a dental checkup time",
"context": "The time and code are absent.",
"instructions": "Ask for time and code, repeat both, then complete_task.",
"opening_line": "What exact time and confirmation code do you confirm?",
"missing_information": ["appointment time", "confirmation code"],
"decision_summary": "Collect both omitted fields by voice.",
}
]
)
plan = react_plan(
"Call Jane, but I forgot the time and code",
client=client,
model="planner-test",
provider_name="injected-test",
)
assert plan.missing_information == ["appointment time", "confirmation code"]
assert [item["stage"] for item in plan.trace] == ["observation", "reason", "action"]
receipt = plan.planner_receipt
assert receipt["provider_response_id"] == "provider-response-123"
assert receipt["usage"]["total_tokens"] == 60
assert receipt["raw_response"]["choices"][0]["message"]["content"]
assert receipt["fallback_used"] is False
assert "test-key-not-retained" not in json.dumps(receipt)
def test_conversation_requires_explicit_confirmation_for_completion():
plan = direct_plan(
callee_name="Jane",
goal="Confirm a time",
context="Tuesday afternoon",
instructions="Ask and confirm",
)
client = FakeClient(
[
{
"assistant_message": "Thanks. I recorded Tuesday at 3 PM and Maple 7.",
"explicit_confirmation_observed": True,
"should_complete": True,
"completion": {
"result": "Local confirmation recorded.",
"appointment_time": "Tuesday at 3 PM",
"confirmation_number": "MAPLE-7",
"notes": "No external organization was contacted or booking made.",
},
}
]
)
result = conversation_turn(
plan,
[],
"I explicitly confirm Tuesday at 3 PM and Maple seven.",
client=client,
model="planner-test",
provider_name="injected-test",
)
assert result["should_complete"] is True
assert result["completion"]["confirmation_number"] == "MAPLE-7"
assert result["llm_receipt"]["purpose"] == "post_asr_dialogue"
def test_model_errors_propagate_without_fallback():
class BrokenCompletions:
def create(self, **_kwargs):
raise RuntimeError("provider unavailable")
client = SimpleNamespace(chat=SimpleNamespace(completions=BrokenCompletions()))
with pytest.raises(RuntimeError, match="provider unavailable"):
react_plan("Call Jane and ask for the missing time", client=client, model="planner-test")
def test_conversation_turn_rejects_none_critical_completion_fields():
plan = direct_plan(
callee_name="Jane",
goal="Confirm a time",
context="Tuesday afternoon",
instructions="Ask and confirm",
)
client = FakeClient(
[
{
"assistant_message": "Thanks.",
"explicit_confirmation_observed": True,
"should_complete": True,
"completion": {
"result": "Local confirmation recorded.",
"appointment_time": None,
"confirmation_number": "MAPLE-7",
"notes": "No external organization was contacted or booking made.",
},
}
]
)
with pytest.raises(ValueError, match="without both critical fields"):
conversation_turn(
plan,
[],
"I explicitly confirm Maple seven.",
client=client,
model="planner-test",
provider_name="injected-test",
)
+20
View File
@@ -0,0 +1,20 @@
import struct
from speech import pcm16_wav, read_pcm16_wav, sha256_bytes
def test_pcm_wav_round_trip_is_exact():
pcm = b"".join(struct.pack("<h", sample) for sample in (0, 100, -100, 32767, -32768))
wav = pcm16_wav(pcm, 16_000)
decoded, rate = read_pcm16_wav(wav)
assert decoded == pcm
assert rate == 16_000
assert len(sha256_bytes(wav)) == 64
def test_speech_source_has_no_mock_or_tone_fallback():
source = __import__("pathlib").Path(__file__).with_name("speech.py").read_text()
assert "SystemSpeechSynthesizer" in source
assert "WhisperASR" in source
assert 'fallback_used": False' in source
assert "440" not in source
@@ -0,0 +1,52 @@
import json
from pathlib import Path
from verify_acceptance import check_llm_receipt
def test_llm_receipt_tamper_is_rejected():
content = '{"ok":true}'
request = {"model": "model", "messages": [{"role": "user", "content": "safe"}]}
raw = {
"id": "response-1",
"model": "model",
"choices": [{"finish_reason": "stop", "message": {"content": content}}],
"usage": {"total_tokens": 2},
}
canonical = lambda value: json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
import hashlib
receipt = {
"execution": "real_external_llm",
"external_request_completed": True,
"provider_response_id": "response-1",
"provider_model": "model",
"requested_model": "model",
"finish_reason": "stop",
"usage": {"total_tokens": 2},
"latency_seconds": 1,
"request": request,
"request_sha256": hashlib.sha256(canonical(request).encode()).hexdigest(),
"raw_response": raw,
"raw_response_sha256": hashlib.sha256(canonical(raw).encode()).hexdigest(),
"response_content": content,
"response_content_sha256": hashlib.sha256(content.encode()).hexdigest(),
"mock": False,
"probe_only": False,
"fallback_used": False,
"credential_fields_retained": False,
}
receipt["provider_response_id"] = "tampered"
failures = []
check_llm_receipt(receipt, "test: ", failures)
assert any("response ID" in failure for failure in failures)
def test_verifier_requires_retained_media_artifacts():
from verify_acceptance import REQUIRED_ARTIFACTS
assert "media/direct/microphone_rtp_asr_input.wav" in REQUIRED_ARTIFACTS
assert "media/react/agent_02.wav" in REQUIRED_ARTIFACTS
assert all(not Path(item).is_absolute() for item in REQUIRED_ARTIFACTS)
+87
View File
@@ -0,0 +1,87 @@
from pathlib import Path
import webrtc_app
from fastapi.testclient import TestClient
def direct_payload():
return {
"mode": "direct",
"callee_name": "Jane",
"goal": "Confirm a time and code",
"context": "Tuesday afternoon",
"instructions": "Ask for a time and confirmation code, then save both.",
}
def test_direct_record_starts_fail_closed_with_audio_provenance_slots():
webrtc_app.CALLS.clear()
client = TestClient(webrtc_app.app)
created = client.post("/api/calls", json=direct_payload())
assert created.status_code == 200
call_id = created.json()["call_id"]
record = client.get(f"/api/calls/{call_id}").json()
assert record["acceptance"]["passed"] is False
assert record["models"]["llm_receipts"] == []
assert record["models"]["asr_receipts"] == []
assert record["models"]["tts_receipts"] == []
assert record["transport"]["pstn_used"] is False
assert record["transport"]["e164_required"] is False
def test_session_endpoint_rejects_non_sdp_without_creating_a_peer():
webrtc_app.CALLS.clear()
client = TestClient(webrtc_app.app)
call_id = client.post("/api/calls", json=direct_payload()).json()["call_id"]
response = client.post(
f"/api/calls/{call_id}/session",
content="not sdp",
headers={"Content-Type": "text/plain"},
)
assert response.status_code == 415
assert call_id not in webrtc_app.PEERS
def test_react_rejects_an_empty_task_before_provider_call():
client = TestClient(webrtc_app.app)
response = client.post("/api/calls", json={"mode": "react", "task": ""})
assert response.status_code == 422
def test_browser_code_has_no_semantic_text_or_browser_speech_bypass():
script = (Path(__file__).parent / "static" / "app.js").read_text()
assert "getUserMedia" in script
assert "client.audio.commit" in script
assert "agent.caption" in script
assert "user.message" not in script
assert "speechSynthesis" not in script
assert "SpeechRecognition" not in script
def test_acceptance_gate_names_are_exact_and_fail_closed():
client = TestClient(webrtc_app.app)
call_id = client.post("/api/calls", json=direct_payload()).json()["call_id"]
checks = client.get(f"/api/calls/{call_id}").json()["acceptance"]["checks"]
assert set(checks) == {
"sdp_offer_answer_negotiated",
"ice_connected",
"data_channel_open",
"browser_microphone_track",
"server_downlink_audio_track",
"outbound_audio_rtp",
"inbound_audio_rtp",
"server_buffered_microphone_rtp",
"real_asr_consumed_microphone_audio",
"external_react_planner_or_fixed_direct_control",
"real_external_post_asr_dialogue",
"real_tts_assets_synthesized",
"tts_audio_transmitted_on_downlink",
"media_is_canonical_transcript_source",
"data_channel_is_control_and_caption_only",
"missing_fields_were_clarified_aloud",
"explicit_confirmation_observed",
"structured_completion_saved",
"no_mock_probe_or_fallback",
"privacy_boundary_preserved",
}
assert not all(checks.values())
+6
View File
@@ -0,0 +1,6 @@
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
@@ -0,0 +1,24 @@
{
"schema_version": 2,
"experiment": "phone-agent-webrtc",
"executed_at_utc": "2026-07-31T11:24:42.453962+00:00",
"control": "fixed parameters -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
"treatment": "natural task -> real LLM ReAct plan -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
"checks": {
"same_browser_aiortc_webrtc_transport": true,
"no_pstn_or_e164": true,
"direct_required_fixed_parameters": true,
"react_accepted_only_natural_language_task": true,
"react_detected_missing_information": true,
"react_has_observe_reason_act_trace": true,
"react_used_real_external_planner": true,
"both_used_microphone_rtp_asr": true,
"both_used_real_downlink_tts": true,
"both_used_external_post_asr_dialogue": true,
"data_channel_never_supplied_user_semantics": true,
"both_completed_all_audio_gates": true,
"both_saved_confirmed_structured_fields": true
},
"passed": true,
"conclusion": "Both arms completed the same real bidirectional browser/aiortc audio path. The treatment additionally used an external ARK ReAct planning receipt to detect missing facts; the control used fixed parameters."
}
@@ -0,0 +1,328 @@
{
"schema_version": 3,
"experiment": "phone-agent-webrtc",
"call_id": "rtc_794c4b8e0481466fbf23",
"created_at_utc": "2026-07-31T11:23:09.666702+00:00",
"finished_at_utc": "2026-07-31T11:23:53.431450+00:00",
"status": "completed",
"mode": "direct",
"input_contract": {
"fields_supplied_by_caller": [
"callee_name",
"goal",
"context",
"instructions"
],
"natural_language_task": ""
},
"plan": {
"mode": "direct",
"callee_name": "Jane Doe",
"goal": "Collect and confirm Jane Doe's preferred dental-checkup time and confirmation code.",
"context": "Tuesday afternoon from 2pm to 4pm is available; the user must supply the exact time and code by voice.",
"instructions": "Ask for one exact time and a confirmation code. Repeat both, require explicit confirmation, then call complete_task with only the ASR-confirmed details.",
"opening_line": "Hello Jane Doe. Please state the exact appointment time and confirmation code, then explicitly confirm both.",
"missing_information": [],
"trace": [
{
"stage": "observation",
"summary": "Caller supplied all call parameters."
},
{
"stage": "action",
"summary": "Open a WebRTC voice session with the fixed parameters."
}
],
"planner_model": null
},
"models": {
"planner": null,
"dialogue_models": [
"ark:doubao-seed-1-6-flash-250615"
],
"llm_receipts": [
{
"schema_version": 1,
"purpose": "post_asr_dialogue",
"execution": "real_external_llm",
"provider": "ark",
"requested_model": "doubao-seed-1-6-flash-250615",
"provider_model": "doubao-seed-1-6-flash-250615",
"provider_response_id": "0217854970227438de59cc126156ef9419088f6fa2f8da927049f",
"finish_reason": "stop",
"usage": {
"completion_tokens": 181,
"prompt_tokens": 392,
"total_tokens": 573,
"completion_tokens_details": {
"reasoning_tokens": 84
},
"prompt_tokens_details": {
"cached_tokens": 0
}
},
"started_at_utc": "2026-07-31T11:23:42.156339+00:00",
"finished_at_utc": "2026-07-31T11:23:44.170815+00:00",
"latency_seconds": 2.014451,
"request": {
"model": "doubao-seed-1-6-flash-250615",
"messages": [
{
"role": "system",
"content": "You are the voice Agent in a short local browser call. The user text below came only from ASR over the browser microphone RTP track. Return only JSON with assistant_message, explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing result, appointment_time, confirmation_number, notes. If the user states an exact time, a confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious spoken code words/digits into a concise code, and repeat both details in assistant_message. Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an external action occurred. For a completed turn, completion.result must be exactly 'Local confirmation recorded.' and completion.notes must be exactly 'No external organization was contacted or booking made.' Goal: Collect and confirm Jane Doe's preferred dental-checkup time and confirmation code.\nContext: Tuesday afternoon from 2pm to 4pm is available; the user must supply the exact time and code by voice.\nInstructions: Ask for one exact time and a confirmation code. Repeat both, require explicit confirmation, then call complete_task with only the ASR-confirmed details."
},
{
"role": "user",
"content": "{\"latest_user_asr_transcript\":\"Confirmation called as Maple 7. I explicitly confirm Tuesday at 3pm and confirmation code Maple 7.\",\"prior_audio_transcript\":[{\"purpose\":\"missing_field_clarification\",\"source\":\"tts.webrtc_downlink\",\"speaker\":\"agent\",\"text\":\"Hello Jane Doe. Please state the exact appointment time and confirmation code, then explicitly confirm both.\",\"utterance_id\":\"tts_01\"}]}"
}
],
"response_format": {
"type": "json_object"
},
"temperature": 0,
"max_tokens": 700
},
"request_sha256": "625905fa5da9a4cdc0bd711793201abc585ea5434e62f2a03024a128a3cd40d5",
"raw_response": {
"id": "0217854970227438de59cc126156ef9419088f6fa2f8da927049f",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "{\"assistant_message\": \"Confirmed appointment time: Tuesday at 3pm, Confirmation code: Maple 7. Explicit confirmation observed: true\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3pm\", \"confirmation_number\": \"Maple 7\", \"notes\": \"No external organization was contacted or booking made.\"}}",
"role": "assistant",
"reasoning_content": "Got it, let's see. The user provided the exact time as Tuesday at 3pm and the confirmation code as Maple 7, and explicitly confirmed both. So I need to construct the JSON with assistant_message repeating the details, explicit_confirmation_observed as true, should_complete as true, and completion with result \"Local confirmation recorded.\" and notes \"No external organization was contacted or booking made.\""
}
}
],
"created": 1785497024,
"model": "doubao-seed-1-6-flash-250615",
"object": "chat.completion",
"service_tier": "default",
"usage": {
"completion_tokens": 181,
"prompt_tokens": 392,
"total_tokens": 573,
"completion_tokens_details": {
"reasoning_tokens": 84
},
"prompt_tokens_details": {
"cached_tokens": 0
}
}
},
"raw_response_sha256": "d2fb0d5702e0cf7905675047a5bc9ed7330a9d1ac02f6f219f8442ad29794fe8",
"response_content": "{\"assistant_message\": \"Confirmed appointment time: Tuesday at 3pm, Confirmation code: Maple 7. Explicit confirmation observed: true\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3pm\", \"confirmation_number\": \"Maple 7\", \"notes\": \"No external organization was contacted or booking made.\"}}",
"response_content_sha256": "5cec9a348fdd2a028db79b95024befc152676fd85169a254ca2007888b571897",
"external_request_completed": true,
"mock": false,
"probe_only": false,
"fallback_used": false,
"credential_fields_retained": false
}
],
"asr_receipts": [
{
"schema_version": 1,
"operation": "asr",
"execution": "real_local_inference",
"provider": "local OpenAI Whisper",
"model": "whisper-tiny",
"checkpoint_name": "tiny.pt",
"checkpoint_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
"runtime": {
"python": "3.11.4",
"torch": "2.4.0",
"openai_whisper": "20231106"
},
"input_source": "browser_microphone_rtp",
"input_sample_rate_hz": 16000,
"input_channels": 1,
"input_pcm_bytes": 774368,
"input_wav_bytes": 774412,
"input_wav_sha256": "bc5072b92d30d382b7439f56b1453946cbaa5f9de80c7a24eb98a1f8615c1d34",
"input_duration_seconds": 24.199,
"language": "en",
"transcript": "Confirmation called as Maple 7. I explicitly confirm Tuesday at 3pm and confirmation code Maple 7.",
"transcript_sha256": "e34b0bd2ddd7f005484a59cc83d36b5c4147762c45e1ffb7f1fd2c9f0e8e428e",
"model_load_seconds": 0.314312,
"inference_seconds": 0.446881,
"latency_seconds": 1.789071,
"retained_safe_fixture_path": "media/direct/microphone_rtp_asr_input.wav",
"external_request": false,
"mock": false,
"probe_only": false,
"fallback_used": false
}
],
"tts_receipts": [
{
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say",
"model": "operating-system speech synthesizer",
"voice": "Samantha",
"sample_rate_hz": 8000,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": 52080,
"duration_seconds": 6.51,
"wav_bytes": 104238,
"wav_sha256": "c94a2a0b563c9ac55fe472c79b0f94adcb72bdaccd7ecc424f172c35b5af8378",
"pcm_sha256": "de63bfbfc726731f84522f16a33357e13dbb34ab6c21596055503d17d039db40",
"latency_seconds": 0.972527,
"engine": {
"name": "say",
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
},
"decoder": {
"name": "ffmpeg",
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
},
"network_used": false,
"mock": false,
"probe_only": false,
"fallback_used": false,
"utterance_id": "tts_01",
"purpose": "missing_field_clarification",
"retained_safe_fixture_path": "media/direct/agent_01.wav",
"text_sha256": "b89b930d8884dec9a8261e6c4952e430f7146bbdcde40423fe8eecede205bec4",
"enqueued_on_webrtc_track": true,
"transmitted_samples": 52080,
"delivery_complete": true,
"delivered_pcm_sha256": "de63bfbfc726731f84522f16a33357e13dbb34ab6c21596055503d17d039db40"
},
{
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say",
"model": "operating-system speech synthesizer",
"voice": "Samantha",
"sample_rate_hz": 8000,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": 66036,
"duration_seconds": 8.2545,
"wav_bytes": 132150,
"wav_sha256": "8dbef003ff03b50e7c29dce5aad788b74500d6b2882c39388a7712cc5c52081d",
"pcm_sha256": "a6acdc60071259f46379dc98d4eaec583dbcb0f0b3c5ed60587e8f743a22a5dc",
"latency_seconds": 0.97609,
"engine": {
"name": "say",
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
},
"decoder": {
"name": "ffmpeg",
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
},
"network_used": false,
"mock": false,
"probe_only": false,
"fallback_used": false,
"utterance_id": "tts_02",
"purpose": "confirmed_completion",
"retained_safe_fixture_path": "media/direct/agent_02.wav",
"text_sha256": "7021250895bc4dbad83d3cc06ebe120bcc15175baa9fa6cf5d760fd3b1682254",
"enqueued_on_webrtc_track": true,
"transmitted_samples": 66036,
"delivery_complete": true,
"delivered_pcm_sha256": "a6acdc60071259f46379dc98d4eaec583dbcb0f0b3c5ed60587e8f743a22a5dc"
}
]
},
"transport": {
"kind": "webrtc",
"pstn_used": false,
"e164_required": false,
"sdp_negotiated": true,
"offer_sha256": "1bc7eaf0f30f4e506782cfcf80ff1108b6a0a7500e1ee6bbfa3c7faf853c5373",
"answer_sha256": "7e7b70627489f0bdd3aa17890a32af0c498fc25c861b5d947d448d57265116b7",
"ice_connection_state": "connected",
"ice_connected_observed": true,
"data_channel_open": true,
"local_audio_track": true,
"remote_audio_track": true,
"rtc_stats": {
"inbound_packets": 1869,
"inbound_bytes": 188849,
"outbound_packets": 1869,
"outbound_bytes": 76286
},
"server_received_audio_frames": 1865,
"server_received_audio_samples": 1790400,
"server_received_audio_pcm_bytes": 1193568,
"server_sent_tts_samples": 118116
},
"privacy": {
"safe_synthetic_acceptance": true,
"private_audio_retained": false,
"private_transcripts_retained": false,
"safe_synthetic_media_retained": true
},
"event_counts": {
"client.audio.commit": 1,
"rtc.ready": 6,
"rtc.stats": 51
},
"transcript": [
{
"speaker": "agent",
"text": "Hello Jane Doe. Please state the exact appointment time and confirmation code, then explicitly confirm both.",
"source": "tts.webrtc_downlink",
"utterance_id": "tts_01",
"purpose": "missing_field_clarification"
},
{
"speaker": "user",
"text": "Confirmation called as Maple 7. I explicitly confirm Tuesday at 3pm and confirmation code Maple 7.",
"source": "asr.microphone_rtp",
"asr_receipt_index": 0
},
{
"speaker": "agent",
"text": "Confirmed appointment time: Tuesday at 3pm, Confirmation code: Maple 7. Explicit confirmation observed: true",
"source": "tts.webrtc_downlink",
"utterance_id": "tts_02",
"purpose": "confirmed_completion"
}
],
"explicit_confirmation_observed": true,
"completion": {
"result": "Local confirmation recorded.",
"appointment_time": "Tuesday at 3pm",
"confirmation_number": "Maple 7",
"notes": "No external organization was contacted or booking made.",
"saved_at_utc": "2026-07-31T11:23:45.149157+00:00",
"tool": "complete_task"
},
"errors": [],
"acceptance": {
"checks": {
"sdp_offer_answer_negotiated": true,
"ice_connected": true,
"data_channel_open": true,
"browser_microphone_track": true,
"server_downlink_audio_track": true,
"outbound_audio_rtp": true,
"inbound_audio_rtp": true,
"server_buffered_microphone_rtp": true,
"real_asr_consumed_microphone_audio": true,
"external_react_planner_or_fixed_direct_control": true,
"real_external_post_asr_dialogue": true,
"real_tts_assets_synthesized": true,
"tts_audio_transmitted_on_downlink": true,
"media_is_canonical_transcript_source": true,
"data_channel_is_control_and_caption_only": true,
"missing_fields_were_clarified_aloud": true,
"explicit_confirmation_observed": true,
"structured_completion_saved": true,
"no_mock_probe_or_fallback": true,
"privacy_boundary_preserved": true
},
"passed": true
},
"finish_reason": "automated_safe_acceptance"
}
@@ -0,0 +1,213 @@
{
"schema_version": 2,
"experiment": "phone-agent-webrtc",
"run_id": "phone-agent-webrtc-audio-20260731-v1",
"generated_at_utc": "2026-07-31T11:24:42.523874+00:00",
"result": "passed",
"execution": "live_browser_aiortc_asr_external_llm_tts_webrtc",
"canonical_safe_synthetic_fixture": true,
"pstn_used": false,
"e164_required": false,
"credentials_saved": false,
"private_audio_or_transcripts_saved": false,
"safe_fixture_provenance": {
"direct": {
"kind": "safe_synthetic_browser_microphone_fixture",
"contains_private_data": false,
"sample_rate_hz": 16000,
"duration_seconds": 15.730625,
"wav_bytes": 503424,
"wav_sha256": "0def429a125a2cc14f0461c9361a138b88d9cc0103040a9f018ef8fea2f2d46a",
"leading_silence_seconds": 4.0,
"trailing_silence_seconds": 3.0,
"synthesis": {
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say",
"model": "operating-system speech synthesizer",
"voice": "Samantha",
"sample_rate_hz": 16000,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": 139690,
"duration_seconds": 8.730625,
"wav_bytes": 279458,
"wav_sha256": "795fc97e299ecb3bed310e3bde26f1500bf08b9da32d6a276ecadb9dd29b5103",
"pcm_sha256": "74690f7b0ee36186bd8e6cf378c40644219f71c96b8f199185ce5e136bf99d63",
"latency_seconds": 0.962707,
"engine": {
"name": "say",
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
},
"decoder": {
"name": "ffmpeg",
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
},
"network_used": false,
"mock": false,
"probe_only": false,
"fallback_used": false
},
"artifact_path": "fixtures/direct_microphone.wav",
"text_sha256": "b1c6b73a17ff9a5afcee66cfc2944dd4b8e9d3019d362a1907eeeb4ba0a0705d"
},
"react": {
"kind": "safe_synthetic_browser_microphone_fixture",
"contains_private_data": false,
"sample_rate_hz": 16000,
"duration_seconds": 15.440375,
"wav_bytes": 494136,
"wav_sha256": "df1acb0b3db90e2fed6814cb880dabb028cd73d0a1837efc8493a97071f0e7f9",
"leading_silence_seconds": 4.0,
"trailing_silence_seconds": 3.0,
"synthesis": {
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say",
"model": "operating-system speech synthesizer",
"voice": "Samantha",
"sample_rate_hz": 16000,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": 135046,
"duration_seconds": 8.440375,
"wav_bytes": 270170,
"wav_sha256": "c23d3bb0b6f14be7f690ee1a4499b1a84d4edf0b3d3c3cadad5212b0e70a8552",
"pcm_sha256": "fa9f85699e9d3016eae17acd751dbefce05e3eddf980a842d4d638fe9a5d0928",
"latency_seconds": 0.956989,
"engine": {
"name": "say",
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
},
"decoder": {
"name": "ffmpeg",
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
},
"network_used": false,
"mock": false,
"probe_only": false,
"fallback_used": false
},
"artifact_path": "fixtures/react_microphone.wav",
"text_sha256": "001812c3c3bf8a0372be1399852e155a940d8c57fc04ccbc9fe7ac30a86cc725"
}
},
"environment": {
"platform": "macOS-26.3-arm64-arm-64bit",
"python": "3.11.4",
"chrome_version": "Google Chrome 150.0.7871.188",
"chrome_executable_sha256": "2837ae0a82f64cf541af357ce554cc7d0c62d119da2b6b5f3eb64ce7f3d63d4e",
"planner_provider": "ark",
"planner_model": "doubao-seed-1-6-flash-250615",
"dialogue_model": "doubao-seed-1-6-flash-250615",
"media_peer": "aiortc",
"packages": {
"aiortc": "1.15.0",
"av": "17.1.0",
"openai": "2.48.0",
"playwright": "1.61.0"
}
},
"source_sha256": {
"chapter6/phone-agent/agent.py": "fa73bfe9182f39d22243065edc02565da33b5957c6f17a9f886fc65ea42561f3",
"chapter6/phone-agent/speech.py": "ca959ce60eabd6b629a6a238ceef1b8c6bd955f6921cfc8a273a5c86bb00e008",
"chapter6/phone-agent/webrtc_app.py": "260ab64d9ba61a1dcf00cb4f034bcfd115755c11ce0558dc8b610f239f9ed25b",
"chapter6/phone-agent/run_acceptance.py": "c33346520fba6a8d8b6385ff0e3052b3c51be7d1ff2fd4f5fbbbf831a02ba012",
"chapter6/phone-agent/verify_acceptance.py": "83b459e48f337e4457237b629e4f308694fa491893cb41826128df6f6bab02a7",
"chapter6/phone-agent/demo.py": "45ef0878b524ae39faf85693b4ea302746ff2e9ee80ce9aced104e2dbfa67e18",
"chapter6/phone-agent/direct_call.py": "74c96ba2811aa81c9d7fabad9f7177df84a6544300ab26513e3a39f8fccf7f59",
"chapter6/phone-agent/env.example": "ae6ec336e02041e10f10df39846b63aff1a8a125610e7a2069e3ea4d1e193182",
"chapter6/phone-agent/requirements.txt": "451102c65e1b3084096aa59e10816a7a74be20c32d6f3ceb426a73c1c2c6788d",
"chapter6/phone-agent/test_agent.py": "8f77744dc6c38e0617b479aff2941bc1b3dcfa78cfac93ccba47702ae8022ad4",
"chapter6/phone-agent/test_speech.py": "03a64e7c1f8182f94364c109bf1a640f1609f8cdae1ba3d0f34ccfd41900512c",
"chapter6/phone-agent/test_webrtc_app.py": "cf5c2f9f14a65a8fcbd643657d48c9c1d315229609094e30a7642315a1079227",
"chapter6/phone-agent/test_verify_acceptance.py": "b2082309ed3409875dac310147894fea50bba9b3909928e2de21b37ed9390912",
"chapter6/phone-agent/static/index.html": "fc6020510e90eed59d517139b4cc291bb6aa72be4c16d05714ca134cf3a821f8",
"chapter6/phone-agent/static/app.js": "dc1ba08f21bccafc43a5dcc79e57af2dca2bc350fb0021b7e458ce5654e6d04d",
"chapter6/phone-agent/static/style.css": "1586e93230ec1a77a5141e05f72c560e193cebbfe43115e26ba81ebd06f00908",
"chapter6/phone-agent/README.md": "ad6e93082358a1694b8dc3d73af82feed8d1948efb3a96fde115ac1b1a8e9358",
"chapter6/README.md": "84803cef75fc522d21ee9086ce9d8471b1eec99394513a232d420130cfeb580f",
"book/chapter6.md": "8e1859fb1b163c3620dac7da4d7560f5d0dce06613f025aa90140e31955d5d73",
"pyproject.toml": "b04778d69977f0843def0c26930ab6c5f32706746733495e95647e83c19c2a1b",
"uv.lock": "d25d18bb7c0a5d93c888547f19a420dc50719357a3dba813094a6c233140691c"
},
"artifact_sha256": {
"comparison.json": "5b73934cbc776c01cc3aac5d29dbd7026d454a7d7b9d7654c9834339a4bc5a49",
"direct.json": "360c613574692e9d01d60e6aefb3faac34a1cf38dc9a14f1bd3b354055db2912",
"fixtures/direct_microphone.wav": "0def429a125a2cc14f0461c9361a138b88d9cc0103040a9f018ef8fea2f2d46a",
"fixtures/react_microphone.wav": "df1acb0b3db90e2fed6814cb880dabb028cd73d0a1837efc8493a97071f0e7f9",
"media/direct/agent_01.wav": "c94a2a0b563c9ac55fe472c79b0f94adcb72bdaccd7ecc424f172c35b5af8378",
"media/direct/agent_02.wav": "8dbef003ff03b50e7c29dce5aad788b74500d6b2882c39388a7712cc5c52081d",
"media/direct/microphone_rtp_asr_input.wav": "bc5072b92d30d382b7439f56b1453946cbaa5f9de80c7a24eb98a1f8615c1d34",
"media/react/agent_01.wav": "36569fa63273d1ed2e7ff1eec9e3be7fc5e878e935bd58e7ea25bc5a6011b473",
"media/react/agent_02.wav": "e8a227f303baa45cdcdaa20700c58f2405adf06725394eafb9364b12079ea936",
"media/react/microphone_rtp_asr_input.wav": "d13d43693d8af1b579ff7a5f458f7bf0940fbf0daec9ddebc9f279ed1317d73a",
"react.json": "5889cf6582b04ea93b81a46a474fc6fbd4e8e8b4590f100c1bd056be91e3907f",
"server.log": "1b6cafe4291015cda74a79e49ef5fcad2c485fc7a4882312b183eefd97bbc85e"
},
"redaction": {
"scanned_file_count": 12,
"environment_credential_value_matches": [],
"passed": true
},
"cleanup": {
"browser_contexts_closed": true,
"server_process_terminated": true,
"raw_private_media_created": false
},
"acceptance": {
"direct": {
"checks": {
"sdp_offer_answer_negotiated": true,
"ice_connected": true,
"data_channel_open": true,
"browser_microphone_track": true,
"server_downlink_audio_track": true,
"outbound_audio_rtp": true,
"inbound_audio_rtp": true,
"server_buffered_microphone_rtp": true,
"real_asr_consumed_microphone_audio": true,
"external_react_planner_or_fixed_direct_control": true,
"real_external_post_asr_dialogue": true,
"real_tts_assets_synthesized": true,
"tts_audio_transmitted_on_downlink": true,
"media_is_canonical_transcript_source": true,
"data_channel_is_control_and_caption_only": true,
"missing_fields_were_clarified_aloud": true,
"explicit_confirmation_observed": true,
"structured_completion_saved": true,
"no_mock_probe_or_fallback": true,
"privacy_boundary_preserved": true
},
"passed": true
},
"react": {
"checks": {
"sdp_offer_answer_negotiated": true,
"ice_connected": true,
"data_channel_open": true,
"browser_microphone_track": true,
"server_downlink_audio_track": true,
"outbound_audio_rtp": true,
"inbound_audio_rtp": true,
"server_buffered_microphone_rtp": true,
"real_asr_consumed_microphone_audio": true,
"external_react_planner_or_fixed_direct_control": true,
"real_external_post_asr_dialogue": true,
"real_tts_assets_synthesized": true,
"tts_audio_transmitted_on_downlink": true,
"media_is_canonical_transcript_source": true,
"data_channel_is_control_and_caption_only": true,
"missing_fields_were_clarified_aloud": true,
"explicit_confirmation_observed": true,
"structured_completion_saved": true,
"no_mock_probe_or_fallback": true,
"privacy_boundary_preserved": true
},
"passed": true
},
"comparison_passed": true
}
}
@@ -0,0 +1,412 @@
{
"schema_version": 3,
"experiment": "phone-agent-webrtc",
"call_id": "rtc_f8c984610b434ce4abf7",
"created_at_utc": "2026-07-31T11:24:00.115961+00:00",
"finished_at_utc": "2026-07-31T11:24:40.071438+00:00",
"status": "completed",
"mode": "react",
"input_contract": {
"fields_supplied_by_caller": [
"task"
],
"natural_language_task": "Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm."
},
"plan": {
"mode": "react",
"callee_name": "Jane Doe",
"goal": "Arrange a dental checkup",
"context": "User forgot to include exact appointment time and confirmation code",
"instructions": "Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values",
"opening_line": "Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?",
"missing_information": [
"appointment time",
"confirmation code"
],
"trace": [
{
"stage": "observation",
"summary": "Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm."
},
{
"stage": "reason",
"summary": "Requesting the user to explicitly confirm the dental checkup time and confirmation code as they were previously omitted"
},
{
"stage": "action",
"summary": "Open a WebRTC call and collect the missing facts by voice."
}
],
"planner_model": "ark:doubao-seed-1-6-flash-250615"
},
"models": {
"planner": "ark:doubao-seed-1-6-flash-250615",
"dialogue_models": [
"ark:doubao-seed-1-6-flash-250615"
],
"llm_receipts": [
{
"schema_version": 1,
"purpose": "react_planning",
"execution": "real_external_llm",
"provider": "ark",
"requested_model": "doubao-seed-1-6-flash-250615",
"provider_model": "doubao-seed-1-6-flash-250615",
"provider_response_id": "021785497038047251fb4d9d2b64b7e0912e9d9de0a598cecf2cc",
"finish_reason": "stop",
"usage": {
"completion_tokens": 262,
"prompt_tokens": 222,
"total_tokens": 484,
"completion_tokens_details": {
"reasoning_tokens": 113
},
"prompt_tokens_details": {
"cached_tokens": 0
}
},
"started_at_utc": "2026-07-31T11:23:57.805430+00:00",
"finished_at_utc": "2026-07-31T11:24:00.114900+00:00",
"latency_seconds": 2.309437,
"request": {
"model": "doubao-seed-1-6-flash-250615",
"messages": [
{
"role": "system",
"content": "Plan a local browser WebRTC voice call. Observe the user's task, identify every missing task-critical fact, reason briefly about what must be collected, and choose the call action. Never invent facts. Return only JSON with callee_name, goal, context, instructions, opening_line, missing_information (array), and decision_summary. opening_line must ask aloud for the missing appointment time and confirmation code. instructions must require the voice Agent to repeat the facts, obtain explicit confirmation, and complete_task only with confirmed values. This local experiment records a confirmation but performs no external booking."
},
{
"role": "user",
"content": "Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm."
}
],
"response_format": {
"type": "json_object"
},
"temperature": 0,
"max_tokens": 700
},
"request_sha256": "276ca473bec3303ba5356765d1fd4b5e3c8c9e82e1960e4b9b19ad726b8e8c90",
"raw_response": {
"id": "021785497038047251fb4d9d2b64b7e0912e9d9de0a598cecf2cc",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "{\n \"callee_name\": \"Jane Doe\",\n \"goal\": \"Arrange a dental checkup\",\n \"context\": \"User forgot to include exact appointment time and confirmation code\",\n \"instructions\": \"Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values\",\n \"opening_line\": \"Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?\",\n \"missing_information\": [\"appointment time\", \"confirmation code\"],\n \"decision_summary\": \"Requesting the user to explicitly confirm the dental checkup time and confirmation code as they were previously omitted\"\n}",
"role": "assistant",
"reasoning_content": "So, let's see. I need to structure the JSON according to the user's request. First, callee_name is Jane Doe. The goal is to arrange a dental checkup. Context is that the user forgot the exact time and confirmation code, so those are missing. Instructions should tell the voice Agent to repeat the facts, get explicit confirmation. Opening line needs to ask aloud for the missing appointment time and confirmation code. Missing_information should list time and confirmation code. Decision_summary is about requesting the user to provide the time and confirmation code."
}
}
],
"created": 1785497040,
"model": "doubao-seed-1-6-flash-250615",
"object": "chat.completion",
"service_tier": "default",
"usage": {
"completion_tokens": 262,
"prompt_tokens": 222,
"total_tokens": 484,
"completion_tokens_details": {
"reasoning_tokens": 113
},
"prompt_tokens_details": {
"cached_tokens": 0
}
}
},
"raw_response_sha256": "2b02efd3ca007ca45bec6d2be83978d630a064d5f23ac15e0a1058979770760d",
"response_content": "{\n \"callee_name\": \"Jane Doe\",\n \"goal\": \"Arrange a dental checkup\",\n \"context\": \"User forgot to include exact appointment time and confirmation code\",\n \"instructions\": \"Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values\",\n \"opening_line\": \"Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?\",\n \"missing_information\": [\"appointment time\", \"confirmation code\"],\n \"decision_summary\": \"Requesting the user to explicitly confirm the dental checkup time and confirmation code as they were previously omitted\"\n}",
"response_content_sha256": "13c94bdc67acc4f47d52ad737b445cb82598d02f9036e0afe14f57f9030dcb96",
"external_request_completed": true,
"mock": false,
"probe_only": false,
"fallback_used": false,
"credential_fields_retained": false
},
{
"schema_version": 1,
"purpose": "post_asr_dialogue",
"execution": "real_external_llm",
"provider": "ark",
"requested_model": "doubao-seed-1-6-flash-250615",
"provider_model": "doubao-seed-1-6-flash-250615",
"provider_response_id": "021785497071624d9018183c34bd269dc45b9a10b892d56f50515",
"finish_reason": "stop",
"usage": {
"completion_tokens": 168,
"prompt_tokens": 366,
"total_tokens": 534,
"completion_tokens_details": {
"reasoning_tokens": 75
},
"prompt_tokens_details": {
"cached_tokens": 0
}
},
"started_at_utc": "2026-07-31T11:24:31.373004+00:00",
"finished_at_utc": "2026-07-31T11:24:33.913565+00:00",
"latency_seconds": 2.54053,
"request": {
"model": "doubao-seed-1-6-flash-250615",
"messages": [
{
"role": "system",
"content": "You are the voice Agent in a short local browser call. The user text below came only from ASR over the browser microphone RTP track. Return only JSON with assistant_message, explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing result, appointment_time, confirmation_number, notes. If the user states an exact time, a confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious spoken code words/digits into a concise code, and repeat both details in assistant_message. Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an external action occurred. For a completed turn, completion.result must be exactly 'Local confirmation recorded.' and completion.notes must be exactly 'No external organization was contacted or booking made.' Goal: Arrange a dental checkup\nContext: User forgot to include exact appointment time and confirmation code\nInstructions: Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values"
},
{
"role": "user",
"content": "{\"latest_user_asr_transcript\":\"Confirmation called a Cedar 8. I explicitly confirm Tuesday at 3 p.m. and confirmation called Cedar 8.\",\"prior_audio_transcript\":[{\"purpose\":\"missing_field_clarification\",\"source\":\"tts.webrtc_downlink\",\"speaker\":\"agent\",\"text\":\"Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?\",\"utterance_id\":\"tts_01\"}]}"
}
],
"response_format": {
"type": "json_object"
},
"temperature": 0,
"max_tokens": 700
},
"request_sha256": "926acff71159d1659a957a45a8244a1f2d6eeefbf78987a155bd0e8aa6660378",
"raw_response": {
"id": "021785497071624d9018183c34bd269dc45b9a10b892d56f50515",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "{\"assistant_message\": \"Appointment time confirmed as Tuesday at 3 p.m. and confirmation number confirmed as Cedar 8.\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3 p.m.\", \"confirmation_number\": \"Cedar 8\", \"notes\": \"No external organization was contacted or booking made.\"}}",
"role": "assistant",
"reasoning_content": "Got it, let's see. The user mentioned Tuesday at 3 p.m. as the appointment time and confirmed the confirmation code as Cedar 8. So now I need to structure the JSON with assistant_message repeating the details, explicit_confirmation_observed as true, should_complete as true, and completion with result, appointment_time, confirmation_number, notes."
}
}
],
"created": 1785497074,
"model": "doubao-seed-1-6-flash-250615",
"object": "chat.completion",
"service_tier": "default",
"usage": {
"completion_tokens": 168,
"prompt_tokens": 366,
"total_tokens": 534,
"completion_tokens_details": {
"reasoning_tokens": 75
},
"prompt_tokens_details": {
"cached_tokens": 0
}
}
},
"raw_response_sha256": "26440ed69dcb1f77178a284fdd0571e6f90cca9c776402bcf4e8ffcb56bfa994",
"response_content": "{\"assistant_message\": \"Appointment time confirmed as Tuesday at 3 p.m. and confirmation number confirmed as Cedar 8.\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3 p.m.\", \"confirmation_number\": \"Cedar 8\", \"notes\": \"No external organization was contacted or booking made.\"}}",
"response_content_sha256": "589e901f03635305e84094cd8a1e6619643fb554bf6ce175550feaf22b0246c3",
"external_request_completed": true,
"mock": false,
"probe_only": false,
"fallback_used": false,
"credential_fields_retained": false
}
],
"asr_receipts": [
{
"schema_version": 1,
"operation": "asr",
"execution": "real_local_inference",
"provider": "local OpenAI Whisper",
"model": "whisper-tiny",
"checkpoint_name": "tiny.pt",
"checkpoint_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
"runtime": {
"python": "3.11.4",
"torch": "2.4.0",
"openai_whisper": "20231106"
},
"input_source": "browser_microphone_rtp",
"input_sample_rate_hz": 16000,
"input_channels": 1,
"input_pcm_bytes": 733408,
"input_wav_bytes": 733452,
"input_wav_sha256": "d13d43693d8af1b579ff7a5f458f7bf0940fbf0daec9ddebc9f279ed1317d73a",
"input_duration_seconds": 22.919,
"language": "en",
"transcript": "Confirmation called a Cedar 8. I explicitly confirm Tuesday at 3 p.m. and confirmation called Cedar 8.",
"transcript_sha256": "cabe012d1165ad25d56167aa5b898fa029facf739a658df6d8580ddfaa6715d5",
"model_load_seconds": 0.314773,
"inference_seconds": 0.511034,
"latency_seconds": 1.865804,
"retained_safe_fixture_path": "media/react/microphone_rtp_asr_input.wav",
"external_request": false,
"mock": false,
"probe_only": false,
"fallback_used": false
}
],
"tts_receipts": [
{
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say",
"model": "operating-system speech synthesizer",
"voice": "Samantha",
"sample_rate_hz": 8000,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": 43638,
"duration_seconds": 5.45475,
"wav_bytes": 87354,
"wav_sha256": "36569fa63273d1ed2e7ff1eec9e3be7fc5e878e935bd58e7ea25bc5a6011b473",
"pcm_sha256": "cc573183930ba8f3640ff06dfa7462d5575d30a01685b3fc2d1f6591f1aace06",
"latency_seconds": 0.959548,
"engine": {
"name": "say",
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
},
"decoder": {
"name": "ffmpeg",
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
},
"network_used": false,
"mock": false,
"probe_only": false,
"fallback_used": false,
"utterance_id": "tts_01",
"purpose": "missing_field_clarification",
"retained_safe_fixture_path": "media/react/agent_01.wav",
"text_sha256": "8210e199a83fcf3de8179059a226e38ad88463493c974beb40460683b21fb785",
"enqueued_on_webrtc_track": true,
"transmitted_samples": 43638,
"delivery_complete": true,
"delivered_pcm_sha256": "cc573183930ba8f3640ff06dfa7462d5575d30a01685b3fc2d1f6591f1aace06"
},
{
"schema_version": 1,
"operation": "tts",
"execution": "real_speech_synthesis",
"provider": "macOS say",
"model": "operating-system speech synthesizer",
"voice": "Samantha",
"sample_rate_hz": 8000,
"channels": 1,
"sample_width_bytes": 2,
"sample_count": 41491,
"duration_seconds": 5.186375,
"wav_bytes": 83060,
"wav_sha256": "e8a227f303baa45cdcdaa20700c58f2405adf06725394eafb9364b12079ea936",
"pcm_sha256": "1686b19c8c3c73b3629c7aeac74029bc70d59d1f326ad47524b81bee3794ac28",
"latency_seconds": 0.954852,
"engine": {
"name": "say",
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
},
"decoder": {
"name": "ffmpeg",
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
},
"network_used": false,
"mock": false,
"probe_only": false,
"fallback_used": false,
"utterance_id": "tts_02",
"purpose": "confirmed_completion",
"retained_safe_fixture_path": "media/react/agent_02.wav",
"text_sha256": "5ba502a0adcb8198267792ac32cb50f475009bb1a0cfff839ac2c94f41df8cca",
"enqueued_on_webrtc_track": true,
"transmitted_samples": 41491,
"delivery_complete": true,
"delivered_pcm_sha256": "1686b19c8c3c73b3629c7aeac74029bc70d59d1f326ad47524b81bee3794ac28"
}
]
},
"transport": {
"kind": "webrtc",
"pstn_used": false,
"e164_required": false,
"sdp_negotiated": true,
"offer_sha256": "a08ac0b87d92b1e92cb28fbb1b23d71a845dec2b6fcddd3b916ccd00c778ad69",
"answer_sha256": "94bef0859b07a728240bf7699c0c9982744e1145783c3522c31a304a613d013c",
"ice_connection_state": "connected",
"ice_connected_observed": true,
"data_channel_open": true,
"local_audio_track": true,
"remote_audio_track": true,
"rtc_stats": {
"inbound_packets": 1679,
"inbound_bytes": 147402,
"outbound_packets": 1679,
"outbound_bytes": 69386
},
"server_received_audio_frames": 1675,
"server_received_audio_samples": 1608000,
"server_received_audio_pcm_bytes": 1071968,
"server_sent_tts_samples": 85129
},
"privacy": {
"safe_synthetic_acceptance": true,
"private_audio_retained": false,
"private_transcripts_retained": false,
"safe_synthetic_media_retained": true
},
"event_counts": {
"client.audio.commit": 1,
"rtc.ready": 6,
"rtc.stats": 46
},
"transcript": [
{
"speaker": "agent",
"text": "Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?",
"source": "tts.webrtc_downlink",
"utterance_id": "tts_01",
"purpose": "missing_field_clarification"
},
{
"speaker": "user",
"text": "Confirmation called a Cedar 8. I explicitly confirm Tuesday at 3 p.m. and confirmation called Cedar 8.",
"source": "asr.microphone_rtp",
"asr_receipt_index": 0
},
{
"speaker": "agent",
"text": "Appointment time confirmed as Tuesday at 3 p.m. and confirmation number confirmed as Cedar 8.",
"source": "tts.webrtc_downlink",
"utterance_id": "tts_02",
"purpose": "confirmed_completion"
}
],
"explicit_confirmation_observed": true,
"completion": {
"result": "Local confirmation recorded.",
"appointment_time": "Tuesday at 3 p.m.",
"confirmation_number": "Cedar 8",
"notes": "No external organization was contacted or booking made.",
"saved_at_utc": "2026-07-31T11:24:34.870657+00:00",
"tool": "complete_task"
},
"errors": [],
"acceptance": {
"checks": {
"sdp_offer_answer_negotiated": true,
"ice_connected": true,
"data_channel_open": true,
"browser_microphone_track": true,
"server_downlink_audio_track": true,
"outbound_audio_rtp": true,
"inbound_audio_rtp": true,
"server_buffered_microphone_rtp": true,
"real_asr_consumed_microphone_audio": true,
"external_react_planner_or_fixed_direct_control": true,
"real_external_post_asr_dialogue": true,
"real_tts_assets_synthesized": true,
"tts_audio_transmitted_on_downlink": true,
"media_is_canonical_transcript_source": true,
"data_channel_is_control_and_caption_only": true,
"missing_fields_were_clarified_aloud": true,
"explicit_confirmation_observed": true,
"structured_completion_saved": true,
"no_mock_probe_or_fallback": true,
"privacy_boundary_preserved": true
},
"passed": true
},
"finish_reason": "automated_safe_acceptance"
}
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""Standalone fail-closed verification for Phone Agent add-on retained evidence."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
from typing import Any
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
REQUIRED_ARTIFACTS = {
"direct.json",
"react.json",
"comparison.json",
"server.log",
"fixtures/direct_microphone.wav",
"fixtures/react_microphone.wav",
"media/direct/agent_01.wav",
"media/direct/agent_02.wav",
"media/direct/microphone_rtp_asr_input.wav",
"media/react/agent_01.wav",
"media/react/agent_02.wav",
"media/react/microphone_rtp_asr_input.wav",
}
REQUIRED_SOURCES = {
"book/chapter9.md",
"chapter6/README.md",
"chapter6/phone-agent/README.md",
"chapter6/phone-agent/agent.py",
"chapter6/phone-agent/demo.py",
"chapter6/phone-agent/direct_call.py",
"chapter6/phone-agent/env.example",
"chapter6/phone-agent/requirements.txt",
"chapter6/phone-agent/run_acceptance.py",
"chapter6/phone-agent/speech.py",
"chapter6/phone-agent/static/app.js",
"chapter6/phone-agent/static/index.html",
"chapter6/phone-agent/static/style.css",
"chapter6/phone-agent/test_agent.py",
"chapter6/phone-agent/test_speech.py",
"chapter6/phone-agent/test_verify_acceptance.py",
"chapter6/phone-agent/test_webrtc_app.py",
"chapter6/phone-agent/verify_acceptance.py",
"chapter6/phone-agent/webrtc_app.py",
"pyproject.toml",
"uv.lock",
}
REQUIRED_ARM_CHECKS = {
"sdp_offer_answer_negotiated",
"ice_connected",
"data_channel_open",
"browser_microphone_track",
"server_downlink_audio_track",
"outbound_audio_rtp",
"inbound_audio_rtp",
"server_buffered_microphone_rtp",
"real_asr_consumed_microphone_audio",
"external_react_planner_or_fixed_direct_control",
"real_external_post_asr_dialogue",
"real_tts_assets_synthesized",
"tts_audio_transmitted_on_downlink",
"media_is_canonical_transcript_source",
"data_channel_is_control_and_caption_only",
"missing_fields_were_clarified_aloud",
"explicit_confirmation_observed",
"structured_completion_saved",
"no_mock_probe_or_fallback",
"privacy_boundary_preserved",
}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def sha256_json(value: Any) -> str:
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def load_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise TypeError(f"{path.name} is not a JSON object")
return value
def valid_hash(value: Any) -> bool:
return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None
def check_llm_receipt(receipt: dict[str, Any], prefix: str, failures: list[str]) -> None:
raw = receipt.get("raw_response") or {}
request = receipt.get("request") or {}
choices = raw.get("choices") or []
usage = receipt.get("usage") or {}
raw_usage = raw.get("usage") or {}
content = receipt.get("response_content")
if (
receipt.get("execution") != "real_external_llm"
or receipt.get("external_request_completed") is not True
):
failures.append(prefix + "LLM execution is not a completed external request")
if (
receipt.get("mock") is not False
or receipt.get("probe_only") is not False
or receipt.get("fallback_used") is not False
):
failures.append(prefix + "LLM receipt permits mock/probe/fallback")
if receipt.get("credential_fields_retained") is not False:
failures.append(prefix + "LLM receipt does not assert credential-free retention")
if not receipt.get("provider_response_id") or receipt.get("provider_response_id") != raw.get(
"id"
):
failures.append(prefix + "provider response ID is absent or differs from raw response")
if not receipt.get("provider_model") or receipt.get("provider_model") != raw.get("model"):
failures.append(prefix + "provider model is absent or differs from raw response")
if not choices or receipt.get("finish_reason") != choices[0].get("finish_reason"):
failures.append(prefix + "finish status is absent or differs from raw response")
raw_content = (choices[0].get("message") or {}).get("content") if choices else None
if not content or content != raw_content:
failures.append(prefix + "retained response content differs from raw response")
if not usage or int(usage.get("total_tokens", 0)) <= 0 or usage != raw_usage:
failures.append(prefix + "usage is absent or differs from raw response")
if float(receipt.get("latency_seconds", 0)) <= 0:
failures.append(prefix + "LLM latency is not positive")
if receipt.get("request_sha256") != sha256_json(request):
failures.append(prefix + "LLM request hash mismatch")
if receipt.get("raw_response_sha256") != sha256_json(raw):
failures.append(prefix + "LLM raw response hash mismatch")
if receipt.get("response_content_sha256") != hashlib.sha256(str(content).encode()).hexdigest():
failures.append(prefix + "LLM response content hash mismatch")
if request.get("model") != receipt.get("requested_model") or not request.get("messages"):
failures.append(prefix + "raw request/model is incomplete")
def check_arm(name: str, record: dict[str, Any], run_dir: Path, failures: list[str]) -> None:
prefix = f"{name}: "
transport = record.get("transport") or {}
stats = transport.get("rtc_stats") or {}
models = record.get("models") or {}
llm_receipts = models.get("llm_receipts") or []
asr_receipts = models.get("asr_receipts") or []
tts_receipts = models.get("tts_receipts") or []
transcript = record.get("transcript") or []
completion = record.get("completion") or {}
acceptance = record.get("acceptance") or {}
checks = acceptance.get("checks") or {}
if (
record.get("experiment") != "9-2"
or record.get("mode") != name
or record.get("status") != "completed"
):
failures.append(prefix + "identity/mode/status mismatch")
if transport.get("kind") != "webrtc" or transport.get("pstn_used") is not False:
failures.append(prefix + "transport is not non-PSTN WebRTC")
if transport.get("e164_required") is not False:
failures.append(prefix + "E.164 was incorrectly required")
if not transport.get("sdp_negotiated") or not transport.get("ice_connected_observed"):
failures.append(prefix + "SDP/ICE gate failed")
for field in ("offer_sha256", "answer_sha256"):
if not valid_hash(transport.get(field)):
failures.append(prefix + f"invalid {field}")
if (
not transport.get("data_channel_open")
or not transport.get("local_audio_track")
or not transport.get("remote_audio_track")
):
failures.append(prefix + "data channel or bidirectional audio-track gate failed")
for field in ("inbound_packets", "inbound_bytes", "outbound_packets", "outbound_bytes"):
if not isinstance(stats.get(field), int) or stats[field] <= 0:
failures.append(prefix + f"non-positive RTC stat {field}")
if (
int(transport.get("server_received_audio_frames", 0)) <= 0
or int(transport.get("server_received_audio_pcm_bytes", 0)) <= 0
):
failures.append(prefix + "server did not buffer microphone RTP audio")
if record.get("errors"):
failures.append(prefix + "runtime errors were retained")
planning = [item for item in llm_receipts if item.get("purpose") == "react_planning"]
dialogue = [item for item in llm_receipts if item.get("purpose") == "post_asr_dialogue"]
if name == "direct" and planning:
failures.append(prefix + "direct control unexpectedly used an LLM planner")
if name == "react" and len(planning) != 1:
failures.append(prefix + "ReAct arm lacks exactly one planner receipt")
if len(dialogue) != 1:
failures.append(prefix + "arm lacks exactly one post-ASR dialogue receipt")
for index, receipt in enumerate(llm_receipts):
check_llm_receipt(receipt, f"{prefix}llm[{index}]: ", failures)
if len(asr_receipts) != 1:
failures.append(prefix + "arm lacks exactly one ASR receipt")
for receipt in asr_receipts:
artifact = run_dir / str(receipt.get("retained_safe_fixture_path", ""))
if (
receipt.get("execution") != "real_local_inference"
or receipt.get("input_source") != "browser_microphone_rtp"
or receipt.get("mock") is not False
or receipt.get("probe_only") is not False
or receipt.get("fallback_used") is not False
or not valid_hash(receipt.get("checkpoint_sha256"))
or not artifact.is_file()
or sha256(artifact) != receipt.get("input_wav_sha256")
):
failures.append(prefix + "ASR provenance/input artifact gate failed")
if (
not receipt.get("transcript")
or receipt.get("transcript_sha256")
!= hashlib.sha256(str(receipt.get("transcript", "")).encode()).hexdigest()
):
failures.append(prefix + "ASR transcript/hash gate failed")
if len(tts_receipts) != 2:
failures.append(prefix + "arm lacks exactly two Agent TTS receipts")
for index, receipt in enumerate(tts_receipts):
artifact = run_dir / str(receipt.get("retained_safe_fixture_path", ""))
if (
receipt.get("execution") != "real_speech_synthesis"
or receipt.get("mock") is not False
or receipt.get("probe_only") is not False
or receipt.get("fallback_used") is not False
or not artifact.is_file()
or sha256(artifact) != receipt.get("wav_sha256")
or receipt.get("delivery_complete") is not True
or receipt.get("enqueued_on_webrtc_track") is not True
or receipt.get("delivered_pcm_sha256") != receipt.get("pcm_sha256")
or int(receipt.get("transmitted_samples", 0)) != int(receipt.get("sample_count", -1))
):
failures.append(prefix + f"TTS/downlink provenance gate failed at receipt {index}")
if not transcript or any(
turn.get("source")
!= ("asr.microphone_rtp" if turn.get("speaker") == "user" else "tts.webrtc_downlink")
for turn in transcript
):
failures.append(prefix + "canonical transcript contains a non-media semantic source")
if record.get("event_counts", {}).get("semantic_user_messages", 0) != 0:
failures.append(prefix + "data channel supplied user semantics")
if not transcript or transcript[0].get("purpose") != "missing_field_clarification":
failures.append(prefix + "missing-field clarification was not the first TTS turn")
if record.get("explicit_confirmation_observed") is not True:
failures.append(prefix + "explicit confirmation was not observed")
if (
not completion.get("appointment_time")
or not completion.get("confirmation_number")
or completion.get("tool") != "complete_task"
):
failures.append(prefix + "structured completion fields/tool are incomplete")
if (
completion.get("result") != "Local confirmation recorded."
or completion.get("notes") != "No external organization was contacted or booking made."
):
failures.append(prefix + "structured completion violates the no-external-action boundary")
privacy = record.get("privacy") or {}
if (
privacy.get("safe_synthetic_acceptance") is not True
or privacy.get("private_audio_retained") is not False
or privacy.get("private_transcripts_retained") is not False
):
failures.append(prefix + "privacy/safe-fixture boundary mismatch")
if (
acceptance.get("passed") is not True
or set(checks) != REQUIRED_ARM_CHECKS
or not all(checks.values())
):
failures.append(prefix + "acceptance did not pass the exact fail-closed gate set")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("run_dir", type=Path)
args = parser.parse_args()
run_dir = args.run_dir.resolve()
failures: list[str] = []
try:
manifest = load_json(run_dir / "manifest.json")
direct = load_json(run_dir / "direct.json")
react = load_json(run_dir / "react.json")
comparison = load_json(run_dir / "comparison.json")
if set(manifest.get("artifact_sha256", {})) != REQUIRED_ARTIFACTS:
failures.append("manifest does not enumerate the exact required artifacts")
if set(manifest.get("source_sha256", {})) != REQUIRED_SOURCES:
failures.append("manifest does not enumerate the exact required source set")
for relative, expected in manifest.get("source_sha256", {}).items():
path = ROOT / relative
if not path.is_file() or sha256(path) != expected:
failures.append(f"source hash mismatch: {relative}")
for relative, expected in manifest.get("artifact_sha256", {}).items():
path = run_dir / relative
if not path.is_file() or sha256(path) != expected:
failures.append(f"artifact hash mismatch: {relative}")
check_arm("direct", direct, run_dir, failures)
check_arm("react", react, run_dir, failures)
if direct.get("call_id") == react.get("call_id"):
failures.append("the two arms reused one call ID")
if direct.get("input_contract", {}).get("fields_supplied_by_caller") != [
"callee_name",
"goal",
"context",
"instructions",
]:
failures.append("direct arm did not require all four fixed parameters")
if react.get("input_contract", {}).get("fields_supplied_by_caller") != ["task"]:
failures.append("ReAct arm accepted more than the natural-language task")
if not react.get("plan", {}).get("missing_information"):
failures.append("ReAct arm did not identify missing information")
if [step.get("stage") for step in react.get("plan", {}).get("trace", [])] != [
"observation",
"reason",
"action",
]:
failures.append("ReAct trace is not observation/reason/action")
comparison_checks = comparison.get("checks") or {}
if (
comparison.get("passed") is not True
or not comparison_checks
or not all(comparison_checks.values())
):
failures.append("direct-vs-ReAct comparison did not pass every check")
if (
manifest.get("result") != "passed"
or manifest.get("execution") != "live_browser_aiortc_asr_external_llm_tts_webrtc"
):
failures.append("manifest result/execution mismatch")
if (
manifest.get("canonical_safe_synthetic_fixture") is not True
or manifest.get("pstn_used") is not False
or manifest.get("e164_required") is not False
or manifest.get("credentials_saved") is not False
or manifest.get("private_audio_or_transcripts_saved") is not False
):
failures.append("manifest violates canonical safety/telephony boundary")
if manifest.get("environment", {}).get("media_peer") != "aiortc":
failures.append("manifest does not identify aiortc")
if manifest.get("redaction", {}).get("passed") is not True:
failures.append("manifest redaction gate is false")
cleanup = manifest.get("cleanup") or {}
if (
cleanup.get("browser_contexts_closed") is not True
or cleanup.get("server_process_terminated") is not True
):
failures.append("manifest cleanup gate is false")
if cleanup.get("raw_private_media_created") is not False:
failures.append("manifest says private media was created")
for arm, record in (("direct", direct), ("react", react)):
if manifest.get("acceptance", {}).get(arm) != record.get("acceptance"):
failures.append(f"manifest {arm} acceptance differs from raw record")
if manifest.get("acceptance", {}).get("comparison_passed") is not True:
failures.append("manifest comparison gate is false")
# Standalone pattern scan catches common leaked API credential forms even
# when the original environment is unavailable to this verifier.
credential_pattern = re.compile(rb"\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b")
for relative in REQUIRED_ARTIFACTS:
if credential_pattern.search((run_dir / relative).read_bytes()):
failures.append(f"credential-shaped value found: {relative}")
except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
failures.append(f"malformed or incomplete evidence: {exc}")
result = {"run_id": run_dir.name, "passed": not failures, "failures": failures}
print(json.dumps(result, ensure_ascii=False))
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())
+698
View File
@@ -0,0 +1,698 @@
"""Browser-to-aiortc voice Agent for Phone Agent add-on.
The browser microphone is the only user-semantic input. The server buffers its
decoded RTP audio, runs real Whisper ASR, sends that transcript to a real external
LLM, synthesizes the Agent reply, and places the resulting PCM on the WebRTC
downlink track. The data channel carries captions and control events only.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import uuid
from collections import Counter, deque
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal
from agent import CallPlan, conversation_turn, direct_plan, react_plan
from aiortc import AudioStreamTrack, RTCPeerConnection, RTCSessionDescription
from aiortc.mediastreams import MediaStreamError
from av import AudioFrame, AudioResampler
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, model_validator
from speech import SystemSpeechSynthesizer, WhisperASR, sha256_bytes
HERE = Path(__file__).resolve().parent
STATIC = HERE / "static"
load_dotenv(HERE / ".env")
CALLS: dict[str, dict[str, Any]] = {}
PEERS: dict[str, RTCPeerConnection] = {}
RUNTIMES: dict[str, CallRuntime] = {}
MAX_MICROPHONE_PCM_BYTES = 16_000 * 2 * 60
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _safe_error(exc: BaseException) -> str:
text = f"{type(exc).__name__}: {exc}"
for name, secret in os.environ.items():
if (
any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
and len(secret) >= 8
):
text = text.replace(secret, "[REDACTED]")
return text[:1000]
class CreateCall(BaseModel):
mode: Literal["direct", "react"]
task: str = Field(default="", max_length=4000)
callee_name: str = Field(default="", max_length=200)
goal: str = Field(default="", max_length=2000)
context: str = Field(default="", max_length=4000)
instructions: str = Field(default="", max_length=4000)
@model_validator(mode="after")
def validate_arm(self) -> CreateCall:
if self.mode == "react" and not self.task.strip():
raise ValueError("the ReAct arm requires a natural-language task")
if self.mode == "direct":
missing = [
name
for name in ("callee_name", "goal", "context", "instructions")
if not getattr(self, name).strip()
]
if missing:
raise ValueError("the direct arm requires: " + ", ".join(missing))
return self
class EventEnvelope(BaseModel):
event: dict[str, Any]
class CompleteTask(BaseModel):
result: str = Field(min_length=1, max_length=2000)
appointment_time: str = Field(min_length=1, max_length=300)
confirmation_number: str = Field(min_length=1, max_length=300)
notes: str = Field(min_length=1, max_length=2000)
class FinishCall(BaseModel):
reason: str = Field(default="user_hangup", max_length=200)
@dataclass
class QueuedUtterance:
receipt: dict[str, Any]
pcm: bytes
offset: int = 0
class SynthesizedAudioTrack(AudioStreamTrack):
"""Continuous WebRTC audio track whose non-silent segments are real TTS PCM."""
def __init__(self, record: dict[str, Any]) -> None:
super().__init__()
self.record = record
self.queue: deque[QueuedUtterance] = deque()
def enqueue(self, pcm: bytes, receipt: dict[str, Any]) -> None:
if not pcm:
raise ValueError("cannot enqueue empty TTS audio")
receipt["enqueued_on_webrtc_track"] = True
receipt["transmitted_samples"] = 0
receipt["delivery_complete"] = False
self.queue.append(QueuedUtterance(receipt=receipt, pcm=pcm))
async def recv(self) -> AudioFrame:
frame = await super().recv()
target = bytearray(frame.samples * 2)
cursor = 0
while cursor < len(target) and self.queue:
utterance = self.queue[0]
available = len(utterance.pcm) - utterance.offset
take = min(len(target) - cursor, available)
target[cursor : cursor + take] = utterance.pcm[
utterance.offset : utterance.offset + take
]
utterance.offset += take
cursor += take
samples = take // 2
utterance.receipt["transmitted_samples"] += samples
self.record["transport"]["server_sent_tts_samples"] += samples
if utterance.offset == len(utterance.pcm):
utterance.receipt["delivery_complete"] = True
utterance.receipt["delivered_pcm_sha256"] = utterance.receipt["pcm_sha256"]
self.queue.popleft()
frame.planes[0].update(bytes(target))
return frame
class CallRuntime:
def __init__(self, record: dict[str, Any]) -> None:
self.record = record
self.tts = SystemSpeechSynthesizer()
self.asr = WhisperASR()
self.output_track = SynthesizedAudioTrack(record)
self.microphone_pcm = bytearray()
self.resampler = AudioResampler(format="s16", layout="mono", rate=16_000)
self.channel: Any | None = None
self.commit_started = False
app = FastAPI(title="Phone Agent add-on WebRTC Voice Agent", version="3.0")
app.mount("/static", StaticFiles(directory=STATIC), name="static")
def _record(call_id: str) -> dict[str, Any]:
record = CALLS.get(call_id)
if record is None:
raise HTTPException(status_code=404, detail="unknown call")
return record
def _public(record: dict[str, Any]) -> dict[str, Any]:
return json.loads(json.dumps(record, ensure_ascii=False))
def _hash_is_valid(value: Any) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
def _llm_receipt_is_real(receipt: dict[str, Any]) -> bool:
usage = receipt.get("usage") or {}
return bool(
receipt.get("execution") == "real_external_llm"
and receipt.get("external_request_completed") is True
and receipt.get("provider_response_id")
and receipt.get("provider_model")
and receipt.get("finish_reason")
and int(usage.get("total_tokens", 0)) > 0
and float(receipt.get("latency_seconds", 0)) > 0
and _hash_is_valid(receipt.get("request_sha256"))
and _hash_is_valid(receipt.get("raw_response_sha256"))
and _hash_is_valid(receipt.get("response_content_sha256"))
and receipt.get("fallback_used") is False
and receipt.get("mock") is False
and receipt.get("probe_only") is False
and receipt.get("credential_fields_retained") is False
)
def _acceptance(record: dict[str, Any]) -> dict[str, Any]:
transport = record["transport"]
stats = transport["rtc_stats"]
transcript = record["transcript"]
llm_receipts = record["models"]["llm_receipts"]
asr_receipts = record["models"]["asr_receipts"]
tts_receipts = record["models"]["tts_receipts"]
planning_receipts = [item for item in llm_receipts if item.get("purpose") == "react_planning"]
dialogue_receipts = [
item for item in llm_receipts if item.get("purpose") == "post_asr_dialogue"
]
all_model_receipts = [*llm_receipts, *asr_receipts, *tts_receipts]
completion = record.get("completion") or {}
first_agent = next((turn for turn in transcript if turn.get("speaker") == "agent"), {})
checks = {
"sdp_offer_answer_negotiated": bool(transport["sdp_negotiated"]),
"ice_connected": bool(transport["ice_connected_observed"]),
"data_channel_open": bool(transport["data_channel_open"]),
"browser_microphone_track": bool(transport["local_audio_track"]),
"server_downlink_audio_track": bool(transport["remote_audio_track"]),
"outbound_audio_rtp": int(stats["outbound_packets"]) > 0
and int(stats["outbound_bytes"]) > 0,
"inbound_audio_rtp": int(stats["inbound_packets"]) > 0 and int(stats["inbound_bytes"]) > 0,
"server_buffered_microphone_rtp": (
int(transport["server_received_audio_frames"]) > 0
and int(transport["server_received_audio_pcm_bytes"]) > 0
),
"real_asr_consumed_microphone_audio": bool(
asr_receipts
and all(
item.get("execution") == "real_local_inference"
and item.get("input_source") == "browser_microphone_rtp"
and _hash_is_valid(item.get("input_wav_sha256"))
and _hash_is_valid(item.get("checkpoint_sha256"))
and item.get("fallback_used") is False
for item in asr_receipts
)
),
"external_react_planner_or_fixed_direct_control": (
record["mode"] == "direct"
and record["input_contract"]["fields_supplied_by_caller"]
== ["callee_name", "goal", "context", "instructions"]
)
or (
record["mode"] == "react"
and len(planning_receipts) == 1
and _llm_receipt_is_real(planning_receipts[0])
),
"real_external_post_asr_dialogue": len(dialogue_receipts) == 1
and _llm_receipt_is_real(dialogue_receipts[0]),
"real_tts_assets_synthesized": len(tts_receipts) >= 2
and all(
item.get("execution") == "real_speech_synthesis"
and int(item.get("sample_count", 0)) > 0
and _hash_is_valid(item.get("wav_sha256"))
and _hash_is_valid(item.get("pcm_sha256"))
and item.get("fallback_used") is False
for item in tts_receipts
),
"tts_audio_transmitted_on_downlink": len(tts_receipts) >= 2
and all(
item.get("enqueued_on_webrtc_track") is True
and item.get("delivery_complete") is True
and int(item.get("transmitted_samples", 0)) == int(item.get("sample_count", -1))
and item.get("delivered_pcm_sha256") == item.get("pcm_sha256")
for item in tts_receipts
),
"media_is_canonical_transcript_source": bool(transcript)
and all(
(turn.get("speaker") == "user" and turn.get("source") == "asr.microphone_rtp")
or (turn.get("speaker") == "agent" and turn.get("source") == "tts.webrtc_downlink")
for turn in transcript
),
"data_channel_is_control_and_caption_only": int(
record["event_counts"].get("semantic_user_messages", 0)
)
== 0,
"missing_fields_were_clarified_aloud": (
first_agent.get("purpose") == "missing_field_clarification"
and first_agent.get("source") == "tts.webrtc_downlink"
and (record["mode"] == "direct" or bool(record["plan"].get("missing_information")))
),
"explicit_confirmation_observed": record.get("explicit_confirmation_observed") is True,
"structured_completion_saved": bool(
completion.get("appointment_time")
and completion.get("confirmation_number")
and completion.get("result")
),
"no_mock_probe_or_fallback": bool(all_model_receipts)
and all(
item.get("mock") is False
and item.get("probe_only") is False
and item.get("fallback_used") is False
for item in all_model_receipts
),
"privacy_boundary_preserved": record["privacy"]["private_audio_retained"] is False
and record["privacy"]["private_transcripts_retained"] is False,
}
return {"checks": checks, "passed": all(checks.values()) and not record["errors"]}
def _retained_media_path(record: dict[str, Any], filename: str) -> Path | None:
if os.getenv("PHONE_SAFE_SYNTHETIC_ACCEPTANCE") != "1":
return None
root = os.getenv("PHONE_EVIDENCE_DIR")
if not root:
raise RuntimeError("safe acceptance media retention requires PHONE_EVIDENCE_DIR")
path = Path(root).resolve() / "media" / record["mode"] / filename
path.parent.mkdir(parents=True, exist_ok=True)
return path
def _relative_evidence_path(path: Path | None) -> str | None:
if path is None:
return None
root = Path(os.environ["PHONE_EVIDENCE_DIR"]).resolve()
return str(path.relative_to(root))
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC / "index.html")
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {
"ok": True,
"experiment": "phone-agent",
"model_provider": os.getenv("PHONE_MODEL_PROVIDER", "ark"),
"model_credential_present": bool(
os.getenv("ARK_API_KEY")
or os.getenv("OPENAI_API_KEY")
or os.getenv("OPENROUTER_API_KEY")
),
"speech_paths": "browser microphone RTP -> Whisper ASR; system TTS -> WebRTC RTP",
}
@app.post("/api/calls")
async def create_call(request: CreateCall) -> dict[str, Any]:
try:
if request.mode == "direct":
plan = direct_plan(
callee_name=request.callee_name,
goal=request.goal,
context=request.context,
instructions=request.instructions,
)
supplied = ["callee_name", "goal", "context", "instructions"]
else:
plan = await asyncio.to_thread(react_plan, request.task)
supplied = ["task"]
except (RuntimeError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=422, detail=_safe_error(exc)) from exc
plan_dict = plan.to_dict()
planner_receipt = plan_dict.pop("planner_receipt", None)
llm_receipts = [planner_receipt] if planner_receipt else []
call_id = "rtc_" + uuid.uuid4().hex[:20]
safe_acceptance = os.getenv("PHONE_SAFE_SYNTHETIC_ACCEPTANCE") == "1"
record = {
"schema_version": 3,
"experiment": "phone-agent",
"call_id": call_id,
"created_at_utc": _now(),
"finished_at_utc": None,
"status": "planned",
"mode": request.mode,
"input_contract": {
"fields_supplied_by_caller": supplied,
"natural_language_task": request.task,
},
"plan": plan_dict,
"models": {
"planner": plan.planner_model,
"dialogue_models": [],
"llm_receipts": llm_receipts,
"asr_receipts": [],
"tts_receipts": [],
},
"transport": {
"kind": "webrtc",
"pstn_used": False,
"e164_required": False,
"sdp_negotiated": False,
"offer_sha256": None,
"answer_sha256": None,
"ice_connection_state": "new",
"ice_connected_observed": False,
"data_channel_open": False,
"local_audio_track": False,
"remote_audio_track": False,
"rtc_stats": {
"inbound_packets": 0,
"inbound_bytes": 0,
"outbound_packets": 0,
"outbound_bytes": 0,
},
"server_received_audio_frames": 0,
"server_received_audio_samples": 0,
"server_received_audio_pcm_bytes": 0,
"server_sent_tts_samples": 0,
},
"privacy": {
"safe_synthetic_acceptance": safe_acceptance,
"private_audio_retained": False,
"private_transcripts_retained": False,
"safe_synthetic_media_retained": safe_acceptance,
},
"event_counts": {},
"transcript": [],
"explicit_confirmation_observed": False,
"completion": None,
"errors": [],
"acceptance": {"checks": {}, "passed": False},
}
CALLS[call_id] = record
return {"call_id": call_id, "join_url": f"/?call_id={call_id}", "plan": plan_dict}
async def _consume_microphone(track: Any, runtime: CallRuntime) -> None:
record = runtime.record
try:
while True:
frame = await track.recv()
record["transport"]["server_received_audio_frames"] += 1
record["transport"]["server_received_audio_samples"] += int(
getattr(frame, "samples", 0)
)
for resampled in runtime.resampler.resample(frame):
# Packed mono s16 has two bytes per sample. Reading the plane
# directly avoids imposing NumPy on this transport-only stack.
pcm = bytes(resampled.planes[0])[: int(resampled.samples) * 2]
if len(runtime.microphone_pcm) + len(pcm) > MAX_MICROPHONE_PCM_BYTES:
raise RuntimeError("microphone buffer exceeded the 60-second safety limit")
runtime.microphone_pcm.extend(pcm)
record["transport"]["server_received_audio_pcm_bytes"] = len(runtime.microphone_pcm)
except MediaStreamError:
return
except Exception as exc: # noqa: BLE001 - evidence must retain asynchronous media failures
record["errors"].append(
{"at": _now(), "stage": "microphone_rtp", "message": _safe_error(exc)}
)
async def _send_agent(
runtime: CallRuntime,
text: str,
*,
purpose: str,
) -> None:
cleaned = text.strip()
if not cleaned:
raise ValueError("Agent TTS text is empty")
index = len(runtime.record["models"]["tts_receipts"]) + 1
speech = await asyncio.to_thread(runtime.tts.synthesize, cleaned)
path = _retained_media_path(runtime.record, f"agent_{index:02d}.wav")
if path is not None:
path.write_bytes(speech.wav)
if sha256_bytes(path.read_bytes()) != speech.receipt["wav_sha256"]:
raise RuntimeError("retained TTS asset hash mismatch")
receipt = {
**speech.receipt,
"utterance_id": f"tts_{index:02d}",
"purpose": purpose,
"retained_safe_fixture_path": _relative_evidence_path(path),
"text_sha256": hashlib.sha256(cleaned.encode("utf-8")).hexdigest(),
}
runtime.record["models"]["tts_receipts"].append(receipt)
runtime.output_track.enqueue(speech.pcm, receipt)
runtime.record["transcript"].append(
{
"speaker": "agent",
"text": cleaned,
"source": "tts.webrtc_downlink",
"utterance_id": receipt["utterance_id"],
"purpose": purpose,
}
)
channel = runtime.channel
if channel is not None and channel.readyState == "open":
channel.send(
json.dumps(
{
"type": "agent.caption",
"text": cleaned,
"utterance_id": receipt["utterance_id"],
"source": "accessibility_mirror_of_tts",
},
ensure_ascii=False,
)
)
async def _commit_microphone(runtime: CallRuntime) -> None:
if runtime.commit_started:
raise RuntimeError("microphone audio was already committed")
runtime.commit_started = True
record = runtime.record
pcm = bytes(runtime.microphone_pcm)
path = _retained_media_path(record, "microphone_rtp_asr_input.wav")
transcript, asr_receipt = await asyncio.to_thread(
runtime.asr.transcribe, pcm, retained_wav_path=path
)
asr_receipt["retained_safe_fixture_path"] = _relative_evidence_path(path)
if path is not None and sha256_bytes(path.read_bytes()) != asr_receipt["input_wav_sha256"]:
raise RuntimeError("retained microphone/ASR input hash mismatch")
record["models"]["asr_receipts"].append(asr_receipt)
record["transcript"].append(
{
"speaker": "user",
"text": transcript,
"source": "asr.microphone_rtp",
"asr_receipt_index": len(record["models"]["asr_receipts"]) - 1,
}
)
channel = runtime.channel
if channel is not None and channel.readyState == "open":
channel.send(
json.dumps(
{
"type": "user.caption",
"text": transcript,
"source": "accessibility_mirror_of_asr",
},
ensure_ascii=False,
)
)
plan = CallPlan(**record["plan"])
dialogue = await asyncio.to_thread(
conversation_turn,
plan,
list(record["transcript"][:-1]),
transcript,
)
receipt = dialogue["llm_receipt"]
record["models"]["llm_receipts"].append(receipt)
model = dialogue["dialogue_model"]
if model not in record["models"]["dialogue_models"]:
record["models"]["dialogue_models"].append(model)
record["explicit_confirmation_observed"] = dialogue["explicit_confirmation_observed"] is True
if not dialogue["should_complete"]:
raise RuntimeError("canonical call did not reach explicit structured completion")
completion = CompleteTask(**dialogue["completion"])
await _send_agent(runtime, dialogue["assistant_message"], purpose="confirmed_completion")
record["completion"] = {
**completion.model_dump(),
"saved_at_utc": _now(),
"tool": "complete_task",
}
if channel is not None and channel.readyState == "open":
channel.send(json.dumps({"type": "tool.result", "name": "complete_task", "saved": True}))
async def _handle_data_message(runtime: CallRuntime, raw: Any) -> None:
record = runtime.record
try:
message = json.loads(raw) if isinstance(raw, str) else {}
message_type = message.get("type")
if message_type == "client.ready":
record["transport"]["data_channel_open"] = True
if not record["transcript"]:
await _send_agent(
runtime,
record["plan"]["opening_line"],
purpose="missing_field_clarification",
)
return
if message_type == "client.audio.commit":
record["event_counts"]["client.audio.commit"] = (
int(record["event_counts"].get("client.audio.commit", 0)) + 1
)
await _commit_microphone(runtime)
return
if message_type in {"user.message", "client.user_text"}:
record["event_counts"]["semantic_user_messages"] = (
int(record["event_counts"].get("semantic_user_messages", 0)) + 1
)
raise RuntimeError("semantic user text is forbidden; speak over the microphone track")
except Exception as exc: # noqa: BLE001 - asynchronous failures belong in the evidence record
error = {"at": _now(), "stage": "voice_agent_loop", "message": _safe_error(exc)}
record["errors"].append(error)
if runtime.channel is not None and runtime.channel.readyState == "open":
runtime.channel.send(json.dumps({"type": "error", "error": error}, ensure_ascii=False))
@app.post("/api/calls/{call_id}/session")
async def negotiate(call_id: str, request: Request) -> Response:
record = _record(call_id)
content_type = request.headers.get("content-type", "")
if not content_type.startswith("application/sdp"):
raise HTTPException(status_code=415, detail="expected application/sdp")
offer = (await request.body()).decode("utf-8", errors="strict")
if not offer.startswith("v=0") or len(offer) > 1_000_000:
raise HTTPException(status_code=400, detail="invalid SDP offer")
try:
runtime = CallRuntime(record)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=_safe_error(exc)) from exc
RUNTIMES[call_id] = runtime
pc = RTCPeerConnection()
PEERS[call_id] = pc
pc.addTrack(runtime.output_track)
@pc.on("track")
def on_track(track: Any) -> None:
if track.kind == "audio":
record["transport"]["local_audio_track"] = True
asyncio.create_task(_consume_microphone(track, runtime))
@pc.on("datachannel")
def on_datachannel(channel: Any) -> None:
runtime.channel = channel
@channel.on("message")
def on_message(message: Any) -> None:
asyncio.create_task(_handle_data_message(runtime, message))
@pc.on("connectionstatechange")
async def on_connectionstatechange() -> None:
state = pc.connectionState
if state in {"connected", "completed"}:
record["transport"]["ice_connected_observed"] = True
record["transport"]["ice_connection_state"] = state
elif not record["transport"]["ice_connected_observed"]:
record["transport"]["ice_connection_state"] = state
if state in {"failed", "closed"}:
await pc.close()
await pc.setRemoteDescription(RTCSessionDescription(sdp=offer, type="offer"))
answer_description = await pc.createAnswer()
await pc.setLocalDescription(answer_description)
answer = pc.localDescription.sdp
record["status"] = "connected"
record["transport"].update(
{
"sdp_negotiated": True,
"offer_sha256": _sha256_text(offer),
"answer_sha256": _sha256_text(answer),
"remote_audio_track": True,
}
)
return Response(content=answer, media_type="application/sdp")
@app.post("/api/calls/{call_id}/events")
async def save_event(call_id: str, envelope: EventEnvelope) -> dict[str, bool]:
record = _record(call_id)
event = envelope.event
event_type = str(event.get("type", "unknown"))[:200]
counts = Counter(record["event_counts"])
counts[event_type] += 1
record["event_counts"] = dict(sorted(counts.items()))
if event_type == "rtc.ready":
state = str(event.get("ice_connection_state", "unknown"))[:30]
if state in {"connected", "completed"}:
record["transport"]["ice_connected_observed"] = True
record["transport"]["ice_connection_state"] = state
for field, event_field in (
("data_channel_open", "data_channel_open"),
("local_audio_track", "local_audio_track"),
("remote_audio_track", "remote_audio_track"),
):
record["transport"][field] = bool(record["transport"][field] or event.get(event_field))
elif event_type == "rtc.stats":
stats = record["transport"]["rtc_stats"]
for field in stats:
stats[field] = max(int(stats[field]), max(0, int(event.get(field, 0))))
state = str(event.get("ice_connection_state", ""))[:30]
if state in {"connected", "completed"}:
record["transport"]["ice_connected_observed"] = True
record["transport"]["ice_connection_state"] = state
elif event_type == "error":
record["errors"].append({"at": _now(), "stage": "browser", "message": str(event)[:1000]})
return {"saved": True}
@app.post("/api/calls/{call_id}/finish")
async def finish(call_id: str, finish_request: FinishCall) -> dict[str, Any]:
record = _record(call_id)
record["finished_at_utc"] = _now()
record["finish_reason"] = finish_request.reason
record["acceptance"] = _acceptance(record)
record["status"] = "completed" if record["acceptance"]["passed"] else "ended"
result = _public(record)
peer = PEERS.pop(call_id, None)
RUNTIMES.pop(call_id, None)
if peer is not None:
await peer.close()
return result
@app.get("/api/calls/{call_id}")
async def get_call(call_id: str) -> dict[str, Any]:
record = _record(call_id)
record["acceptance"] = _acceptance(record)
return _public(record)