Files
agent-call/scripts/run_real_cell_call.py
T

261 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run one explicitly authorized Cell call through the tenant RabbitMQ queue."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
import wave
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _data(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError(f"profile is unavailable: {path}") from exc
if not isinstance(value, dict):
raise TypeError("profile must be an object")
return value
def _path(value: str) -> Path:
candidate = Path(value)
return candidate if candidate.is_absolute() else ROOT / candidate
def _integer(value: Any, default: int, field: str, minimum: int = 1) -> int:
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(f"{field} must be an integer") from exc
if parsed < minimum:
raise ValueError(f"{field} must be at least {minimum}")
return parsed
def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", default="configs/ai-test.bailian.example.yaml")
parser.add_argument("--prompt-file", default="prompts/test-call.txt")
parser.add_argument("--callee", required=True)
parser.add_argument("--tenant-id", default=None)
parser.add_argument("--tenant-key", default=None)
parser.add_argument("--route-policy-id", default="route_policy_test")
parser.add_argument("--caller-profile-id", default="caller_profile_test")
parser.add_argument("--broker-url", default=None)
parser.add_argument("--ledger", default="/data/agent-call-cell.sqlite3")
parser.add_argument("--wait-seconds", type=int, default=240)
parser.add_argument("--allow-real-call", action="store_true")
parser.add_argument("--output", default=None)
return parser.parse_args()
def _command(
settings: dict[str, Any],
args: argparse.Namespace,
tenant_id: str,
tenant_key: str,
agent_version_id: str,
) -> dict[str, Any]:
now_timestamp = time.time()
issued_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_timestamp))
not_after = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_timestamp + 300))
execution_id = f"exec_cell_{time.time_ns()}_{os.getpid()}"
return {
"schema_version": "1.0",
"command_type": "call.execute",
"command_id": f"cmd_cell_{execution_id}",
"tenant_id": tenant_id,
"tenant_key": tenant_key,
"trace_id": f"trace_cell_{execution_id}",
"issued_at": issued_at,
"not_after": not_after,
"payload": {
"execution_id": execution_id,
"task_id": str(settings.get("task_id", "task-ai-cell")),
"task_item_id": f"item_{execution_id}",
"task_revision": 1,
"callee": args.callee,
"route_policy_id": args.route_policy_id,
"caller_profile_id": args.caller_profile_id,
"agent_version_id": agent_version_id,
"variables": {},
"ring_timeout_ms": 30000,
"max_call_duration_ms": _integer(
settings.get("max_duration_ms", 180000),
180000,
"max_duration_ms",
),
},
}
def _recording(path: str) -> dict[str, Any] | None:
recording = Path(path)
if not recording.is_file() or recording.is_symlink():
return None
try:
with wave.open(str(recording), "rb") as source:
evidence = {
"bytes": recording.stat().st_size,
"sha256": hashlib.sha256(recording.read_bytes()).hexdigest(),
"channels": source.getnchannels(),
"sample_rate_hz": source.getframerate(),
"frames": source.getnframes(),
"valid_wav": source.getnframes() > 0 and source.getnchannels() == 1,
}
except (OSError, EOFError, wave.Error):
return None
return evidence
def _write(report: dict[str, Any], output: str | None) -> None:
payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if output:
destination = _path(output)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(payload, encoding="utf-8")
print(payload, end="")
def run(args: argparse.Namespace) -> dict[str, Any]:
if not args.allow_real_call:
raise ValueError("--allow-real-call is required")
from agent_call.ai_runtime import ConversationEngine, load_prompt
from agent_call.bailian import (
BailianASR,
BailianLLM,
BailianTTS,
build_bailian_config,
)
from agent_call.core import PikaBroker
from agent_call.real_cell import (
CellCallConfig,
CellExecutionLedger,
RealCellCall,
RealCellWorker,
load_cell_routes,
)
settings = _data(_path(args.profile))
authorized = {str(value) for value in settings.get("authorized_callees", [])}
if args.callee not in authorized:
raise ValueError("callee is not in the profile allow-list")
tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo"))
tenant_key = str(args.tenant_key or settings.get("tenant_key", "tenant-demo-key"))
agent_version_id = str(settings.get("agent_version_id", "agent_bailian_test_v1"))
try:
prompt = load_prompt(
_path(args.prompt_file),
_integer(
settings.get("max_prompt_bytes", 32768), 32768, "max_prompt_bytes"
),
)
except (OSError, UnicodeDecodeError, ValueError) as exc:
raise ValueError("prompt is unavailable") from exc
config = build_bailian_config(
agent_version_id,
prompt,
str(settings.get("model", "qwen-plus")),
settings.get("tts_model"),
settings.get("voice"),
str(settings.get("language", "zh-CN")),
settings.get("asr_model"),
str(settings.get("opening", "")),
min(3, _integer(settings.get("max_turns", 3), 3, "max_turns")),
_integer(settings.get("max_duration_ms", 180000), 180000, "max_duration_ms"),
)
engine = ConversationEngine(
config,
llm=BailianLLM.from_env(),
tts=BailianTTS.from_env(),
asr=BailianASR.from_env(),
)
broker = PikaBroker(args.broker_url or os.environ.get("RABBITMQ_URL", ""))
ledger = CellExecutionLedger(args.ledger)
executor = RealCellCall(CellCallConfig.from_env(), engine, BailianASR.from_env())
worker = RealCellWorker(
broker,
tenant_key,
ledger,
executor,
routes=load_cell_routes(os.environ.get("CELL_ROUTE_MAP_JSON")),
)
command = _command(settings, args, tenant_id, tenant_key, agent_version_id)
route = f"agent-call.tenant.{tenant_key}.call.execute"
broker.publish(
"agent-call.commands.v1", route, command, message_id=command["command_id"]
)
deadline = time.monotonic() + args.wait_seconds
event: dict[str, Any] | None = None
while time.monotonic() < deadline:
event = worker.process_once()
if event and event.get("event_type") == "call.finished":
break
time.sleep(0.2)
if event is None or event.get("event_type") != "call.finished":
raise TimeoutError("Cell call did not finish before the bounded wait")
result = ledger.result(command["payload"]["execution_id"]) or {}
result_status = event["payload"].get("status")
recording_path = result.get("recording_path")
recording = _recording(recording_path) if isinstance(recording_path, str) else None
passed = (
result_status == "completed"
and len(event["payload"].get("turns", [])) >= 3
and bool(recording and recording.get("valid_wav"))
)
report: dict[str, Any] = {
"schema_version": "1.0",
"status": "PASS" if passed else "FAIL",
"mode": "real",
"call_execution": {
"execution_id": command["payload"]["execution_id"],
"tenant_id": tenant_id,
"tenant_key": tenant_key,
"callee": args.callee,
"event_id": event["event_id"],
},
"result": event["payload"],
}
if recording is not None:
report["recording"] = recording
return report
def main() -> int:
args = _args()
try:
report = run(args)
code = 0 if report.get("status") == "PASS" else 1
except (
OSError,
RuntimeError,
TimeoutError,
TypeError,
ValueError,
KeyError,
) as exc:
report = {
"schema_version": "1.0",
"status": "FAIL",
"reason_code": type(exc).__name__,
"message": str(exc),
}
code = 1
_write(report, args.output)
return code
if __name__ == "__main__":
raise SystemExit(main())