diff --git a/AGENTS.md b/AGENTS.md index ccdc2f2..4e3d4ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ - 本地:Go 1.26、Python 3.11+、Node、Docker Engine/Compose;云操作另需 `aliyun` CLI 和北京区域权限。 - 云创建权限至少涉及 ECS Describe/RunInstances、VPC DescribeEipAddresses/AssociateEipAddress;已有 VSwitch、安全组、镜像和 SSH KeyPair。不得自动开放全部端口。 -- Asterisk:镜像固定 digest;已提供的 SIP 参数见上节,其余供应商/备用地址及接入规则仍待补充。ARI 默认仅回环访问;跨机器使用管理网和受控 TLS。RTP、防火墙、NAT、编解码必须真机验证。 +- Asterisk:镜像固定 digest;已提供的 SIP 参数见上节,其余供应商/备用地址及接入规则仍待补充。ARI 默认仅回环访问;跨机器使用管理网和受控 TLS。RTP、防火墙、NAT、编解码必须真机验证。诊断阶段的 UDP 放行必须按供应商 SIP/SDP 媒体源 IP 限定,禁止 `0.0.0.0/0`;第一组实测 183 SDP 的媒体源为 `61.132.228.245`,仅可作为临时诊断源,验证后应收窄或移除。 - ASR Web:服务访问令牌、百炼/火山凭据;默认仅宿主机回环暴露。远程麦克风需要 HTTPS,或通过 SSH 隧道访问 localhost;不要求用户关闭浏览器安全机制。 - 业务对接仍需 RabbitMQ VHost/队列/ACL、用户发布的消息契约、OSS 上传与 OSS ID 规范。测试台音频/识别输出不是已接入 SaaS 的 MQ 结果。 - LLM/TTS:供应商、协议、模型、参数、取消/打断与音频契约均待用户新规范;不得静默调用旧实现或宣称完整对话已验证。 diff --git a/agent_call/real_cell.py b/agent_call/real_cell.py index 59c65b5..8988349 100644 --- a/agent_call/real_cell.py +++ b/agent_call/real_cell.py @@ -66,9 +66,7 @@ class CellRoute: if not isinstance(value, str) or not re.fullmatch( r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value ): - raise CellCallError( - "ROUTE_CONFIG_INVALID", f"{field_name} is invalid" - ) + raise CellCallError("ROUTE_CONFIG_INVALID", f"{field_name} is invalid") if not isinstance(self.trunk_id, str) or not re.fullmatch( r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", self.trunk_id ): @@ -77,8 +75,12 @@ class CellRoute: r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", self.caller_id ): raise CellCallError("ROUTE_CONFIG_INVALID", "caller_id is invalid") - if not isinstance(self.dial_prefix, str) or len(self.dial_prefix) > 32 or ( - self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix) + if ( + not isinstance(self.dial_prefix, str) + or len(self.dial_prefix) > 32 + or ( + self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix) + ) ): raise CellCallError("ROUTE_CONFIG_INVALID", "dial_prefix is invalid") @@ -89,9 +91,13 @@ def load_cell_routes(raw: str | None) -> dict[str, CellRoute]: try: data = json.loads(raw) except (TypeError, json.JSONDecodeError) as exc: - raise CellCallError("ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON is invalid") from exc + raise CellCallError( + "ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON is invalid" + ) from exc if not isinstance(data, Mapping): - raise CellCallError("ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON must be an object") + raise CellCallError( + "ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON must be an object" + ) routes: dict[str, CellRoute] = {} for route_policy_id, value in data.items(): if not isinstance(value, Mapping): @@ -104,7 +110,8 @@ def load_cell_routes(raw: str | None) -> dict[str, CellRoute]: dial_prefix = value.get("dial_prefix", "") if not isinstance(caller_profile_id, str): raise CellCallError( - "ROUTE_CONFIG_INVALID", f"route {route_policy_id!r} has invalid caller profile" + "ROUTE_CONFIG_INVALID", + f"route {route_policy_id!r} has invalid caller profile", ) if not isinstance(trunk_id, str): raise CellCallError( @@ -560,7 +567,11 @@ class RealCellCall: "CALLEE_INVALID", "callee must be the original digit string" ) selected_route = route or CellRoute( - "legacy", "legacy", self.config.trunk_id, self.config.caller_id, self.config.dial_prefix + "legacy", + "legacy", + self.config.trunk_id, + self.config.caller_id, + self.config.dial_prefix, ) self._reset_call() self.engine.history.clear() @@ -1205,13 +1216,12 @@ class RealCellWorker: self.broker.ack(message) return event - def _route( - self, route_policy_id: str, caller_profile_id: str - ) -> CellRoute | None: + def _route(self, route_policy_id: str, caller_profile_id: str) -> CellRoute | None: if not route_policy_id and not caller_profile_id: if self.routes: raise CellCallError( - "ROUTE_POLICY_REQUIRED", "route policy is required on a multi-trunk Cell" + "ROUTE_POLICY_REQUIRED", + "route policy is required on a multi-trunk Cell", ) return None if not route_policy_id or not caller_profile_id: @@ -1234,13 +1244,12 @@ class RealCellWorker: ) if route.caller_profile_id != caller_profile_id: raise CellCallError( - "CALLER_PROFILE_MISMATCH", "caller profile is not bound to the route policy" + "CALLER_PROFILE_MISMATCH", + "caller profile is not bound to the route policy", ) return route - def _command( - self, body: Any - ) -> tuple[str, str, str, str, str, str]: + def _command(self, body: Any) -> tuple[str, str, str, str, str, str]: if not isinstance(body, Mapping) or body.get("command_type") != "call.execute": raise CellCallError("COMMAND_INVALID", "message is not call.execute") if body.get("tenant_key") != self.tenant_key: diff --git a/deploy/render_asterisk.py b/deploy/render_asterisk.py index 801b77f..50ba14b 100755 --- a/deploy/render_asterisk.py +++ b/deploy/render_asterisk.py @@ -136,7 +136,9 @@ def _trunks(cfg): ) return rows if "primary" in cfg or "backup" in cfg: - raise ValueError("use trunks instead of mixing arbitrary trunks with primary/backup") + raise ValueError( + "use trunks instead of mixing arbitrary trunks with primary/backup" + ) raw = cfg["trunks"] if not isinstance(raw, list) or not raw: raise ValueError("trunks must be a non-empty list") diff --git a/docs/evidence/three-sip-llm-outbound-20260917.json b/docs/evidence/three-sip-llm-outbound-20260917.json new file mode 100644 index 0000000..6b922d5 --- /dev/null +++ b/docs/evidence/three-sip-llm-outbound-20260917.json @@ -0,0 +1,106 @@ +{ + "record_version": "1.0", + "recorded_at_utc": "2026-09-17T07:50:38Z", + "scope": "One packet-captured real outbound attempt per independently selectable SIP line.", + "code_commit": "e65cfbe", + "deployment": { + "region": "cn-beijing", + "instance_id": "i-2zeimnh6hq53q4srr3o2", + "public_eip": "123.56.71.98", + "asterisk_container": "agent-call-asterisk", + "trunks_loaded": ["provider-primary", "provider-second", "provider-third"], + "ari_after_fix": "HTTP and WebSocket smoke tests passed; ari.conf installed readable by asterisk (0640)." + }, + "security": { + "diagnostic_udp": "source-limited provider SIP/media addresses only", + "global_udp_open": false, + "provider_rules_added": [ + "sgr-2ze2gmif9nlbravo9b9x", + "sgr-2ze2gmif9nlbravo9b9w" + ], + "observed_first_provider_media_source": "61.132.228.245" + }, + "ai": { + "mode": "real", + "stack": "Bailian ASR + Qwen LLM + CosyVoice TTS", + "credentials": "runtime environment only; no secret recorded" + }, + "tests": [ + { + "line": "first", + "route_policy_id": "route_policy_sip_first", + "caller_profile_id": "caller_profile_sip_first", + "trunk_id": "provider-primary", + "provider": "61.132.228.221:5060", + "caller_id": "BD93205882", + "original_callee": "15003164745", + "effective_target": "708915003164745", + "status": "FAIL", + "reason_code": "CALL_NOT_ANSWERED", + "sip_exchange": ["INVITE", "100 Trying", "183 Session Progress", "486 Busy Here", "ACK"], + "rtp_in": 0, + "rtp_out": 0, + "ai_turns": 0, + "recording_valid": false, + "recording_bytes": 44, + "report": ".local/agent-call-three-tests/results-20260917/first/call-report.json", + "pcap": ".local/agent-call-three-tests/results-20260917/first/capture.pcap", + "pcap_sha256": "e8cf6aa5d5f039cc93cb81db1a390da9f56f249eb92e7ee6d2b6819772aa6034" + }, + { + "line": "second", + "route_policy_id": "route_policy_sip_second", + "caller_profile_id": "caller_profile_sip_second", + "trunk_id": "provider-second", + "provider": "60.171.24.90:5060", + "caller_id": "mbkq", + "original_callee": "18625770806", + "effective_target": "18625770806", + "status": "FAIL", + "reason_code": "CALL_NOT_ANSWERED", + "sip_exchange": ["INVITE", "100 Trying", "404 Not Found", "ACK"], + "rtp_in": 0, + "rtp_out": 0, + "ai_turns": 0, + "recording_valid": false, + "recording_bytes": 44, + "report": ".local/agent-call-three-tests/results-20260917/second/call-report.json", + "pcap": ".local/agent-call-three-tests/results-20260917/second/capture.pcap", + "pcap_sha256": "2a799c3c32754dc2cadee7c064076cb94578b08cdc88766f93140ff25d5cc25c" + }, + { + "line": "third", + "route_policy_id": "route_policy_sip_third", + "caller_profile_id": "caller_profile_sip_third", + "trunk_id": "provider-third", + "provider": "160.202.254.79:5060", + "caller_id": "KQ91526", + "original_callee": "18625770806", + "effective_target": "mka75518625770806", + "status": "FAIL", + "reason_code": "CALL_NOT_ANSWERED", + "sip_exchange": ["INVITE", "100 Trying", "480 Temporarily Unavailable", "ACK"], + "rtp_in": 0, + "rtp_out": 0, + "ai_turns": 0, + "recording_valid": false, + "recording_bytes": 44, + "report": ".local/agent-call-three-tests/results-20260917/third/call-report.json", + "pcap": ".local/agent-call-three-tests/results-20260917/third/capture.pcap", + "pcap_sha256": "1c5229757cd53b9384f0221b429f4629f0cd60e7f00bcb575b4bf8eb4e0416f8" + } + ], + "conclusion": { + "route_selection": "verified: each task used its intended preloaded trunk", + "signaling": "verified through provider-specific SIP captures", + "answer": "not reached on any attempt", + "rtp": "not exercised because no call reached 200 OK with SDP", + "llm_asr_tts": "not exercised; no media or AI turns were possible", + "acceptance": "blocked pending provider-side answer/caller authorization and a post-answer RTP test" + }, + "notes": [ + "No automatic retry, redial, or trunk failover was performed.", + "The 486, 404, and 480 responses are provider/pre-answer failures and do not establish a post-answer RTP diagnosis.", + "The first provider advertised media address 61.132.228.245 in 183 SDP; it was added as a source-limited diagnostic address afterward." + ] +} diff --git a/tests/test_real_cell.py b/tests/test_real_cell.py index 1d636ee..e3162ab 100644 --- a/tests/test_real_cell.py +++ b/tests/test_real_cell.py @@ -294,7 +294,11 @@ class RealCellTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3") fake_result = SimpleNamespace( - as_dict=lambda: {"call_id": "call-route", "status": "failed", "turns": []} + as_dict=lambda: { + "call_id": "call-route", + "status": "failed", + "turns": [], + } ) class FakeExecutor: