feat(agent-call): add contract-driven mock executor

This commit is contained in:
2026-09-12 19:29:49 +08:00
parent 6cd28c033e
commit d4ac130a47
47 changed files with 5936 additions and 30 deletions
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
.codegraph/
.local/
deploy/state/
data/*.sqlite3*
deploy/asterisk/generated/
services/asr-web/asr-web
*.test
+4
View File
@@ -38,6 +38,10 @@
| 被叫前缀 | `7089` |
| 我方出口白名单 IP | `123.56.71.98` |
## 测试呼叫号码
- 用户指定测试被叫号码:`18601013734`。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。
- 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。
- 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。
- 传输协议、IP/Digest 鉴权、是否注册、编解码及并发限制仍需供应商确认;当前生成器的 UDP/ulaw 基线不代表这些参数已经确认。
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& mkdir -p /data \
&& chown 65532:65532 /data
COPY agent_call ./agent_call
COPY docs/contracts ./docs/contracts
ENV PYTHONPATH=/app \
AGENT_CALL_HOST=0.0.0.0 \
AGENT_CALL_PORT=8080 \
AGENT_CALL_DB=/data/agent-call.sqlite3 \
AGENT_CALL_OBJECT_DIR=/data/objects
EXPOSE 8080
USER 65532:65532
VOLUME ["/data"]
ENTRYPOINT ["python", "-m", "agent_call"]
+3
View File
@@ -0,0 +1,3 @@
"""Contract-driven mock execution service for agent-call."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .main import main
if __name__ == "__main__":
main()
+2690
View File
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
"""HTTP control/query adapter for the contract-defined non-execution endpoints."""
from __future__ import annotations
import json
import os
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import unquote, urlsplit
from .core import AgentCallService, ServiceError, ValidationError, _integer, _loads
READ_SCOPE = "outbound.read"
CONTROL_SCOPE = "outbound.control"
UPLOAD_SCOPE = "recording.upload"
COMPLETE_SCOPE = "recording.complete"
class AgentCallHandler(BaseHTTPRequestHandler):
service: AgentCallService
server_version = "agent-call/0.1"
def log_message(self, format: str, *args: Any) -> None:
# Request IDs are enough for local diagnostics; never log auth headers or bodies.
return
def _request_id(self) -> str:
value = self.headers.get("X-Request-ID", "")
if not value or len(value) > 128 or any(ch.isspace() for ch in value):
raise ServiceError("REQUEST_ID_REQUIRED", "X-Request-ID is required", 400)
return value
def _tenant_id(self) -> str:
value = self.headers.get("X-Tenant-ID", "")
if not value or len(value) > 128 or any(ch.isspace() for ch in value):
raise ServiceError("TENANT_ID_REQUIRED", "X-Tenant-ID is required", 400)
return value
def _authorize(self, scope: str, tenant_id: str | None = None) -> None:
token_header = self.headers.get("Authorization", "")
if not token_header.startswith("Bearer "):
raise ServiceError("UNAUTHORIZED", "bearer authentication is required", 401)
token = token_header[7:]
token_map = _loads(os.environ.get("HTTP_TOKENS"), {})
if not isinstance(token_map, dict):
raise ServiceError("UNAUTHORIZED", "token configuration is invalid", 401)
identity = token_map.get(token)
if not isinstance(identity, dict):
raise ServiceError("UNAUTHORIZED", "bearer token is not authorized", 401)
scopes = identity.get("scopes", [])
if scope not in scopes and "*" not in scopes:
raise ServiceError("FORBIDDEN", "required scope is missing", 403)
if tenant_id is not None:
tenant_ids = identity.get("tenant_ids", [])
if "*" not in tenant_ids and tenant_id not in tenant_ids:
raise ServiceError("FORBIDDEN", "tenant scope is missing", 403)
def _body(self) -> dict[str, Any]:
raw_length = self.headers.get("Content-Length")
if not raw_length:
raise ValidationError("BODY_REQUIRED", "JSON request body is required")
length = _integer(raw_length)
if length < 1 or length > self.service.profile.get("limits", {}).get(
"max_http_bytes", 65536
):
raise ServiceError(
"BODY_TOO_LARGE", "request body exceeds the HTTP limit", 413
)
try:
raw = self.rfile.read(length)
body = _loads(raw.decode("utf-8"))
except UnicodeDecodeError as exc:
raise ValidationError(
"INVALID_JSON", "request body must be UTF-8 JSON"
) from exc
if not isinstance(body, dict):
raise ValidationError("INVALID_JSON", "request body must be a JSON object")
return body
def _header(self, name: str) -> str:
value = self.headers.get(name, "")
if not value:
raise ServiceError("HEADER_REQUIRED", f"{name} is required", 400)
return value
def _send(
self, status: int, body: Any, content_type: str = "application/json"
) -> None:
encoded = json.dumps(
body, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(encoded)
def _send_text(self, status: int, body: str, content_type: str) -> None:
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(encoded)
def _error(self, exc: ServiceError, request_id: str | None = None) -> None:
body = {
"type": "about:blank",
"title": HTTPStatus(exc.status).phrase
if exc.status in HTTPStatus._value2member_map_
else "Request failed",
"status": exc.status,
"code": exc.code,
"detail": exc.detail,
"request_id": request_id or self.headers.get("X-Request-ID", "unknown"),
"retryable": exc.retryable,
}
try:
self._send(exc.status, body, "application/problem+json")
except (BrokenPipeError, ConnectionResetError):
return
def _dispatch(self, method: str) -> None:
request_id: str | None = None
try:
path = urlsplit(self.path).path
parts = [unquote(part) for part in path.split("/") if part]
if method == "GET" and path == "/healthz/live":
self._send(200, {"status": "live", "mode": self.service.mode})
return
if method == "GET" and path == "/healthz/ready":
if not self.service.ready():
raise ServiceError(
"NOT_READY", "dependency health check failed", 503, True
)
self._send(200, {"status": "ready", "mode": self.service.mode})
return
if method == "GET" and path == "/metrics":
self._send_text(
200, self.service.metrics(), "text/plain; version=0.0.4"
)
return
if not (
method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"]
):
request_id = self._request_id()
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "commands"
):
tenant_id = self._tenant_id()
self._authorize(READ_SCOPE, tenant_id)
self._send(200, self.service.get_command(tenant_id, parts[4]))
return
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "calls"
):
tenant_id = self._tenant_id()
self._authorize(READ_SCOPE, tenant_id)
self._send(200, self.service.get_call(tenant_id, parts[4]))
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "tasks"
and parts[5] == "controls"
):
tenant_id = self._tenant_id()
self._authorize(CONTROL_SCOPE, tenant_id)
body = self._body()
command_id = self._header("Idempotency-Key")
result = self.service.control_task(
tenant_id,
parts[4],
body,
command_id,
allow_hangup=self._has_scope("outbound.hangup", tenant_id),
)
self._send(202, result)
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "calls"
and parts[5] == "replays"
):
tenant_id = self._tenant_id()
self._authorize(CONTROL_SCOPE, tenant_id)
result = self.service.create_replay(
tenant_id,
"call",
parts[4],
self._body(),
self._header("Idempotency-Key"),
)
self._send(202, result)
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "commands"
and parts[5] == "replays"
):
tenant_id = self._tenant_id()
self._authorize(CONTROL_SCOPE, tenant_id)
result = self.service.create_replay(
tenant_id,
"command",
parts[4],
self._body(),
self._header("Idempotency-Key"),
)
self._send(202, result)
return
if (
method == "POST"
and len(parts) == 4
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "recording-uploads"
):
tenant_id = self._tenant_id()
self._authorize(UPLOAD_SCOPE, tenant_id)
result_status, result = self.service.create_upload(
tenant_id, self._body()
)
self._send(result_status, result)
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "recording-uploads"
and parts[5] == "complete"
):
tenant_id = self._tenant_id()
self._authorize(COMPLETE_SCOPE, tenant_id)
result = self.service.complete_upload(tenant_id, parts[4], self._body())
self._send(200, result)
return
if method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"]:
if self.service.mode == "real":
raise ServiceError(
"MOCK_DISABLED",
"mock object storage is disabled in real mode",
404,
)
token = self._header("X-Mock-Upload-Token")
raw_length = self.headers.get("Content-Length")
if not raw_length:
raise ValidationError("BODY_REQUIRED", "upload body is required")
length = _integer(raw_length)
if length > self.service.profile.get("limits", {}).get(
"recording_max_bytes", 16777216
):
raise ServiceError(
"BODY_TOO_LARGE",
"recording exceeds the configured size limit",
413,
)
try:
data = self.rfile.read(length)
except (BrokenPipeError, ConnectionResetError) as exc:
raise ServiceError(
"UPLOAD_INTERRUPTED", "upload connection interrupted", 400
) from exc
upload = self.service.store.one(
"SELECT tenant_id FROM uploads WHERE upload_id=?", (parts[2],)
)
if upload is None:
raise ServiceError("NOT_FOUND", "upload session not found", 404)
self.service.put_upload(parts[2], token, data)
self._send(200, {"status": "uploaded", "upload_id": parts[2]})
return
raise ServiceError("NOT_FOUND", "route not found", 404)
except ServiceError as exc:
self._error(exc, request_id)
except (BrokenPipeError, ConnectionResetError):
return
except Exception:
self._error(
ServiceError("INTERNAL_ERROR", "request could not be completed", 500),
request_id,
)
def _has_scope(self, scope: str, tenant_id: str) -> bool:
token_header = self.headers.get("Authorization", "")
if not token_header.startswith("Bearer "):
return False
token_map = _loads(os.environ.get("HTTP_TOKENS"), {})
identity = (
token_map.get(token_header[7:]) if isinstance(token_map, dict) else None
)
if not isinstance(identity, dict):
return False
tenant_ids = identity.get("tenant_ids", [])
return ("*" in tenant_ids or tenant_id in tenant_ids) and (
scope in identity.get("scopes", []) or "*" in identity.get("scopes", [])
)
def do_GET(self) -> None:
self._dispatch("GET")
def do_POST(self) -> None:
self._dispatch("POST")
def do_PUT(self) -> None:
self._dispatch("PUT")
def make_server(
service: AgentCallService, host: str = "127.0.0.1", port: int = 8080
) -> ThreadingHTTPServer:
class BoundHandler(AgentCallHandler):
pass
BoundHandler.service = service
return ThreadingHTTPServer((host, port), BoundHandler)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import os
from pathlib import Path
from .core import AgentCallService, ConfigurationError, _integer
from .http import make_server
def build_service() -> AgentCallService:
profile = Path(
os.environ.get("AGENT_CALL_PROFILE", "docs/contracts/mock-profile.json")
)
db_path = os.environ.get("AGENT_CALL_DB", "data/agent-call.sqlite3")
object_dir = os.environ.get("AGENT_CALL_OBJECT_DIR")
provider_mode = os.environ.get("AGENT_CALL_MODE")
return AgentCallService(
db_path=db_path,
profile_path=profile,
object_dir=object_dir,
mode=provider_mode,
start_background=True,
)
def main() -> None:
service = build_service()
host = os.environ.get("AGENT_CALL_HOST", "127.0.0.1")
port = _integer(os.environ.get("AGENT_CALL_PORT", "8080"))
server = make_server(service, host, port)
try:
server.serve_forever()
except KeyboardInterrupt:
return
finally:
server.shutdown()
server.server_close()
service.stop()
if __name__ == "__main__":
try:
main()
except ConfigurationError as exc:
raise SystemExit(f"configuration error: {exc.detail}") from exc
+105
View File
@@ -0,0 +1,105 @@
"""Protocol-shaped deterministic mocks used by the mock profile.
They record protocol facts instead of pretending that an HTTP callback is a SIP,
ARI, RTP, AI-stream, or OSS integration.
"""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
class ProtocolJournal:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
def append(self, protocol: str, event: str, **facts: Any) -> None:
entry = {"protocol": protocol, "event": event, **facts}
try:
encoded = json.dumps(
entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock, self.path.open("a", encoding="utf-8") as stream:
stream.write(encoded + "\n")
except (OSError, TypeError, ValueError) as exc:
raise RuntimeError("protocol journal write failed") from exc
def read(self) -> list[dict[str, Any]]:
try:
lines = self.path.read_text(encoding="utf-8").splitlines()
return [json.loads(line) for line in lines if line]
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("protocol journal read failed") from exc
class MockSIPPeer:
def __init__(self, journal: ProtocolJournal) -> None:
self.journal = journal
def invite(self, call_id: str, callee: str, target_uri: str, caller: str) -> None:
self.journal.append(
"sip",
"invite",
call_id=call_id,
callee=callee,
target_uri=target_uri,
caller=caller,
)
def ringing(self, call_id: str) -> None:
self.journal.append("sip", "ringing", call_id=call_id)
def answered(self, call_id: str) -> None:
self.journal.append("sip", "answered", call_id=call_id)
def bye(self, call_id: str, reason: str) -> None:
self.journal.append("sip", "bye", call_id=call_id, reason=reason)
class MockARI:
def __init__(self, journal: ProtocolJournal) -> None:
self.journal = journal
def channel_created(self, call_id: str, cell_id: str) -> None:
self.journal.append("ari", "channel_created", call_id=call_id, cell_id=cell_id)
def bridge_ready(self, call_id: str) -> None:
self.journal.append("ari", "bridge_ready", call_id=call_id)
def media_closed(self, call_id: str) -> None:
self.journal.append("ari", "media_closed", call_id=call_id)
def cleanup(self, call_id: str) -> None:
self.journal.append("ari", "cleanup", call_id=call_id)
class MockRTP:
def __init__(self, journal: ProtocolJournal) -> None:
self.journal = journal
def packet(self, call_id: str, direction: str, sequence: int) -> None:
self.journal.append(
"rtp", "packet", call_id=call_id, direction=direction, sequence=sequence
)
class MockAI:
def __init__(self, journal: ProtocolJournal) -> None:
self.journal = journal
def turn(
self, call_id: str, turn_index: int, speaker: str, playback_state: str
) -> None:
self.journal.append(
"ai",
"turn",
call_id=call_id,
turn_index=turn_index,
speaker=speaker,
playback_state=playback_state,
)
+53
View File
@@ -1,6 +1,55 @@
---
name: agent-call
services:
rabbitmq:
image: >-
rabbitmq:4.1-management-alpine@sha256:eb6736723c5d0831ab12d29e7a2b8ee1082f744370c2a784b50b84b3b4d6d030
restart: unless-stopped
init: true
environment:
RABBITMQ_DEFAULT_USER: >-
${RABBITMQ_DEFAULT_USER:?Set RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: >-
${RABBITMQ_DEFAULT_PASS:?Set RABBITMQ_DEFAULT_PASS}
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 5s
timeout: 3s
retries: 12
volumes:
- rabbitmq_data:/var/lib/rabbitmq
logging:
driver: json-file
options: {max-size: "10m", max-file: "3"}
agent-call:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
rabbitmq:
condition: service_healthy
ports:
- "127.0.0.1:${AGENT_CALL_PORT:-18080}:8080"
environment:
AGENT_CALL_MODE: "mock"
BROKER_MODE: "rabbit"
RABBITMQ_URL: "${RABBITMQ_URL:?Set RABBITMQ_URL}"
HTTP_TOKENS: "${HTTP_TOKENS:?Set HTTP_TOKENS}"
volumes:
- agent_call_data:/data
read_only: false
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 256
mem_limit: 512m
cpus: 2
logging:
driver: json-file
options: {max-size: "10m", max-file: "3"}
asr-web:
build:
context: ./services/asr-web
@@ -33,3 +82,7 @@ services:
options:
max-size: "10m"
max-file: "3"
volumes:
rabbitmq_data:
agent_call_data:
+21
View File
@@ -0,0 +1,21 @@
groups:
- name: agent-call
rules:
- alert: AgentCallOutboxStalled
expr: agent_call_outbox_pending > 0
for: 2m
labels: {severity: warning}
annotations:
summary: agent-call outbox has pending events
- alert: AgentCallDeadLetters
expr: increase(agent_call_dead_letters_total[5m]) > 0
for: 1m
labels: {severity: critical}
annotations:
summary: agent-call rejected or malformed messages entered the DLQ
- alert: AgentCallNotMockSafe
expr: agent_call_mode_info{mode="mock"} != 1
for: 1m
labels: {severity: critical}
annotations:
summary: provider mode changed; verify production admission gates
+1
View File
@@ -11,5 +11,6 @@
"key_pair_name": "",
"spot_price_limit": null,
"system_disk_category": "cloud_essd",
"system_disk_performance_level": "PL1",
"system_disk_gib": 40
}
+18 -1
View File
@@ -2,6 +2,7 @@
"""Read-only by default. Reuse/prepare the fixed Beijing host via the aliyun CLI."""
import argparse
import base64
import fcntl
import hashlib
import json
@@ -230,7 +231,7 @@ def create_params(cfg):
raise CloudError(
"system_disk_gib must be within the approved 40–200 GiB safety bound"
)
return {
params = {
"ImageId": cfg["image_id"],
"InstanceType": cfg["instance_type"],
"VSwitchId": cfg["vswitch_id"],
@@ -244,9 +245,25 @@ def create_params(cfg):
"SpotPriceLimit": price,
"SystemDisk.Category": cfg.get("system_disk_category", "cloud_essd"),
"SystemDisk.Size": disk,
"SystemDisk.PerformanceLevel": cfg.get("system_disk_performance_level", "PL1"),
"Tag.1.Key": "project",
"Tag.1.Value": cfg["project_tag"],
}
user_data_file = cfg.get("user_data_file")
if user_data_file is not None:
if not isinstance(user_data_file, str) or not user_data_file.strip():
raise CloudError("user_data_file must be a non-empty local file path")
path = Path(user_data_file)
if path.is_symlink() or not path.is_file():
raise CloudError("user_data_file must be an existing non-symlink file")
try:
content = path.read_bytes()
except OSError as exc:
raise CloudError("user_data_file cannot be read") from exc
if len(content) > 16 * 1024:
raise CloudError("user_data_file exceeds the ECS 16 KiB limit")
params["UserData"] = base64.b64encode(content).decode("ascii")
return params
def save_state(path, data):
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import argparse
import sqlite3
from pathlib import Path
def copy_database(source: str, destination: str) -> None:
source_path = Path(source)
destination_path = Path(destination)
if not source_path.is_file():
raise FileNotFoundError(source)
destination_path.parent.mkdir(parents=True, exist_ok=True)
source_db = sqlite3.connect(source_path)
destination_db = sqlite3.connect(destination_path)
try:
source_db.backup(destination_db)
destination_db.commit()
finally:
destination_db.close()
source_db.close()
def main() -> int:
parser = argparse.ArgumentParser(
description="offline SQLite backup/restore for agent-call"
)
parser.add_argument("action", choices=("backup", "restore"))
parser.add_argument("source")
parser.add_argument("destination")
args = parser.parse_args()
copy_database(args.source, args.destination)
print(f"{args.action} complete")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import importlib
import json
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))
_core = importlib.import_module("agent_call.core")
queue_name = _core.queue_name
routing_key = _core.routing_key
def check(profile_path: str | Path) -> list[str]:
path = Path(profile_path)
try:
profile = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return [f"profile load failed: {type(exc).__name__}"]
problems: list[str] = []
if profile.get("mode") != "mock":
problems.append("mock profile must declare mode=mock")
for component, provider in profile.get("provider_modes", {}).items():
if component in {"llm", "tts", "asr", "sip", "oss"} and provider == "real":
problems.append(f"mock profile enables real provider: {component}")
for tenant in profile.get("tenants", []):
try:
route = routing_key(tenant["tenant_key"])
queue_name(tenant["tenant_key"])
except Exception as exc:
problems.append(f"tenant route invalid: {type(exc).__name__}")
continue
if len(route.encode("utf-8")) > 255:
problems.append("tenant route exceeds broker limit")
if not profile.get("cells"):
problems.append("no execution cells configured")
return problems
def main() -> int:
problems = check(
sys.argv[1] if len(sys.argv) > 1 else "docs/contracts/mock-profile.json"
)
if problems:
for problem in problems:
print(f"ERROR {problem}")
return 1
print("mock profile clean")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+10
View File
@@ -0,0 +1,10 @@
{
"title": "agent-call overview",
"schemaVersion": 39,
"panels": [
{"type": "stat", "title": "Active calls", "targets": [{"expr": "agent_call_active_calls"}]},
{"type": "stat", "title": "Waiting commands", "targets": [{"expr": "agent_call_waiting_commands"}]},
{"type": "stat", "title": "Outbox pending", "targets": [{"expr": "agent_call_outbox_pending"}]},
{"type": "stat", "title": "Dead letters", "targets": [{"expr": "agent_call_dead_letters_total"}], "transparent": false}
]
}
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import argparse
import importlib
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))
_core = importlib.import_module("agent_call.core")
Store = _core.Store
def migrate(path: str) -> int:
store = Store(path)
row = store.one("SELECT MAX(version) AS version FROM schema_migrations")
print(f"schema_version={row['version']}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="apply idempotent agent-call SQLite migrations"
)
parser.add_argument("database")
args = parser.parse_args()
return migrate(args.database)
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -0,0 +1,9 @@
# Synthetic local values only. Never use this file for production credentials.
RABBITMQ_DEFAULT_USER=agent_call_mock
RABBITMQ_DEFAULT_PASS=replace-before-start
RABBITMQ_URL=amqp://agent_call_mock:replace-before-start@rabbitmq:5672/%2f
# JSON map: token -> tenant IDs and scopes. Inject through a secret manager in real deployments.
HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]}}
AGENT_CALL_MODE=mock
BROKER_MODE=rabbit
AGENT_CALL_PORT=18080
+9
View File
@@ -0,0 +1,9 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: agent-call
metrics_path: /metrics
static_configs:
- targets: ["agent-call:8080"]
+47 -12
View File
@@ -49,6 +49,21 @@ def endpoint(name, data, env):
if not isinstance(registration, bool) or registration and mode != "digest":
raise ValueError("registration requires explicit digest authentication")
text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow=ulaw\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n"
from_user = data.get("from_user")
if from_user is not None:
if not isinstance(from_user, str) or not re.fullmatch(
r"[A-Za-z0-9_.+\-]+", from_user
):
raise ValueError(f"{name}.from_user must be a plain caller identifier")
text += f"from_user={from_user}\n"
display_identity = data.get("caller_id")
if display_identity is not None:
display_identity = scalar(display_identity, name + ".caller_id")
if not re.fullmatch(
r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", display_identity
):
raise ValueError(f"{name}.caller_id contains unsupported characters")
text += f"callerid={display_identity}\n"
auth = ""
if mode == "digest":
user = scalar(data.get("username"), name + ".username")
@@ -56,7 +71,9 @@ def endpoint(name, data, env):
raise ValueError("SIP username must be a plain user identifier")
password_key = "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD"
password = scalar(env.get(password_key), password_key, secret=True)
text += f"outbound_auth={name}-auth\nfrom_user={user}\n"
text += f"outbound_auth={name}-auth\n"
if from_user is None:
text += f"from_user={user}\n"
auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={password}\n"
if registration:
auth += f"\n[{name}-registration]\ntype=registration\ntransport=transport-udp\noutbound_auth={name}-auth\nserver_uri=sip:{host}:{port}\nclient_uri=sip:{user}@{host}:{port}\nretry_interval=60\n"
@@ -89,22 +106,40 @@ def render(cfg, env):
password = scalar(env.get("ARI_PASSWORD"), "ARI_PASSWORD", secret=True)
if len(password) < 32:
raise ValueError("ARI_PASSWORD must have at least 32 characters")
primary = endpoint("provider-primary", cfg.get("primary", {}), env)
backup = endpoint("provider-backup", cfg.get("backup", {}), env)
if (cfg["primary"]["host"], cfg["primary"].get("port", 5060)) == (
cfg["backup"]["host"],
cfg["backup"].get("port", 5060),
):
raise ValueError(
"primary and backup targets must be distinct; shared failure domains still require validation"
)
primary_data = cfg.get("primary", {})
primary = endpoint("provider-primary", primary_data, env)
backup_data = cfg.get("backup")
if backup_data is not None and not isinstance(backup_data, dict):
raise ValueError("backup must be an object when configured")
sections = [primary]
if backup_data:
backup = endpoint("provider-backup", backup_data, env)
if (primary_data["host"], primary_data.get("port", 5060)) == (
backup_data["host"],
backup_data.get("port", 5060),
):
raise ValueError(
"primary and backup targets must be distinct; shared failure domains still require validation"
)
sections.append(backup)
transport = f"[global]\ntype=global\nuser_agent=agent-call\n\n[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\nlocal_net={network}\nexternal_signaling_address={PUBLIC_IP}\nexternal_media_address={PUBLIC_IP}\n\n"
caller = primary_data.get("caller_id") or primary_data.get("from_user")
extensions = "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n"
if caller:
caller = scalar(caller, "primary.caller_id")
extensions += (
"\n[outbound]\n"
"exten => _X.,1,NoOp(agent-call outbound)\n"
f" same => n,Set(CALLERID(all)={caller})\n"
" same => n,Dial(PJSIP/${EXTEN}@provider-primary,60)\n"
" same => n,Hangup()\n"
)
return {
"http.conf": f"[general]\nenabled=yes\nbindaddr={bind}\nbindport=8088\n",
"ari.conf": f"[general]\nenabled=yes\npretty=no\n\n[outbound]\ntype=user\nread_only=no\npassword={password}\n",
"pjsip.conf": transport + primary + "\n" + backup,
"pjsip.conf": transport + "\n".join(sections),
"rtp.conf": "[general]\nrtpstart=10000\nrtpend=10800\nstrictrtp=yes\n",
"extensions.conf": "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n",
"extensions.conf": extensions,
}
+5
View File
@@ -0,0 +1,5 @@
# Contract fixtures
`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes.
All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration.
+23
View File
@@ -0,0 +1,23 @@
{
"schema_version": "1.0",
"command_type": "call.execute",
"command_id": "cmd_demo_001",
"tenant_id": "tenant-demo",
"tenant_key": "tenant-demo-key",
"trace_id": "trace_demo_001",
"issued_at": "2026-09-11T08:00:00Z",
"not_after": "2099-09-11T08:05:00Z",
"payload": {
"execution_id": "exec_demo_001",
"task_id": "task-demo",
"task_item_id": "item_demo",
"task_revision": 1,
"callee": "18601013734",
"route_policy_id": "route_policy_test",
"caller_profile_id": "caller_profile_test",
"agent_version_id": "agent_v1",
"variables": {},
"ring_timeout_ms": 30000,
"max_call_duration_ms": 180000
}
}
+303
View File
@@ -0,0 +1,303 @@
# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema
openapi: 3.1.0
info:
title: agent-call Executor Control API
version: 1.0.0
description: >-
Internal control, query, replay, and recording hand-off API. Call execution
enters through RabbitMQ, not HTTP.
servers:
- url: https://executor.internal
security:
- bearerAuth: []
paths:
/internal/v1/outbound/tasks/{task_id}/controls:
post:
operationId: controlTask
summary: Persist a pause, resume, or stop barrier
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/TaskId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ControlRequest'
responses:
'202':
description: Reliably persisted, not yet necessarily applied
headers:
Location:
schema: {type: string}
content:
application/json:
schema: {$ref: '#/components/schemas/ControlAccepted'}
'409': {$ref: '#/components/responses/Conflict'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/v1/outbound/commands/{command_id}:
get:
operationId: getCommand
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/CommandId'
responses:
'200':
description: Command snapshot
content:
application/json:
schema: {$ref: '#/components/schemas/Command'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/v1/outbound/calls/{call_id}:
get:
operationId: getCall
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/CallId'
responses:
'200':
description: Call snapshot
content:
application/json:
schema: {$ref: '#/components/schemas/Call'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/v1/outbound/calls/{call_id}/replays:
post:
operationId: replayCall
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/CallId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayRequest'}
responses:
'202':
description: Replay persisted for bounded broker delivery
headers:
Location: {schema: {type: string}}
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayAccepted'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
'410': {$ref: '#/components/responses/ReplayExpired'}
/internal/v1/outbound/commands/{source_command_id}/replays:
post:
operationId: replayCommand
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/SourceCommandId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayRequest'}
responses:
'202':
description: Replay persisted for bounded broker delivery
headers:
Location: {schema: {type: string}}
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayAccepted'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
'410': {$ref: '#/components/responses/ReplayExpired'}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
parameters:
TenantId:
name: X-Tenant-ID
in: header
required: true
schema: {type: string}
RequestId:
name: X-Request-ID
in: header
required: true
schema: {type: string}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string}
TaskId:
name: task_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
CommandId:
name: command_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
SourceCommandId:
name: source_command_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
CallId:
name: call_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
schemas:
Id:
type: string
minLength: 1
maxLength: 128
pattern: '^[^\\s/\\\\]+$'
ControlRequest:
type: object
additionalProperties: false
required: [command_id, action, expected_task_revision, reason]
properties:
command_id: {$ref: '#/components/schemas/Id'}
action: {type: string, enum: [pause, resume, stop]}
expected_task_revision: {type: integer, minimum: 1}
active_call_policy: {type: string, enum: [drain, hangup]}
reason: {type: string, minLength: 1, maxLength: 512}
ControlAccepted:
type: object
required:
- command_id
- tenant_id
- tenant_key
- task_id
- status
- requested_task_revision
- accepted_at
properties:
command_id: {$ref: '#/components/schemas/Id'}
tenant_id: {type: string}
tenant_key: {type: string}
task_id: {$ref: '#/components/schemas/Id'}
status: {const: accepted}
requested_task_revision: {type: integer}
accepted_at: {type: string, format: date-time}
ReplayRequest:
type: object
additionalProperties: false
required: [command_id, reason]
properties:
command_id: {$ref: '#/components/schemas/Id'}
reason: {type: string, minLength: 1, maxLength: 512}
ReplayAccepted:
type: object
required: [command_id, status, snapshot_cutoff]
properties:
command_id: {$ref: '#/components/schemas/Id'}
status: {const: accepted}
snapshot_cutoff: {type: string, format: date-time}
Command:
type: object
required:
- command_id
- command_type
- tenant_id
- tenant_key
- status
- aggregate_version
properties:
command_id: {$ref: '#/components/schemas/Id'}
command_type: {type: string}
tenant_id: {type: string}
tenant_key: {type: string}
task_id: {type: [string, 'null']}
execution_id: {type: [string, 'null']}
call_id: {type: [string, 'null']}
status: {type: string}
reason_code: {type: [string, 'null']}
wait_reason_code: {type: [string, 'null']}
accepted_at: {type: [string, 'null'], format: date-time}
waiting_since: {type: [string, 'null'], format: date-time}
admission_deadline: {type: [string, 'null'], format: date-time}
requested_task_revision: {type: [integer, 'null']}
applied_task_revision: {type: [integer, 'null']}
task_state: {type: [string, 'null']}
updated_at: {type: string, format: date-time}
aggregate_version: {type: integer, minimum: 1}
Call:
type: object
required:
- call_id
- execution_id
- call_state
- call_version
- attempts
- transcript
- recordings
- delivery
- snapshot_at
properties:
call_id: {type: string}
execution_id: {type: string}
task_id: {type: string}
task_item_id: {type: string}
call_state: {type: string}
call_version: {type: integer}
reason_code: {type: [string, 'null']}
outcome: {type: [string, 'null']}
started_at: {type: [string, 'null'], format: date-time}
ended_at: {type: [string, 'null'], format: date-time}
duration_ms: {type: [integer, 'null']}
attempts: {type: array, items: {type: object}}
transcript: {type: object}
recordings: {type: array, items: {type: object}}
delivery: {type: object}
snapshot_at: {type: string, format: date-time}
responses:
Unauthorized:
description: Unauthorized
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Forbidden:
description: Forbidden
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
NotFound:
description: Not found without cross-tenant enumeration
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Conflict:
description: Idempotency or revision conflict
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
ReplayExpired:
description: Retention window expired
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Problem:
type: object
additionalProperties: false
required: [type, title, status, code, detail, request_id, retryable]
properties:
type: {type: string, format: uri-reference}
title: {type: string}
status: {type: integer}
code: {type: string}
detail: {type: string}
request_id: {type: string}
retryable: {type: boolean}
+54
View File
@@ -0,0 +1,54 @@
{
"profile_version": "1.0.0",
"mode": "mock",
"provider_modes": {
"saas": "mock",
"database": "sqlite",
"rabbitmq": "memory",
"sip": "mock",
"asterisk": "mock",
"asr": "mock",
"llm": "mock",
"tts": "mock",
"oss": "mock",
"cloud": "fake-cli"
},
"versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"},
"limits": {
"admission_window_s": 30,
"ring_timeout_ms": 30000,
"max_call_duration_ms": 180000,
"max_mq_bytes": 262144,
"max_queue_messages": 1000,
"max_http_bytes": 65536,
"recording_max_bytes": 16777216,
"replay_retention_s": 604800,
"upload_ttl_s": 300,
"global_concurrency": 6,
"global_cps": 3,
"tenant_concurrency": 2,
"tenant_cps": 1,
"cell_capacity": 4,
"turns": 2,
"hold_ms": 0
},
"random_seed": 7,
"tenants": [
{"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true},
{"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true},
{"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true}
],
"tasks": [
{"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1},
{"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1},
{"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1}
],
"routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}],
"caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}],
"agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}],
"cells": [
{"cell_id": "cell-a", "capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"},
{"cell_id": "cell-b", "capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}
],
"failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"]
}
+40
View File
@@ -0,0 +1,40 @@
# agent-call MQ topology v1.0
This file is an implementation companion to the field authority in
`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`.
| Element | Value |
| --- | --- |
| Namespace | `agent-call` |
| Command exchange | `agent-call.commands.v1`, durable `direct` |
| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding |
| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` |
| Event exchange | `agent-call.events.v1`, durable `topic` |
| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` |
| Event routing key | `agent-call.{event_type}` |
| Body limit | `262144` UTF-8 bytes in the Mock profile |
| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes |
## Delivery rules
1. SaaS persists the command publication record before publishing. A mandatory
publisher confirmation is required; an unroutable/full queue leaves the
original record retained for bounded retry.
2. The executor consumes only its trusted tenant queue. RabbitMQ messages are
acknowledged after durable SQLite acceptance or durable dead-lettering, not
when they are fetched.
3. Executor business events are written to the same database transaction as
the state transition. The outbox dispatcher publishes them durably and the
SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown
after broker confirmation; it does not trigger unbounded republishing.
4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and
routing key. It is not normalized, encoded, truncated or cleaned. A route
over the byte budget is retained and not sent.
5. Replay publishes the original event body and original `event_id` from a
fixed retention cutoff. It never creates a new business fact and never
includes events written after that cutoff.
6. HTTP has no call execution or redial endpoint. Control, query, replay and
recording metadata paths require bearer scope and tenant scope.
The in-process broker is only for deterministic tests. Docker Compose uses the
same topology through the `pika` adapter and RabbitMQ durable queues.
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://agent-call.invalid/contracts/mq.schema.json",
"title": "agent-call MQ command and event envelope",
"oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}],
"$defs": {
"id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"},
"tenantKey": {"type": "string", "minLength": 1},
"time": {"type": "string", "format": "date-time"},
"executeCommand": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"],
"properties": {
"schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "command_id": {"$ref": "#/$defs/id"},
"tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"},
"issued_at": {"$ref": "#/$defs/time"}, "not_after": {"$ref": "#/$defs/time"}, "payload": {"$ref": "#/$defs/executePayload"}
}
},
"executePayload": {
"type": "object", "additionalProperties": false,
"required": ["execution_id", "task_id", "task_item_id", "task_revision", "callee", "route_policy_id", "caller_profile_id", "agent_version_id", "variables", "ring_timeout_ms", "max_call_duration_ms"],
"properties": {
"execution_id": {"$ref": "#/$defs/id"}, "task_id": {"$ref": "#/$defs/id"}, "task_item_id": {"$ref": "#/$defs/id"},
"task_revision": {"type": "integer", "minimum": 1}, "callee": {"type": "string", "minLength": 1, "maxLength": 256},
"route_policy_id": {"$ref": "#/$defs/id"}, "caller_profile_id": {"$ref": "#/$defs/id"}, "agent_version_id": {"$ref": "#/$defs/id"},
"variables": {"type": "object", "additionalProperties": true}, "ring_timeout_ms": {"type": "integer", "minimum": 1}, "max_call_duration_ms": {"type": "integer", "minimum": 1}
}
},
"event": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "event_id", "event_type", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version", "payload"],
"properties": {
"schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"},
"event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]},
"tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"},
"aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"}
}
}
}
}
+169
View File
@@ -0,0 +1,169 @@
# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema
openapi: 3.1.0
info:
title: agent-call SaaS Recording Handoff API
version: 1.0.0
description: >-
Internal storage handshake. Business results still return through RabbitMQ.
servers:
- url: https://saas.internal
security:
- bearerAuth: []
paths:
/internal/v1/outbound/recording-uploads:
post:
operationId: createRecordingUpload
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UploadRequest'}
responses:
'201':
description: Upload session created or existing session returned
headers: {Cache-Control: {schema: {const: no-store}}}
content:
application/json:
schema: {$ref: '#/components/schemas/UploadSession'}
'200':
description: Existing upload session
content:
application/json:
schema: {$ref: '#/components/schemas/UploadSession'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/v1/outbound/recording-uploads/{upload_id}/complete:
post:
operationId: completeRecordingUpload
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- name: upload_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CompleteRequest'}
responses:
'200':
description: Object independently verified
content:
application/json:
schema: {$ref: '#/components/schemas/VerifiedUpload'}
'409': {$ref: '#/components/responses/Conflict'}
'410': {$ref: '#/components/responses/Expired'}
'422': {$ref: '#/components/responses/Unprocessable'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer}
parameters:
TenantId:
name: X-Tenant-ID
in: header
required: true
schema: {type: string}
RequestId:
name: X-Request-ID
in: header
required: true
schema: {type: string}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string}
schemas:
Id:
type: string
minLength: 1
maxLength: 128
pattern: '^[^\\s/\\\\]+$'
UploadRequest:
type: object
additionalProperties: false
required:
- recording_id
- call_id
- content_type
- size_bytes
- checksum_algorithm
- checksum
- channels
- sample_rate_hz
- duration_ms
properties:
recording_id: {$ref: '#/components/schemas/Id'}
call_id: {$ref: '#/components/schemas/Id'}
content_type: {type: string, const: audio/wav}
size_bytes: {type: integer, minimum: 1}
checksum_algorithm: {type: string, const: SHA-256}
checksum: {type: string, pattern: '^[0-9a-f]{64}$'}
channels: {type: integer, const: 1}
sample_rate_hz: {type: integer, minimum: 8000}
duration_ms: {type: integer, minimum: 1}
UploadSession:
type: object
required:
- upload_id
- recording_id
- expires_at
- upload_method
- upload_url
- required_headers
- constraints
properties:
upload_id: {$ref: '#/components/schemas/Id'}
recording_id: {$ref: '#/components/schemas/Id'}
expires_at: {type: string, format: date-time}
upload_method: {const: PUT}
upload_url: {type: string, format: uri}
required_headers: {type: object}
constraints: {type: object}
oss_id: {type: [string, 'null']}
CompleteRequest:
type: object
additionalProperties: false
required: [recording_id, size_bytes, checksum_algorithm, checksum]
properties:
recording_id: {$ref: '#/components/schemas/Id'}
size_bytes: {type: integer, minimum: 1}
checksum_algorithm: {const: SHA-256}
checksum: {type: string, pattern: '^[0-9a-f]{64}$'}
etag: {type: [string, 'null']}
VerifiedUpload:
type: object
required: [upload_id, recording_id, status, oss_id, verified_at]
properties:
upload_id: {$ref: '#/components/schemas/Id'}
recording_id: {$ref: '#/components/schemas/Id'}
status: {const: verified}
oss_id: {type: string}
verified_at: {type: string, format: date-time}
Problem:
type: object
required: [type, title, status, code, detail, request_id, retryable]
properties:
type: {type: string}
title: {type: string}
status: {type: integer}
code: {type: string}
detail: {type: string}
request_id: {type: string}
retryable: {type: boolean}
responses:
Unauthorized: {description: Unauthorized}
Forbidden: {description: Forbidden}
Conflict: {description: Idempotency conflict}
Expired: {description: Upload expired}
Unprocessable: {description: Object failed independent verification}
@@ -0,0 +1 @@
acceptance_gate_exit=1
@@ -0,0 +1,121 @@
{
"finished_at": "2026-09-11T16:07:58.537Z",
"gate_status": "INCOMPLETE",
"probes": {
"V01": {
"duration_ms": 11.0,
"evidence": {
"http_execute_route": "absent",
"profile": "mock",
"schema_negative_cases": 3
},
"status": "PASS"
},
"V02": {
"duration_ms": 95.403,
"evidence": {
"calls": 1,
"different_execution_command": "associated",
"same_command_replays": 1
},
"status": "PASS"
},
"V03": {
"duration_ms": 204.088,
"evidence": {
"lease_duplicate_calls": 0,
"scheduler_instances": 2,
"tenants_admitted": [
"tenant-demo",
"tenant-b",
"tenant-c"
]
},
"status": "PASS"
},
"V04": {
"duration_ms": 28.609,
"evidence": {
"affected_commands": 1,
"stale_cas": "rejected",
"waiting_barrier": "applied"
},
"status": "PASS"
},
"V05": {
"duration_ms": 83.973,
"evidence": {
"dead_letters": 0,
"recovered": true,
"retained_id": "cmd_broker_retry"
},
"status": "PASS"
},
"V06/V07": {
"duration_ms": 2077.321,
"evidence": {
"asterisk_real": "not_run",
"fallback_redials": 0,
"mock_protocol": "pass",
"outcomes": {
"busy": "busy",
"disconnect": "unknown",
"no_answer": "no_answer"
}
},
"status": "PASS"
},
"V08": {
"duration_ms": 668.155,
"evidence": {
"old_tts_reused": false,
"segments": 200,
"turns": 100
},
"status": "PASS"
},
"V09": {
"duration_ms": 1087.237,
"evidence": {
"bad_checksum": "failed",
"invalid_ready": 0,
"missing_object": "failed"
},
"status": "PASS"
},
"V10": {
"duration_ms": 82.427,
"evidence": {
"original_event_ids": true,
"pending_after_recovery": 0,
"pending_during_outage": 13
},
"status": "PASS"
},
"V11/V12": {
"duration_ms": 88.53,
"evidence": {
"backup": "verified",
"metrics": "healthy",
"real_mode_mock_rejection": true,
"rollback_preserves_db": true
},
"status": "PASS"
},
"V13": {
"duration_ms": 0.003,
"evidence": {
"reason": "requires dedicated SCALE-MOCK host and sustained media load",
"required_hold_minutes": 60,
"simulated_calls_declared": 1200,
"status": "not_run"
},
"status": "NOT_RUN"
}
},
"production": {
"V14": "blocked_external_evidence"
},
"profile": "mock",
"started_at": "2026-09-11T16:07:54.111Z"
}
@@ -0,0 +1,124 @@
{
"finished_at": "2026-09-12T03:58:28.889Z",
"gate_status": "INCOMPLETE",
"probes": {
"V01": {
"duration_ms": 11.378,
"evidence": {
"http_execute_route": "absent",
"profile": "mock",
"schema_negative_cases": 3
},
"status": "PASS"
},
"V02": {
"duration_ms": 103.751,
"evidence": {
"calls": 1,
"different_execution_command": "associated",
"same_command_replays": 1
},
"status": "PASS"
},
"V03": {
"duration_ms": 228.406,
"evidence": {
"lease_duplicate_calls": 0,
"scheduler_instances": 2,
"tenants_admitted": [
"tenant-demo",
"tenant-b",
"tenant-c"
]
},
"status": "PASS"
},
"V04": {
"duration_ms": 27.291,
"evidence": {
"affected_commands": 1,
"stale_cas": "rejected",
"waiting_barrier": "applied"
},
"status": "PASS"
},
"V05": {
"duration_ms": 90.358,
"evidence": {
"dead_letters": 0,
"recovered": true,
"retained_id": "cmd_broker_retry"
},
"status": "PASS"
},
"V06/V07": {
"duration_ms": 2079.879,
"evidence": {
"asterisk_real": "not_run",
"fallback_redials": 0,
"mock_protocol": "pass",
"outcomes": {
"busy": "busy",
"disconnect": "unknown",
"no_answer": "no_answer"
}
},
"status": "PASS"
},
"V08": {
"duration_ms": 823.004,
"evidence": {
"old_tts_reused": false,
"segments": 200,
"turns": 100
},
"status": "PASS"
},
"V09": {
"duration_ms": 1092.352,
"evidence": {
"bad_checksum": "failed",
"invalid_ready": 0,
"missing_object": "failed"
},
"status": "PASS"
},
"V10": {
"duration_ms": 78.156,
"evidence": {
"original_event_ids": true,
"pending_after_recovery": 0,
"pending_during_outage": 13
},
"status": "PASS"
},
"V11/V12": {
"duration_ms": 76.478,
"evidence": {
"backup": "verified",
"metrics": "healthy",
"real_mode_mock_rejection": true,
"rollback_preserves_db": true
},
"status": "PASS"
},
"V13": {
"duration_ms": 0.507,
"evidence": {
"evidence_file": "docs/evidence/scale-mock-2026-09-12.json",
"minimum_answered_calls": 1004,
"observed_hold_seconds": 3600.0,
"production_claim": false,
"status": "observed",
"submitted_calls": 1200,
"tenants": 100
},
"status": "PASS"
}
},
"production": {
"V14": "blocked_external_evidence"
},
"profile": "mock",
"started_at": "2026-09-12T03:58:24.277Z"
}
+27
View File
@@ -0,0 +1,27 @@
# R1 Asterisk/SIP 真实联调证据(2026-09-12)
## 结果
- 新建北京竞价 ECS:`i-2zed29evhasbqzag63mg`,已按测试清理删除。
- 固定 EIP:`123.56.71.98`,未释放、未手工解绑;清理后状态为 `Available`。
- Asterisk:`22.10.1`,镜像使用固定 digest:`sha256:0f5e3cd0e9a86bc9dc4750929abee9ba1faae11ed39739f3e75343d39d837e41`。
- 线路:UDP `61.132.228.221:5060`,IP 白名单免注册。
- PJSIP contact:`Avail`,实测 RTT 约 `22–25 ms`。
- Asterisk 对外信令/媒体地址均为 `123.56.71.98`;UDP 5060、RTP 10000–10800 已配置。
- 已按确认规则使用原值主叫 `BD93205882`:`from_user` 与 `CALLERID` 均设置,未发送 P-Asserted-Identity。
## 单次真实外呼探针
- 原始被叫:`18601013734`。
- 按线路规则构造:`7089` + 原始被叫 = `708918601013734`,未重复加前缀。
- INVITE 已从 `123.56.71.98:5060` 发到供应商并收到 `100 trying`,说明公网出口、SIP 路由和白名单链路可达。
- 供应商随后返回:`488 Not Acceptable Here`,`Reason: Q.850;cause=88;text="INCOMPATIBLE_DESTINATION"`。
- 未收到 `180/183/200`,未建立通话或 RTP;未自动重试。
## 清理与遗留
- 测试 ECS 已停止并删除;未操作无关实例。
- 专用安全组已删除;此前仅含供应商 `/32` 的 SIP/RTP 规则,临时 SSH `/32` 规则已先撤销。
- 专用 SSH KeyPair 已删除,本地受限状态/密钥文件已清理,未写入代码、文档或日志。
- 当前阻塞是供应商对目标号码/线路规则返回 `INCOMPATIBLE_DESTINATION`;需供应商确认被叫格式/路由、主叫域及编解码要求后才能再次发起测试。
- 本次未验证真实 MQ、ASR/LLM/TTS、OSS 或完整 AI 通话。
@@ -0,0 +1,16 @@
# Aliyun Beijing read-only inventory — 2026-09-11
Commands executed without create/associate/delete actions:
```text
aliyun ecs DescribeInstances --RegionId cn-beijing --PageSize 100
aliyun vpc DescribeEipAddresses --RegionId cn-beijing --PageSize 100
```
Observed facts:
- Region `cn-beijing` has one existing ECS instance, `ecs.e-c1m1.large`, status `Running`, charge type `PrePaid`, with public IP `39.105.111.158`. It is not reused or modified because it is not an authorized target spot test cell.
- The registered SIP egress EIP `123.56.71.98` exists in Beijing, allocation `eip-2zeevfsaxzwuue2szy7xb`, status `Available`, and is currently unassociated.
- No instance/EIP was created, rebound, stopped, deleted, or released.
Creation remains gated on explicit test budget/spot price ceiling, disk/VSwitch/security-group selection, SSH key authorization, and the real SIP/AI/OSS acceptance window. The available EIP must be associated only through the approved cell plan; it must never be placed in a SIP trunk contact field or replaced with a newly allocated address.
+1
View File
@@ -0,0 +1 @@
mock profile clean
@@ -0,0 +1,5 @@
ready= ready
command_status= completed
command_reason= CALL_COMPLETED
rabbit_provider= agent_call_provider_mode_info{component="rabbitmq",mode="rabbit"} 1
outbox_pending= agent_call_outbox_pending 0
+65
View File
@@ -0,0 +1,65 @@
{
"acceptance": "V13",
"profile": "mock",
"status": "observed",
"run_started_at": "2026-09-12T02:50:20Z",
"run_finished_at_observed": "2026-09-12T03:51:31Z",
"host": {
"region": "cn-beijing",
"zone": "cn-beijing-g",
"instance_id": "i-2zeivenvp67lj4es3l62",
"instance_type": "ecs.hfg6.large",
"fixed_egress_ip": "123.56.71.98",
"image_id": "rockylinux_9_8_x64_20G_alibase_20260827.vhd",
"system_disk": {
"category": "cloud_essd",
"size_gib": 40,
"performance_level": "PL1"
},
"spot_price_limit_cny_per_hour": 0.15
},
"load": {
"tenants": 100,
"submitted_calls": 1200,
"one_cell_disabled": true,
"required_hold_minutes": 60,
"threshold_reached_seconds": 51.479,
"observed_hold_seconds": 3600.0,
"answered_calls_at_end_before_teardown": 1200,
"minimum_answered_calls": 1004
},
"teardown": {
"stop_commands_requested": 100,
"stop_commands_applied": 100,
"active_calls_after_cleanup": 0,
"nonterminal_commands_after_cleanup": 0,
"outbox_pending_after_cleanup": 0,
"clean": true
},
"remote_evidence": {
"result_sha256": "a5faf7adccfb77151e2fcc20efbd53fbdede7bf77354685fb55b641148de9db7",
"sqlite_sha256": "3fdc0461b7da8c0f14b6b0f186af3654082c7512d0991373dc7bf6f37b82ff8b",
"source_hashes": {
"scripts/scale_mock.py": "5b10afb2a741b440f3a73688cd5632c1c4890ff760629e1fbbce6dcc89073087",
"agent_call/core.py": "84f7085bfdecb337229c5415687c852fc3e639c6bbf08be2886f6b1ead50f590",
"docs/contracts/mq.schema.json": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a"
},
"call_states_after_teardown": {
"failed": 1200
},
"command_states_after_teardown": {
"applied": 100,
"failed": 1200
},
"event_counts": {
"call.finished": 1200,
"call.status": 4800,
"command.result": 3830
}
},
"limitations": [
"This is SCALE-MOCK protocol/load evidence only; continuous_media_evidence is false.",
"It does not verify real RTP, Asterisk, SIP, ASR, LLM, TTS, OSS, SaaS, production SLA, or N+1 capacity."
],
"production_claim": false
}
@@ -0,0 +1,10 @@
{
"calls": 1200,
"calls_per_tenant": 12,
"cells": 101,
"reason": "pass --run only on a dedicated SCALE-MOCK host",
"required_hold_minutes": 60,
"safe_capacity_after_one_cell_loss": 1200,
"status": "not_run",
"tenants": 100
}
@@ -50,7 +50,7 @@ OpenAPI 只描述 HTTP 请求/响应;MQ 用 JSON Schema 和真实 AMQP 语义
### 3.2 契约交付与测试约束
以下是 **D01 计划产物,当前未生成**;目录统一放在 docs/contracts/,不在本轮创建空文件占位:
以下是 **D01 已落地产物**;目录统一放在 docs/contracts/,由契约测试和 Mock 验收持续校验:
- `executor.openapi.yaml`:呼出应用提供的控制、查询、两类补传;`saas.openapi.yaml`:SaaS 提供的上传授权/完成确认。使用 OpenAPI 3.1,固定幂等、鉴权、错误和版本语义。
- `mq.schema.json`:执行、事件外壳及分类型 payload;集中样例和正反例复用同一 Schema。标准 HTTP 响应/事件容许兼容的可选扩展,未知主版本隔离;拒绝非法命令字段,不把严格校验和任意扩展混为一谈。
@@ -289,7 +289,7 @@ M3产物 + C1~C6真实配置/权限(受控存放)+ R1/R2全部证据 + 实
## 10. 本次文档验收与后续入口
本次仅检查文档引用、门禁一致性、既有样例完整性和Markdown;当前所有M/R运行用例仍为待执行。当前已存在的回归命令可继续使用,但不能把它们当作新平台全部测试:
本轮已完成文档引用、契约/实现一致性、样例完整性、Mock开发回归及V01~V13验证;V14/R1/R2/R3仍因真实外部证据待执行。当前回归命令可继续使用,但不能把Mock结果当作生产验收:
```bash
python3 -m unittest discover -s tests -v
@@ -298,4 +298,12 @@ node --test tests/test_pcm.cjs
bash -n deploy/asterisk.sh
```
下一实施入口是 D01:生成并验证机器可读契约与Mock profile,再做D02;无需再次讨论已接受的A类方案。遇到真实供应商协议/合规/资源事实不兼容,只对具体差异登记变更,不把整个计划退回重选,也不让Mock假成功掩盖差异。
下一实施入口是 D12/R1:在取得真实SaaS、SIP/Asterisk、ASR/LLM/TTS、OSS、出口、容量和运维证据后逐项替换Mock并复验;无需再次讨论已接受的A类方案。遇到真实供应商协议/合规/资源事实不兼容,只对具体差异登记变更,不把整个计划退回重选,也不让Mock假成功掩盖差异。
### 10.1 当前执行记录(2026-09-12)
已落地 D01 的 OpenAPI 3.1、共用 MQ Schema、集中样例和 Mock profile,以及 D02/D03 的 SQLite 持久状态、租户独立 RabbitMQ 拓扑、outbox/inbox、CAS 控制、重放、Mock SIP/ARI/RTP/AI/OSS 协议记录、监控/告警、迁移/备份和 Compose 部署入口。测试号码 `18601013734` 在 Mock 与明确授权的 R1 探针中均保持原值;当前线路目标按规则生成 `708918601013734`。
本轮可重复命令 `python3 -m unittest discover -s tests -v` 和 RabbitMQ Compose smoke 已通过;`python3 -m scripts.acceptance` 的 V01~V13 Mock 探针通过,因 V14 仍需外部真实证据而按门禁返回非零,证据归档于 `docs/evidence/acceptance-mock-2026-09-12.json`。SCALE-MOCK 在阿里云北京专用竞价 Cell `ecs.hfg6.large` 上完成:100 租户、1200 模拟呼叫、禁用1个Cell,最低1004路已回答并连续观察3600秒;详见 `docs/evidence/scale-mock-2026-09-12.json`。该证据仅证明 Mock 协议/持久状态负载基线,`continuous_media_evidence` 为 false,不替代真实媒体容量。
R1 已完成一次真实 Asterisk/SIP 单路探针:新建测试 ECS `i-2zed29evhasbqzag63mg` 使用固定 EIP `123.56.71.98` 和 Asterisk 22.10.1 digest,PJSIP contact/OPTIONS 可达,真实 INVITE 已送达供应商;目标呼叫收到 `488`、`Q.850 cause=88 INCOMPATIBLE_DESTINATION`,未接通、未建立 RTP,证据见 `docs/evidence/asterisk-real-2026-09-12.md`。该测试实例已停止并删除,专用安全组和KeyPair也已删除,EIP 未释放且回到 Available,临时 SSH 规则已撤销。真实接通/RTP、ASR/LLM/TTS/OSS、生产 1000 路完整 AI、N+1、实际CPS/SLO及副作用正确性仍阻塞,现有产物不得据此宣称生产完成。
+48 -14
View File
@@ -1,6 +1,6 @@
# 部署接入:本轮实现与运行说明
> 本文只说明当前已存在的ASR/部署工具。后续完整执行平台按[最终开发部署监控与验收计划](最终开发部署监控与验收计划_v1.0.md)从契约与Mock开始;未来服务、Compose和运行命令须实现后补充,不把本运行说明当作完整平台已上线。
> 本文同时说明现有 ASR/部署工具与已落地的 agent-call Mock 验证栈。完整执行平台仍按[最终开发部署监控与验收计划](最终开发部署监控与验收计划_v1.0.md)推进;Mock 通过不等于真实线路、供应商或生产容量验收。
## 1. 实施边界
@@ -12,12 +12,13 @@
| ASR 连接与协议 | 已加固:断开清理、取消、写入/启动时限、最终结果背压、火山帧长度及gzip解压上限 |
| 部署底座 | 已实现:非root/read-only ASR容器、回环端口、Asterisk配置生成和显式启动检查 |
| 阿里云主机准备 | 已实现CLI驱动的只读计划、受控创建竞价实例、复用主机和绑定既有EIP;默认不修改云资源 |
| 云端实际操作 | 未执行;当前本机无aliyun CLI/云凭据,未核实固定IP归属,未创建实例或改绑IP |
| Asterisk真实接入 | 未执行;指定SIP地址/鉴权、VPC网络、镜像digest等仍需填写 |
| 云端实际操作 | 已完成V13一次性专用竞价Cell创建、绑定固定EIP、SCALE-MOCK及回收;测试实例已删除,固定EIP保留 |
| Asterisk真实接入 | 已完成R1单路底座与SIP探针;OPTIONS/contact可达,但真实INVITE被供应商以488/Q.850 cause=88拒绝,未通过外呼验收 |
| LLM/TTS | 未实现、未启用,等待用户新的供应商/协议/参数规范 |
| ARI业务调度、自动FALLBACK、MQ/OSS回传 | 尚未实现;仍按开发计划推进,不能把两个trunk配置当成自动切换代码 |
| agent-call Mock 调度、MQ/OSS闭环 | 已实现:租户独立路由、DB状态、CAS控制、outbox/inbox、Mock SIP/ARI/RTP/AI和OSS校验;未替代真实Asterisk/供应商验收 |
| ARI业务调度、自动FALLBACK、真实MQ/OSS回传 | 未完成;现有主备trunk配置不能当成自动切换代码 |
当前目标是一台北京竞价ECS上的部署底座。ASR测试台与SIP媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。
当前目标仍是一台北京竞价ECS上的部署底座。R1已验证固定EIP、UDP SIP链路和Asterisk配置;真实通话路由仍被供应商拒绝。ASR测试台与电话媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。
参考:`sip-research@6a8064e53bb7eadd73f03373524953e68330976c`;ASR协议源自`voice_test@6772bf4`,仅导入`asr.go/asr_bailian.go/asr_volc.go`后进行加固,不导入原main、配置页、LLM或TTS代码。
@@ -166,7 +167,7 @@ python3 deploy/render_asterisk.py --config .local/asterisk.json
### 5.2 镜像与安全组
`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。参考仓库使用latest、历史记录22.10.1,不代表该镜像当前版本已经被本项目验证;本轮没有拉取或启动真实Asterisk镜像。
`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。本轮R1使用内部Registry中的固定digest镜像并实测Asterisk 22.10.1;该镜像验证仅覆盖单路底座,不等于生产镜像批准或容量验收。配置读取权限按镜像实际UID/GID修正为必要的组读权限,未使用chmod 777。
确认:SIP服务端IP/协议/端口、RTP回程、EIP/NAT、实际VPC网段、管理来源、录音卷目录和权限。安全组只按来源和用途开放;不公开裸ARI,不清空既有防火墙。
@@ -200,16 +201,49 @@ bash -n deploy/asterisk.sh
- 自动测试:云只读/归属/固定IP、创建幂等、失败恢复、EIP绑定竞态、库存完整性;Asterisk配置验证;ASR鉴权、取消、模拟WebSocket结果、帧边界及PCM编码。
- 本地容器:ASR镜像构建、非root/read-only运行、回环HTTP与healthcheck;测试容器已清理。
- 浏览器:无效令牌拒绝、正常令牌读取未配置模型、模型禁用、LLM/TTS未启用。没有采集真实麦克风,也没有发送真实供应商请求。
- 未执行:真实阿里云查询/创建/绑定、SIP通话、真实ASR、LLM/TTS、MQ/OSS、完整外呼和竞价回收恢复。
- 已执行:阿里云只读盘点、一次V13专用竞价Cell创建/绑定、Mock规模运行、一次R1真实SIP/Asterisk探针、测试实例停止/删除和固定EIP保留验证。
- 未执行或未通过:真实外呼接通/RTP、真实ASR、LLM/TTS、MQ/OSS、完整外呼以及生产容量/N+1验收;R1证据见`docs/evidence/asterisk-real-2026-09-12.md`。
本地模拟通过不等于生产可用。只有填齐资源和契约、执行既有验收文档相应场景并留存证据后,才可把阶段状态更新为真实环境验收通过。
## 7. 下一步需要用户提供/确认
## 7. agent-call Mock 验证栈
1. 在本机通过安全方式配置阿里云CLI与Profile/RAM Role/STS,并确认123.56.71.98的实际归属及是否可迁移EIP。
2. 北京实例规格、镜像、VSwitch、安全组、SSH KeyPair、竞价上限及磁盘/EIP/流量预算;现有主机是否允许复用。
3. SIP主备真实地址、协议、鉴权、号码/主叫要求和接入限制。
4. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。
5. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。
### 7.1 本地启动
以上未确认前,不创建计费资源、不改绑白名单IP、不自动拨真实号码,也不声称完成整个平台。
```bash
# 仅使用本地SQLite和严格的进程内Broker,适合单元/验收探针。
python3 -m agent_call
# RabbitMQ模式需要先注入受控环境变量;示例只含合成值。
set -a; . deploy/mock.env.example; set +a
# 按实际密码/URL替换示例值后启动:
docker compose up -d --build rabbitmq agent-call
curl --fail http://127.0.0.1:18080/healthz/ready
```
业务执行命令只能经RabbitMQ租户队列进入;HTTP只提供控制、查询、重放和录音交接路径。测试被叫保留原值`18601013734`,Mock线路生成目标`708918601013734`,主叫显示保留`BD93205882`。
### 7.2 可重复检查
```bash
python3 -m unittest discover -s tests -v
python3 -m scripts.acceptance
python3 deploy/check_mock_config.py
python3 deploy/migrate.py data/agent-call.sqlite3
```
`agent_call/core.py`使用真实SQLite表保存任务、执行、通话、尝试、事件、outbox/inbox、上传和重放状态;`deploy/backup_sqlite.py backup|restore`提供离线备份恢复。`ProtocolJournal`只记录合成的SIP/ARI/RTP/AI协议事实,不把它们伪装成真实供应商响应。
### 7.3 当前门禁
本地V01-V12及阿里云专用主机上的V13 Mock探针已通过;完整验收脚本仍因V14需要外部真实证据而按门禁返回非零,证据见`docs/evidence/acceptance-mock-2026-09-12.json`和`docs/evidence/scale-mock-2026-09-12.json`。V13使用100租户/1200模拟接通、禁用1个Cell,最低1004路已回答并持续观察3600秒;这是Mock协议/持久状态负载证据,不是RTP或生产容量证据。R1真实SIP证据见`docs/evidence/asterisk-real-2026-09-12.md`:固定EIP和SIP OPTIONS/INVITE链路可达,但目标呼叫收到488/Q.850 cause=88。测试ECS及本次专用安全组/KeyPair已回收,固定EIP`123.56.71.98`保持Available。真实接通/RTP、ASR/LLM/TTS/OSS、生产1000路/N+1尚未完成。正式模式拒绝当前Mock profile,不能把测试号码或Mock成功自动升级为真实外呼授权。
## 8. 下一步需要用户提供/确认
1. 供应商需确认`7089`前缀/目标号码路由、From域(当前实际出现私网域)及允许的编解码;R1证据中的`488/Q.850 cause=88 INCOMPATIBLE_DESTINATION`未通过外呼验收。
2. 确认后再按单次授权重新创建测试Cell并只发起一通探针;不沿用旧测试实例、不自动重试、不改绑或释放固定EIP。
3. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。
4. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。
5. 真实接通后仍需分别完成RTP、录音、MQ/OSS、ASR/LLM/TTS和生产1000路/N+1验收。
在供应商规则确认前,不再次拨打真实号码,也不声称完成整个平台。
+2
View File
@@ -0,0 +1,2 @@
jsonschema>=4.20,<5
pika>=1.3,<2
+1
View File
@@ -0,0 +1 @@
"""Operational scripts for agent-call."""
+492
View File
@@ -0,0 +1,492 @@
"""Repeatable Mock acceptance probes for V01-V13.
The script deliberately reports R1/R2 as blocked until real external evidence
exists. Passing Mock probes never upgrades the result to production readiness.
"""
from __future__ import annotations
import copy
import json
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from agent_call.core import (
AgentCallService,
ConflictError,
InMemoryBroker,
ServiceError,
iso,
utcnow,
)
from deploy.backup_sqlite import copy_database
from deploy.check_mock_config import check as check_profile
ROOT = Path(__file__).resolve().parents[1]
def load_fixture() -> dict[str, Any]:
try:
return json.loads(
(ROOT / "docs/contracts/examples/call.execute.json").read_text(
encoding="utf-8"
)
)
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError("contract fixture is unavailable") from exc
def service(directory: str, broker: InMemoryBroker | None = None) -> AgentCallService:
return AgentCallService(
db_path=Path(directory) / "executor.sqlite3",
object_dir=Path(directory) / "objects",
broker=broker or InMemoryBroker(),
)
def command(
command_id: str, tenant_id: str = "tenant-demo", task_id: str = "task-demo"
) -> dict[str, Any]:
body = copy.deepcopy(load_fixture())
keys = {
"tenant-demo": "tenant-demo-key",
"tenant-b": "tenant.b",
"tenant-c": "tenant#c",
}
body["command_id"] = command_id
body["trace_id"] = f"trace_{command_id}"
body["tenant_id"] = tenant_id
body["tenant_key"] = keys[tenant_id]
body["issued_at"] = iso(utcnow())
body["payload"]["execution_id"] = f"exec_{command_id}"
body["payload"]["task_id"] = task_id
return body
def run_success(svc: AgentCallService, body: dict[str, Any]) -> dict[str, Any]:
svc.publish_execute(body)
svc.wait_for_idle()
return svc.get_command(body["tenant_id"], body["command_id"])
def assert_raises(code: str, action: Callable[[], Any]) -> None:
try:
action()
except ServiceError as exc:
if exc.code != code:
raise AssertionError(f"expected {code}, got {exc.code}") from exc
return
raise AssertionError(f"expected {code}")
def probe_v01() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
problems = check_profile(ROOT / "docs/contracts/mock-profile.json")
if problems:
raise AssertionError("; ".join(problems))
invalid = command("cmd_bad_version")
invalid["schema_version"] = "2.0"
assert_raises("SCHEMA_INVALID", lambda: svc.publish_execute(invalid))
oversized = command("cmd_oversized")
oversized["payload"]["variables"] = {"blob": "x" * 300000}
assert_raises("MESSAGE_TOO_LARGE", lambda: svc.publish_execute(oversized))
mismatch = command("cmd_mismatch")
mismatch["tenant_key"] = "tenant.b"
assert_raises("TENANT_BINDING_INVALID", lambda: svc.publish_execute(mismatch))
return {
"profile": "mock",
"schema_negative_cases": 3,
"http_execute_route": "absent",
}
def probe_v02() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
first = command("cmd_idempotent", "tenant-demo")
svc.publish_execute(first)
svc.publish_execute(first)
svc.wait_for_idle()
duplicate = command("cmd_second", "tenant-demo")
duplicate["payload"]["execution_id"] = first["payload"]["execution_id"]
duplicate_snapshot = run_success(svc, duplicate)
if duplicate_snapshot["reason_code"] != "DUPLICATE_EXECUTION":
raise AssertionError(
"execution idempotency did not associate the original call"
)
calls = svc.store.one("SELECT COUNT(*) AS n FROM calls")["n"]
if calls != 1:
raise AssertionError("duplicate execution dialed a second call")
with_body_change = copy.deepcopy(first)
with_body_change["payload"]["callee"] = "18601013735"
assert_raises(
"IDEMPOTENCY_CONFLICT", lambda: svc.publish_execute(with_body_change)
)
return {
"calls": calls,
"same_command_replays": 1,
"different_execution_command": "associated",
}
def probe_v03() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
broker = InMemoryBroker()
first = service(directory, broker)
second = AgentCallService(
db_path=Path(directory) / "executor.sqlite3",
object_dir=Path(directory) / "objects-2",
broker=broker,
)
try:
for tenant_id, task_id in (
("tenant-demo", "task-demo"),
("tenant-b", "task-b"),
("tenant-c", "task-c"),
):
first.publish_execute(command(f"cmd_{tenant_id}", tenant_id, task_id))
first.process_once()
second.process_once()
first.wait_for_idle()
tenants = [
row["tenant_id"]
for row in first.store.all(
"SELECT tenant_id FROM calls ORDER BY created_at"
)
]
if (
set(tenants) != {"tenant-demo", "tenant-b", "tenant-c"}
or len(tenants) != 3
):
raise AssertionError(f"fair tenant admission mismatch: {tenants}")
return {
"tenants_admitted": tenants,
"scheduler_instances": 2,
"lease_duplicate_calls": 0,
}
finally:
second.stop()
first.stop()
def probe_v04() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
svc.profile["limits"]["global_concurrency"] = 0
body = command("cmd_waiting")
svc.publish_execute(body)
svc.ingest_commands()
svc.schedule_once()
if svc.get_command("tenant-demo", "cmd_waiting")["status"] != "waiting":
raise AssertionError("resource-exhausted command did not enter waiting")
svc.control_task(
"tenant-demo",
"task-demo",
{
"command_id": "ctrl_pause",
"action": "pause",
"expected_task_revision": 1,
"reason": "drain",
},
"ctrl_pause",
)
svc.apply_controls()
state = svc.get_command("tenant-demo", "cmd_waiting")
if (
state["status"] != "rejected"
or state["reason_code"] != "TASK_CONTROL_BARRIER"
):
raise AssertionError("pause barrier did not reject already waiting work")
with ConflictContext():
svc.control_task(
"tenant-demo",
"task-demo",
{
"command_id": "ctrl_stale",
"action": "resume",
"expected_task_revision": 1,
"reason": "stale",
},
"ctrl_stale",
)
return {
"waiting_barrier": "applied",
"affected_commands": 1,
"stale_cas": "rejected",
}
class ConflictContext:
def __enter__(self) -> ConflictContext:
return self
def __exit__(
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
) -> bool:
if exc_type is not ConflictError:
raise AssertionError("expected revision conflict")
return True
def probe_v05() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
broker = InMemoryBroker()
svc = service(directory, broker)
body = command("cmd_broker_retry")
broker.outage = True
assert_raises("BROKER_UNAVAILABLE", lambda: svc.publish_execute(body))
broker.outage = False
svc.publish_execute(body)
snapshot = run_success(svc, body)
if snapshot["status"] != "completed":
raise AssertionError("retained command did not recover")
return {"retained_id": body["command_id"], "recovered": True, "dead_letters": 0}
def probe_v06_v07() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
scenarios = {
"busy": "busy",
"no_answer": "no_answer",
"disconnect": "ari_disconnect",
}
outcomes: dict[str, str] = {}
for label, scenario in scenarios.items():
body = command(f"cmd_{label}")
svc.set_failure(body["command_id"], scenario)
snapshot = run_success(svc, body)
call = svc.get_call("tenant-demo", snapshot["call_id"])
outcomes[label] = call["outcome"]
if len(call["attempts"]) != 1:
raise AssertionError(
"uncertain or failed call was automatically redialed"
)
journal = svc.journal.read()
if not any(item["protocol"] == "sip" for item in journal):
raise AssertionError("SIP protocol journal is empty")
if any(
item.get("target_uri") == "18601013734"
for item in journal
if item["protocol"] == "sip" and item["event"] == "invite"
):
raise AssertionError("route prefix was lost")
return {
"mock_protocol": "pass",
"asterisk_real": "not_run",
"outcomes": outcomes,
"fallback_redials": 0,
}
def probe_v08() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
svc.profile["limits"]["turns"] = 100
body = command("cmd_100_turns")
snapshot = run_success(svc, body)
call = svc.get_call("tenant-demo", snapshot["call_id"])
segments = call["transcript"]["segments"]
if len(segments) < 200:
raise AssertionError(
f"only {len(segments)} transcript segments were persisted"
)
if any(
segment["playback_state"] != "playback_confirmed"
for segment in segments
if segment["speaker"] == "agent"
):
raise AssertionError("normal agent turns lack playback evidence")
return {"turns": 100, "segments": len(segments), "old_tts_reused": False}
def probe_v09() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
missing = command("cmd_missing_object")
svc.set_failure(missing["command_id"], "upload_missing")
missing_snapshot = run_success(svc, missing)
missing_recording = svc.get_call("tenant-demo", missing_snapshot["call_id"])[
"recordings"
][0]
bad = command("cmd_bad_object")
svc.set_failure(bad["command_id"], "upload_bad_checksum")
bad_snapshot = run_success(svc, bad)
bad_recording = svc.get_call("tenant-demo", bad_snapshot["call_id"])[
"recordings"
][0]
if (
missing_recording["status"] != "failed"
or bad_recording["status"] != "failed"
):
raise AssertionError("invalid object was not failed")
ready = svc.store.one(
"SELECT COUNT(*) AS n FROM events WHERE event_type='recording.ready'"
)["n"]
if ready != 0:
raise AssertionError("invalid object emitted recording.ready")
return {
"missing_object": "failed",
"bad_checksum": "failed",
"invalid_ready": 0,
}
def probe_v10() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
broker = InMemoryBroker()
svc = service(directory, broker)
body = command("cmd_outbox_resume")
svc.publish_execute(body)
svc.ingest_commands()
svc.schedule_once()
for thread in list(svc._call_threads.values()):
thread.join(timeout=2)
broker.outage = True
pending_before = svc.store.one(
"SELECT COUNT(*) AS n FROM events WHERE published=0"
)["n"]
svc.dispatch_outbox()
broker.outage = False
with svc.store.tx() as con:
con.execute("UPDATE events SET next_attempt_at=NULL")
svc.dispatch_outbox()
svc.consume_saas_events()
pending_after = svc.store.one(
"SELECT COUNT(*) AS n FROM events WHERE published=0"
)["n"]
if pending_before == 0 or pending_after != 0:
raise AssertionError("outbox did not preserve and recover events")
return {
"pending_during_outage": pending_before,
"pending_after_recovery": pending_after,
"original_event_ids": True,
}
def probe_v11_v12() -> dict[str, Any]:
with tempfile.TemporaryDirectory() as directory:
svc = service(directory)
body = command("cmd_backup")
run_success(svc, body)
backup = Path(directory) / "backup.sqlite3"
copy_database(str(Path(directory) / "executor.sqlite3"), str(backup))
if not backup.is_file():
raise AssertionError("backup file was not created")
metrics = svc.metrics()
if "agent_call_outbox_pending 0" not in metrics or 'mode="mock"' not in metrics:
raise AssertionError("monitoring metrics lack healthy mock state")
try:
AgentCallService(
profile_path=ROOT / "docs/contracts/mock-profile.json", mode="real"
)
except ServiceError as exc:
if exc.code != "CONFIGURATION_INVALID":
raise AssertionError("real mode rejected for the wrong reason") from exc
else:
raise AssertionError("real mode silently accepted mock providers")
return {
"backup": "verified",
"metrics": "healthy",
"rollback_preserves_db": True,
"real_mode_mock_rejection": True,
}
def probe_v13() -> dict[str, Any]:
candidates = sorted(
path
for path in (ROOT / "docs/evidence").glob("scale-mock-*.json")
if "preflight" not in path.name
)
if not candidates:
return {
"status": "not_run",
"simulated_calls_declared": 1200,
"required_hold_minutes": 60,
"reason": "requires dedicated SCALE-MOCK host and sustained media load",
}
path = candidates[-1]
try:
evidence = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise AssertionError(f"invalid SCALE-MOCK evidence: {path.name}") from exc
load = evidence.get("load", {})
if (
evidence.get("status") != "observed"
or evidence.get("production_claim", True)
or load.get("minimum_answered_calls", 0) < 1000
or load.get("observed_hold_seconds", 0) < 60 * 60
):
raise AssertionError(f"SCALE-MOCK evidence does not meet V13: {path.name}")
return {
"status": "observed",
"evidence_file": str(path.relative_to(ROOT)),
"tenants": load.get("tenants"),
"submitted_calls": load.get("submitted_calls"),
"minimum_answered_calls": load.get("minimum_answered_calls"),
"observed_hold_seconds": load.get("observed_hold_seconds"),
"production_claim": False,
}
def run() -> dict[str, Any]:
probes: list[tuple[str, Callable[[], dict[str, Any]]]] = [
("V01", probe_v01),
("V02", probe_v02),
("V03", probe_v03),
("V04", probe_v04),
("V05", probe_v05),
("V06/V07", probe_v06_v07),
("V08", probe_v08),
("V09", probe_v09),
("V10", probe_v10),
("V11/V12", probe_v11_v12),
("V13", probe_v13),
]
results: dict[str, Any] = {
"started_at": iso(),
"profile": "mock",
"probes": {},
"production": {"V14": "blocked_external_evidence"},
}
for name, probe in probes:
started = time.monotonic()
try:
evidence = probe()
status = (
"PASS" if evidence.get("status", "PASS") != "not_run" else "NOT_RUN"
)
results["probes"][name] = {
"status": status,
"duration_ms": round((time.monotonic() - started) * 1000, 3),
"evidence": evidence,
}
except Exception as exc:
results["probes"][name] = {
"status": "FAIL",
"duration_ms": round((time.monotonic() - started) * 1000, 3),
"error": f"{type(exc).__name__}: {exc}",
}
results["finished_at"] = iso()
return results
def main() -> int:
result = run()
incomplete = [
name for name, value in result["probes"].items() if value["status"] != "PASS"
]
if result["production"].get("V14") != "PASS":
incomplete.append("V14")
result["gate_status"] = "PASS" if not incomplete else "INCOMPLETE"
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 1 if incomplete else 0
if __name__ == "__main__":
raise SystemExit(main())
+256
View File
@@ -0,0 +1,256 @@
"""SCALE-MOCK runner for the 100-tenant/1200-call engineering baseline.
The runner is opt-in because it holds 1200 mock calls for the requested wall
clock duration. It reports protocol-journal limitations instead of claiming
production media capacity.
"""
from __future__ import annotations
import argparse
import copy
import json
import time
from pathlib import Path
from typing import Any
from agent_call.core import AgentCallService, InMemoryBroker, iso, utcnow
from scripts.acceptance import load_fixture
ROOT = Path(__file__).resolve().parents[1]
def build_profile(path: Path, duration_minutes: int) -> dict[str, Any]:
try:
profile = json.loads(
(ROOT / "docs/contracts/mock-profile.json").read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError("mock profile unavailable") from exc
keys = [f"tenant-{index:03d}" for index in range(100)]
profile["tenants"] = [
{"tenant_id": key, "tenant_key": f"{key}.key", "enabled": True} for key in keys
]
profile["tasks"] = [
{
"task_id": f"task-{index:03d}",
"tenant_id": key,
"state": "running",
"revision": 1,
}
for index, key in enumerate(keys)
]
profile["cells"] = [
{
"cell_id": f"cell-{index:03d}",
"capacity": 12,
"egress_pool_id": "egress-mock",
"ari_mode": "mock",
}
for index in range(101)
]
startup_window_seconds = max(120, duration_minutes * 60)
profile["limits"].update(
{
"global_concurrency": 1200,
"global_cps": 1200,
"tenant_concurrency": 12,
"tenant_cps": 12,
"max_queue_messages": 2000,
# Keep calls alive through warm-up and the required observation window.
"hold_ms": (startup_window_seconds + duration_minutes * 60 + 5) * 1000,
}
)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
except (OSError, TypeError, ValueError) as exc:
raise RuntimeError("scale profile could not be written") from exc
return profile
def command_for(
template: dict[str, Any], index: int, tenant_index: int
) -> dict[str, Any]:
body = copy.deepcopy(template)
tenant_id = f"tenant-{tenant_index:03d}"
body["command_id"] = f"scale-cmd-{index:04d}"
body["trace_id"] = f"scale-trace-{index:04d}"
body["tenant_id"] = tenant_id
body["tenant_key"] = f"{tenant_id}.key"
body["issued_at"] = iso(utcnow())
body["payload"]["execution_id"] = f"scale-exec-{index:04d}"
body["payload"]["task_id"] = f"task-{tenant_index:03d}"
body["payload"]["task_item_id"] = f"scale-item-{index:04d}"
return body
def dry_run(duration_minutes: int) -> dict[str, Any]:
return {
"status": "not_run",
"tenants": 100,
"calls": 1200,
"calls_per_tenant": 12,
"required_hold_minutes": duration_minutes,
"cells": 101,
"safe_capacity_after_one_cell_loss": 1200,
"reason": "pass --run only on a dedicated SCALE-MOCK host",
}
def cleanup_load(svc: AgentCallService) -> dict[str, Any]:
requested = 0
for tenant_index in range(100):
tenant_id = f"tenant-{tenant_index:03d}"
command_id = f"scale-stop-{tenant_index:03d}"
svc.control_task(
tenant_id,
f"task-{tenant_index:03d}",
{
"command_id": command_id,
"action": "stop",
"expected_task_revision": 1,
"reason": "SCALE-MOCK teardown",
"active_call_policy": "hangup",
},
command_id,
allow_hangup=True,
)
requested += 1
svc.apply_controls()
active = nonterminal = pending = 0
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
svc.process_once()
active = svc.store.one(
"SELECT COUNT(*) AS n FROM calls WHERE call_state NOT IN ('ended','failed')"
)["n"]
nonterminal = svc.store.one(
"SELECT COUNT(*) AS n FROM commands WHERE status IN ('accepted','waiting','executing','applying')"
)["n"]
pending = svc.store.one("SELECT COUNT(*) AS n FROM events WHERE published=0")[
"n"
]
if active == 0 and nonterminal == 0 and pending == 0:
applied = svc.store.one(
"SELECT COUNT(*) AS n FROM commands WHERE command_id LIKE 'scale-stop-%' AND status='applied'"
)["n"]
return {
"stop_commands_requested": requested,
"stop_commands_applied": applied,
"active_calls_after_cleanup": active,
"nonterminal_commands_after_cleanup": nonterminal,
"outbox_pending_after_cleanup": pending,
"clean": True,
}
time.sleep(0.1)
applied = svc.store.one(
"SELECT COUNT(*) AS n FROM commands WHERE command_id LIKE 'scale-stop-%' AND status='applied'"
)["n"]
return {
"stop_commands_requested": requested,
"stop_commands_applied": applied,
"active_calls_after_cleanup": active,
"nonterminal_commands_after_cleanup": nonterminal,
"outbox_pending_after_cleanup": pending,
"clean": False,
}
def run(directory: Path, duration_minutes: int) -> dict[str, Any]:
profile_path = directory / "profile.json"
build_profile(profile_path, duration_minutes)
broker = InMemoryBroker(max_messages=2000)
svc = AgentCallService(
db_path=directory / "scale.sqlite3",
profile_path=profile_path,
object_dir=directory / "objects",
broker=broker,
start_background=True,
)
svc.set_cell_health("cell-000", False)
template = load_fixture()
started = time.monotonic()
try:
for tenant_index in range(100):
for offset in range(12):
svc.publish_execute(
command_for(template, tenant_index * 12 + offset, tenant_index)
)
warmup_deadline = started + max(120, duration_minutes * 60)
active = 0
threshold_at = None
while time.monotonic() < warmup_deadline:
active = svc.store.one(
"SELECT COUNT(*) AS n FROM calls WHERE call_state='answered'"
)["n"]
if active >= 1000:
threshold_at = time.monotonic()
break
time.sleep(0.25)
minimum_active = active
observed_hold_seconds = 0.0
if threshold_at is not None:
hold_until = threshold_at + duration_minutes * 60
while active >= 1000 and time.monotonic() < hold_until:
active = svc.store.one(
"SELECT COUNT(*) AS n FROM calls WHERE call_state='answered'"
)["n"]
minimum_active = min(minimum_active, active)
time.sleep(1)
observed_hold_seconds = max(
0.0, min(time.monotonic(), hold_until) - threshold_at
)
result = {
"status": "observed"
if minimum_active >= 1000 and observed_hold_seconds >= duration_minutes * 60
else "fail",
"tenants": 100,
"submitted_calls": 1200,
"answered_calls_at_end": active,
"minimum_answered_calls": minimum_active,
"one_cell_disabled": True,
"required_hold_minutes": duration_minutes,
"threshold_reached_seconds": None
if threshold_at is None
else round(threshold_at - started, 3),
"observed_hold_seconds": round(observed_hold_seconds, 3),
"continuous_media_evidence": False,
"reason": "ProtocolJournal records mock lifecycle facts; it is not a real RTP capacity proof",
}
cleanup = cleanup_load(svc)
result["cleanup"] = cleanup
if not cleanup["clean"]:
result["status"] = "fail"
return result
finally:
svc.stop()
def main() -> int:
parser = argparse.ArgumentParser(
description="run or declare the SCALE-MOCK baseline"
)
parser.add_argument(
"--run",
action="store_true",
help="hold the mock load instead of printing a preflight",
)
parser.add_argument("--duration-minutes", type=int, default=60)
parser.add_argument("--directory", type=Path, default=Path("data/scale-mock"))
args = parser.parse_args()
if args.duration_minutes < 1:
raise SystemExit("--duration-minutes must be positive")
result = (
run(args.directory, args.duration_minutes)
if args.run
else dry_run(args.duration_minutes)
)
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if result["status"] != "fail" else 1
if __name__ == "__main__":
raise SystemExit(main())
+311
View File
@@ -0,0 +1,311 @@
from __future__ import annotations
import copy
import json
import tempfile
import unittest
from pathlib import Path
from agent_call.core import (
AgentCallService,
BrokerError,
ConflictError,
InMemoryBroker,
ServiceError,
ValidationError,
iso,
routing_key,
utcnow,
)
ROOT = Path(__file__).resolve().parents[1]
def fixture() -> dict:
try:
return json.loads(
(ROOT / "docs/contracts/examples/call.execute.json").read_text(
encoding="utf-8"
)
)
except (OSError, json.JSONDecodeError) as exc:
raise AssertionError("valid command fixture cannot be loaded") from exc
class AgentCallTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.broker = InMemoryBroker()
self.service = AgentCallService(
db_path=Path(self.temp.name) / "executor.sqlite3",
object_dir=Path(self.temp.name) / "objects",
broker=self.broker,
)
self.service.profile["limits"]["tenant_cps"] = 100
self.service.profile["limits"]["global_cps"] = 100
def tearDown(self) -> None:
self.service.stop()
self.temp.cleanup()
def command(self, command_id: str, execution_id: str | None = None) -> dict:
body = copy.deepcopy(fixture())
body["command_id"] = command_id
body["trace_id"] = f"trace_{command_id}"
body["payload"]["execution_id"] = execution_id or f"exec_{command_id}"
body["issued_at"] = iso(utcnow())
return body
def run_command(self, body: dict) -> dict:
self.service.publish_execute(body)
self.service.wait_for_idle()
return self.service.get_command(body["tenant_id"], body["command_id"])
def test_success_is_durable_and_has_verified_recording(self) -> None:
command = self.command("cmd_success")
snapshot = self.run_command(command)
self.assertEqual(snapshot["status"], "completed")
call = self.service.get_call("tenant-demo", snapshot["call_id"])
self.assertEqual(call["call_state"], "ended")
self.assertEqual(call["outcome"], "completed")
self.assertGreaterEqual(len(call["transcript"]["segments"]), 4)
self.assertEqual(call["recordings"][0]["status"], "verified")
self.assertTrue(call["recordings"][0]["oss_id"].startswith("oss://mock/"))
journal = self.service.journal.read()
invite = next(
item
for item in journal
if item["protocol"] == "sip" and item["event"] == "invite"
)
self.assertEqual(invite["callee"], "18601013734")
self.assertEqual(invite["target_uri"], "708918601013734")
self.assertEqual(invite["caller"], "BD93205882")
self.assertTrue(
any(
item["event"] == "cleanup"
for item in journal
if item["protocol"] == "ari"
)
)
event_count = self.service.store.one("SELECT COUNT(*) AS n FROM events")["n"]
inbox_count = self.service.store.one("SELECT COUNT(*) AS n FROM inbox")["n"]
self.assertEqual(event_count, inbox_count)
self.assertIn("agent_call_outbox_pending 0", self.service.metrics())
def test_execution_id_idempotency_never_dials_twice(self) -> None:
first = self.command("cmd_first", "exec_once")
first_snapshot = self.run_command(first)
duplicate = self.command("cmd_duplicate", "exec_once")
duplicate_snapshot = self.run_command(duplicate)
self.assertEqual(duplicate_snapshot["status"], "completed")
self.assertEqual(duplicate_snapshot["reason_code"], "DUPLICATE_EXECUTION")
self.assertEqual(duplicate_snapshot["call_id"], first_snapshot["call_id"])
calls = self.service.store.one("SELECT COUNT(*) AS n FROM calls")["n"]
attempts = self.service.store.one("SELECT COUNT(*) AS n FROM attempts")["n"]
self.assertEqual(calls, 1)
self.assertEqual(attempts, 1)
def test_controls_use_cas_and_old_commands_do_not_resume(self) -> None:
pause = self.service.control_task(
"tenant-demo",
"task-demo",
{
"command_id": "ctrl_pause",
"action": "pause",
"expected_task_revision": 1,
"reason": "maintenance",
},
"ctrl_pause",
)
self.assertEqual(pause["status"], "accepted")
self.service.process_once()
self.assertEqual(
self.service.get_command("tenant-demo", "ctrl_pause")["status"], "applied"
)
rejected = self.command("cmd_paused")
rejected["payload"]["task_revision"] = 2
with self.assertRaises(ConflictError) as blocked:
self.service.publish_execute(rejected)
self.assertEqual(blocked.exception.code, "TASK_PUBLISH_BLOCKED")
with self.assertRaises(ConflictError):
self.service.control_task(
"tenant-demo",
"task-demo",
{
"command_id": "ctrl_stale",
"action": "resume",
"expected_task_revision": 1,
"reason": "stale",
},
"ctrl_stale",
)
self.service.control_task(
"tenant-demo",
"task-demo",
{
"command_id": "ctrl_resume",
"action": "resume",
"expected_task_revision": 2,
"reason": "ready",
},
"ctrl_resume",
)
self.service.process_once()
resumed = self.command("cmd_resumed")
resumed["payload"]["task_revision"] = 3
self.run_command(resumed)
self.assertEqual(
self.service.get_command("tenant-demo", "cmd_resumed")["status"],
"completed",
)
def test_stop_hangup_requires_scope_and_ends_active_call(self) -> None:
self.service.profile["limits"]["hold_ms"] = 200
body = self.command("cmd_hangup")
self.service.publish_execute(body)
self.service.ingest_commands()
self.service.schedule_once()
denied = {
"command_id": "ctrl_denied",
"action": "stop",
"expected_task_revision": 1,
"active_call_policy": "hangup",
"reason": "test",
}
with self.assertRaises(ServiceError) as caught:
self.service.control_task(
"tenant-demo", "task-demo", denied, "ctrl_denied", allow_hangup=False
)
self.assertEqual(caught.exception.status, 403)
allowed = {
"command_id": "ctrl_stop",
"action": "stop",
"expected_task_revision": 1,
"active_call_policy": "hangup",
"reason": "test",
}
self.service.control_task(
"tenant-demo", "task-demo", allowed, "ctrl_stop", allow_hangup=True
)
self.service.apply_controls()
self.service.wait_for_idle()
self.assertEqual(
self.service.get_command("tenant-demo", "ctrl_stop")["status"], "applied"
)
call_id = self.service.get_command("tenant-demo", "cmd_hangup")["call_id"]
self.assertEqual(
self.service.get_call("tenant-demo", call_id)["reason_code"],
"CONTROL_STOPPED",
)
def test_broker_outage_retains_publication_for_bounded_retry(self) -> None:
command = self.command("cmd_outage")
self.broker.outage = True
with self.assertRaises(BrokerError):
self.service.publish_execute(command)
self.assertEqual(
self.service.store.one(
"SELECT status FROM published_commands WHERE command_id=?",
("cmd_outage",),
)["status"],
"retained",
)
self.broker.outage = False
self.assertEqual(
self.service.publish_execute(command)["status"], "broker_confirmed"
)
self.assertEqual(self.run_command(command)["status"], "completed")
def test_replay_uses_cutoff_and_original_event_ids(self) -> None:
command = self.command("cmd_replay_source")
snapshot = self.run_command(command)
original = self.service.store.all(
"SELECT event_id FROM events WHERE tenant_id=?", ("tenant-demo",)
)
result = self.service.create_replay(
"tenant-demo",
"call",
snapshot["call_id"],
{"command_id": "replay_call", "reason": "audit"},
"replay_call",
)
self.assertEqual(result["status"], "accepted")
self.service.process_replays()
replay = self.service.store.one(
"SELECT * FROM replays WHERE command_id=?", ("replay_call",)
)
self.assertEqual(replay["status"], "completed")
replay_ids = {
row["event_id"]
for row in self.service.store.all(
"SELECT event_id FROM replay_items WHERE replay_id=?",
(replay["replay_id"],),
)
}
self.assertTrue(replay_ids)
self.assertTrue(replay_ids.issubset({row["event_id"] for row in original}))
self.assertEqual(
self.service.get_command("tenant-demo", "replay_call")["status"],
"completed",
)
def test_bad_recording_checksum_emits_failure_without_ready(self) -> None:
command = self.command("cmd_bad_recording")
self.service.set_failure(command["command_id"], "upload_bad_checksum")
snapshot = self.run_command(command)
call = self.service.get_call("tenant-demo", snapshot["call_id"])
self.assertEqual(call["recordings"][0]["status"], "failed")
self.assertEqual(
call["recordings"][0]["failure_code"], "OBJECT_VERIFICATION_FAILED"
)
ready = self.service.store.one(
"SELECT COUNT(*) AS n FROM events WHERE event_type='recording.ready'"
)["n"]
failed = self.service.store.one(
"SELECT COUNT(*) AS n FROM events WHERE event_type='recording.failed'"
)["n"]
self.assertEqual(ready, 0)
self.assertEqual(failed, 1)
def test_tenant_routing_is_exact_and_byte_bounded(self) -> None:
self.assertEqual(
routing_key("tenant#c"), "agent-call.tenant.tenant#c.call.execute"
)
self.assertEqual(len(routing_key("a" * 224).encode("utf-8")), 255)
too_long = "a" * 225
with self.assertRaises(ValidationError) as caught:
routing_key(too_long)
self.assertEqual(caught.exception.code, "TENANT_KEY_UNSUPPORTED")
self.assertEqual(
self.broker._routes[routing_key("tenant#c")],
"agent-call.executor.tenant#c.v1",
)
bounded = InMemoryBroker(max_messages=1)
bounded.declare_tenant("tenant#c")
bounded.publish(
"agent-call.commands.v1", routing_key("tenant#c"), {"message": 1}
)
with self.assertRaises(BrokerError):
bounded.publish(
"agent-call.commands.v1", routing_key("tenant#c"), {"message": 2}
)
def test_profile_never_claims_real_ai(self) -> None:
metrics = self.service.metrics()
self.assertIn(
'agent_call_provider_mode_info{component="llm",mode="mock"} 1', metrics
)
self.assertIn(
'agent_call_provider_mode_info{component="tts",mode="mock"} 1', metrics
)
self.assertNotIn('mode="real"', metrics)
def test_scale_profile_covers_warmup_and_observation(self) -> None:
from scripts.scale_mock import build_profile
profile = build_profile(Path(self.temp.name) / "scale-profile.json", 1)
self.assertEqual(profile["limits"]["hold_ms"], (120 + 60 + 5) * 1000)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
from jsonschema import Draft202012Validator
ROOT = Path(__file__).resolve().parents[1]
def read_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise AssertionError(f"invalid JSON fixture: {path}") from exc
class ContractTests(unittest.TestCase):
def test_openapi_and_mq_contract_inventory(self) -> None:
executor = (ROOT / "docs/contracts/executor.openapi.yaml").read_text(
encoding="utf-8"
)
saas = (ROOT / "docs/contracts/saas.openapi.yaml").read_text(encoding="utf-8")
self.assertIn("openapi: 3.1.0", executor)
self.assertIn("openapi: 3.1.0", saas)
self.assertNotIn("call.execute", executor)
self.assertIn("/internal/v1/outbound/tasks/{task_id}/controls:", executor)
self.assertIn(
"/internal/v1/outbound/commands/{source_command_id}/replays:", executor
)
self.assertIn("/internal/v1/outbound/recording-uploads:", saas)
self.assertIn(
"/internal/v1/outbound/recording-uploads/{upload_id}/complete:", saas
)
def test_command_fixture_and_invalid_version(self) -> None:
schema = read_json(ROOT / "docs/contracts/mq.schema.json")
fixture = read_json(ROOT / "docs/contracts/examples/call.execute.json")
validator = Draft202012Validator(schema)
self.assertEqual(list(validator.iter_errors(fixture)), [])
invalid = dict(fixture)
invalid["schema_version"] = "2.0"
self.assertTrue(list(validator.iter_errors(invalid)))
def test_raw_unicode_tenant_key_and_user_selected_callee_are_preserved(
self,
) -> None:
fixture = read_json(ROOT / "docs/contracts/examples/call.execute.json")
self.assertEqual(fixture["payload"]["callee"], "18601013734")
fixture["tenant_key"] = "租户#原值"
self.assertEqual(fixture["tenant_key"], "租户#原值")
self.assertEqual(
json.loads(json.dumps(fixture, ensure_ascii=False))["tenant_key"],
"租户#原值",
)
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import sqlite3
import tempfile
import unittest
from pathlib import Path
from agent_call.core import Store
from deploy.backup_sqlite import copy_database
from deploy.check_mock_config import check
from deploy.migrate import migrate
class AgentCallDeploymentTests(unittest.TestCase):
def test_mock_profile_and_sqlite_backup_restore(self) -> None:
self.assertEqual(check("docs/contracts/mock-profile.json"), [])
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "source.sqlite3"
backup = Path(directory) / "backup.sqlite3"
store = Store(source)
with store.tx() as con:
con.execute(
"INSERT INTO tenants(tenant_id, tenant_key, created_at) VALUES(?,?,?)",
("t", "k", "now"),
)
copy_database(str(source), str(backup))
connection = sqlite3.connect(backup)
try:
row = connection.execute(
"SELECT tenant_key FROM tenants WHERE tenant_id='t'"
).fetchone()
finally:
connection.close()
self.assertEqual(row[0], "k")
self.assertEqual(migrate(str(source)), 0)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -1,3 +1,4 @@
import base64
import copy
import json
import tempfile
@@ -134,8 +135,20 @@ class CloudTests(unittest.TestCase):
self.assertEqual(params["InternetMaxBandwidthOut"], 0)
self.assertEqual(params["SpotStrategy"], "SpotWithPriceLimit")
self.assertEqual(params["SpotPriceLimit"], 0.1)
self.assertEqual(params["SystemDisk.PerformanceLevel"], "PL1")
self.assertEqual(api.mutations(), ["RunInstances", "AssociateEipAddress"])
def test_user_data_is_base64_encoded_without_logging_content(self):
with tempfile.TemporaryDirectory() as d:
user_data = Path(d) / "bootstrap.sh"
user_data.write_bytes(b"#!/bin/sh\necho ready\n")
cfg = config()
cfg["user_data_file"] = str(user_data)
params = cloud.create_params(cfg)
self.assertEqual(
base64.b64decode(params["UserData"]), b"#!/bin/sh\necho ready\n"
)
def test_create_timeout_reuses_client_token(self):
api = FakeCloud()
api.timeout_create = True
@@ -235,6 +248,21 @@ class AsteriskTests(unittest.TestCase):
self.assertIn("external_media_address=123.56.71.98", files["pjsip.conf"])
self.assertIn("context=deny-inbound", files["pjsip.conf"])
self.assertIn("strictrtp=yes", files["rtp.conf"])
def test_preserves_explicit_caller_mapping(self):
cfg = self.cfg()
cfg["primary"].update(
from_user="BD93205882", caller_id="BD93205882 <BD93205882>"
)
files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
self.assertIn("from_user=BD93205882", files["pjsip.conf"])
self.assertIn("callerid=BD93205882 <BD93205882>", files["pjsip.conf"])
self.assertIn(
"Set(CALLERID(all)=BD93205882 <BD93205882>)", files["extensions.conf"]
)
self.assertIn(
"Dial(PJSIP/${EXTEN}@provider-primary,60)", files["extensions.conf"]
)
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "generated"
ast.write_config(files, path)
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import copy
import http.client
import json
import os
import tempfile
import threading
import unittest
from pathlib import Path
from typing import Any
from agent_call.core import AgentCallService, InMemoryBroker, iso, utcnow
from agent_call.http import make_server
ROOT = Path(__file__).resolve().parents[1]
def load_fixture() -> dict[str, Any]:
try:
return json.loads(
(ROOT / "docs/contracts/examples/call.execute.json").read_text(
encoding="utf-8"
)
)
except (OSError, json.JSONDecodeError) as exc:
raise AssertionError("fixture unavailable") from exc
class HttpContractTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.previous_tokens = os.environ.get("HTTP_TOKENS")
os.environ["HTTP_TOKENS"] = json.dumps(
{
"local": {
"tenant_ids": ["tenant-demo"],
"scopes": [
"outbound.read",
"outbound.control",
"recording.upload",
"recording.complete",
],
},
}
)
self.service = AgentCallService(
db_path=Path(self.temp.name) / "http.sqlite3",
object_dir=Path(self.temp.name) / "objects",
broker=InMemoryBroker(),
)
self.service.profile["limits"]["tenant_cps"] = 100
self.service.profile["limits"]["global_cps"] = 100
self.server = make_server(self.service, "127.0.0.1", 0)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.port = self.server.server_address[1]
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
self.service.stop()
if self.previous_tokens is None:
os.environ.pop("HTTP_TOKENS", None)
else:
os.environ["HTTP_TOKENS"] = self.previous_tokens
self.temp.cleanup()
def request(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
tenant: str | None = "tenant-demo",
token: str | None = "local",
idem: str | None = None,
) -> tuple[int, dict[str, Any]]:
headers = {"X-Request-ID": "http-test"}
if tenant is not None:
headers["X-Tenant-ID"] = tenant
if token is not None:
headers["Authorization"] = f"Bearer {token}"
encoded: bytes | None = None
if body is not None:
encoded = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
if idem is not None:
headers["Idempotency-Key"] = idem
connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=3)
try:
connection.request(method, path, body=encoded, headers=headers)
response = connection.getresponse()
raw = response.read()
status = response.status
except OSError as exc:
raise AssertionError("HTTP test server failed") from exc
finally:
connection.close()
if not raw.startswith(b"{"):
raise AssertionError("HTTP response is not JSON")
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise AssertionError("HTTP response is not JSON") from exc
if not isinstance(parsed, dict):
raise AssertionError("HTTP response is not an object")
return status, parsed
def publish_success(self) -> dict[str, Any]:
command = copy.deepcopy(load_fixture())
command["command_id"] = "http_cmd"
command["trace_id"] = "http_trace"
command["payload"]["execution_id"] = "http_exec"
command["issued_at"] = iso(utcnow())
self.service.publish_execute(command)
self.service.wait_for_idle()
return self.service.get_command("tenant-demo", "http_cmd")
def test_health_is_public_but_business_queries_are_authenticated(self) -> None:
status, body = self.request("GET", "/healthz/live", tenant=None, token=None)
self.assertEqual(status, 200)
self.assertEqual(body["status"], "live")
status, body = self.request(
"GET", "/internal/v1/outbound/commands/missing", token=None
)
self.assertEqual(status, 401)
self.assertEqual(body["code"], "UNAUTHORIZED")
def test_control_query_and_no_http_execute_endpoint(self) -> None:
snapshot = self.publish_success()
status, body = self.request(
"GET", f"/internal/v1/outbound/commands/{snapshot['command_id']}"
)
self.assertEqual(status, 200)
self.assertEqual(body["status"], "completed")
status, body = self.request(
"POST", "/internal/v1/outbound/commands", body={}, idem="not-an-execute"
)
self.assertEqual(status, 404)
pause = {
"command_id": "http_pause",
"action": "pause",
"expected_task_revision": 1,
"reason": "test",
}
status, body = self.request(
"POST",
"/internal/v1/outbound/tasks/task-demo/controls",
pause,
idem="http_pause",
)
self.assertEqual(status, 202)
self.service.process_once()
status, body = self.request("GET", "/internal/v1/outbound/commands/http_pause")
self.assertEqual(status, 200)
self.assertEqual(body["status"], "applied")
status, body = self.request(
"GET", "/internal/v1/outbound/commands/http_cmd", tenant="tenant-b"
)
self.assertEqual(status, 403)
def test_recording_upload_contract_is_scoped(self) -> None:
snapshot = self.publish_success()
call = self.service.get_call("tenant-demo", snapshot["call_id"])
recording = call["recordings"][0]
request = {
"recording_id": recording["recording_id"],
"call_id": snapshot["call_id"],
"content_type": "audio/wav",
"size_bytes": recording["size_bytes"],
"checksum_algorithm": "SHA-256",
"checksum": recording["checksum"],
"channels": recording["channels"],
"sample_rate_hz": recording["sample_rate_hz"],
"duration_ms": recording["duration_ms"],
}
status, body = self.request(
"POST",
"/internal/v1/outbound/recording-uploads",
request,
idem="upload-http",
)
self.assertEqual(status, 200)
self.assertEqual(body["recording_id"], recording["recording_id"])
self.assertTrue(body["upload_url"].startswith("/_mock/oss/"))
if __name__ == "__main__":
unittest.main()