feat: 完善 N7 智能座舱交互与控制链路

This commit is contained in:
vee
2026-09-07 15:34:59 +08:00
parent 32d179143f
commit c96970f068
100 changed files with 30088 additions and 1174 deletions
@@ -0,0 +1,427 @@
from __future__ import annotations
import json
import os
import queue
import subprocess
import sys
import threading
from collections.abc import Callable, Mapping
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Any, Self
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import SeatDemoHandler, VehicleDisplaySync
ROOT = Path(__file__).resolve().parents[2]
ORIGINAL_INSTRUCTION = "打开遮阳帘"
WRONG_REQUEST_TOKEN = "mcp-loopback-wrong-token-456"
_EOF = object()
SHADE_PLAN: dict[str, object] = {
"detected_domains": ["roof"],
"requests": [
{
"domain": "roof",
"capability": "roof.set_shade_motion",
"target_scope": "vehicle",
"operation": "set",
"duration_ms": None,
"parameters": [{"name": "motion", "value": "open"}],
"reason": "用户明确要求打开遮阳帘。",
}
],
"ambiguities": [],
"decision_summary": ["打开遮阳帘。"],
"confidence": 0.99,
}
class _StdioMcpClient:
"""Drive the real MCP child process without platform-specific pipe polling."""
def __init__(self, callback_url: str, callback_token: str) -> None:
environment = os.environ.copy()
python_paths = [
ROOT / "src",
ROOT / "domains" / "_template" / "src",
ROOT / "domains" / "ambient_light" / "src",
ROOT / "domains" / "climate" / "src",
ROOT / "domains" / "roof" / "src",
ROOT / "domains" / "seat" / "src",
]
inherited_python_path = environment.get("PYTHONPATH", "").strip()
environment["PYTHONPATH"] = os.pathsep.join(
[
*(str(path) for path in python_paths),
*([inherited_python_path] if inherited_python_path else []),
]
)
environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_URL"] = callback_url
environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TOKEN"] = callback_token
environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TIMEOUT_SECONDS"] = "5"
# MCP is a UTF-8 JSON-lines protocol. Force the same pipe encoding on
# Windows (whose inherited console encoding may otherwise be GBK).
environment["PYTHONUTF8"] = "1"
environment["PYTHONIOENCODING"] = "utf-8"
self._responses: queue.Queue[object] = queue.Queue()
self._stderr: list[str] = []
self._next_id = 0
self.process = subprocess.Popen(
[
sys.executable,
"-u",
"-m",
"cognitive_agent.infrastructure.codex.vehicle_mcp_server",
],
cwd=ROOT,
env=environment,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="strict",
bufsize=1,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True)
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
self._stdout_thread.start()
self._stderr_thread.start()
def _read_stdout(self) -> None:
assert self.process.stdout is not None
try:
for line in self.process.stdout:
self._responses.put(line)
finally:
self._responses.put(_EOF)
def _read_stderr(self) -> None:
assert self.process.stderr is not None
self._stderr.extend(self.process.stderr)
def request(self, method: str, params: Mapping[str, object]) -> dict[str, Any]:
self._next_id += 1
request_id = self._next_id
self._write(
{
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": dict(params),
}
)
try:
raw = self._responses.get(timeout=10)
except queue.Empty as exc:
raise AssertionError(
f"MCP response timed out; stderr={''.join(self._stderr)!r}"
) from exc
if raw is _EOF:
raise AssertionError(
f"MCP process exited before responding; stderr={''.join(self._stderr)!r}"
)
response = json.loads(str(raw))
assert response["jsonrpc"] == "2.0"
assert response["id"] == request_id
return response
def notify(self, method: str, params: Mapping[str, object]) -> None:
self._write({"jsonrpc": "2.0", "method": method, "params": dict(params)})
def _write(self, message: Mapping[str, object]) -> None:
assert self.process.stdin is not None
assert self.process.poll() is None, f"MCP child exited: {''.join(self._stderr)}"
self.process.stdin.write(
json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n"
)
self.process.stdin.flush()
def close(self) -> None:
if self.process.stdin is not None and not self.process.stdin.closed:
self.process.stdin.close()
try:
return_code = self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.terminate()
try:
return_code = self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.kill()
return_code = self.process.wait(timeout=3)
self._stdout_thread.join(timeout=2)
self._stderr_thread.join(timeout=2)
if self.process.stdout is not None:
self.process.stdout.close()
if self.process.stderr is not None:
self.process.stderr.close()
assert return_code == 0, (
f"MCP child exited with {return_code}; stderr={''.join(self._stderr)!r}"
)
def __enter__(self) -> Self:
return self
def __exit__(self, *_args: object) -> None:
self.close()
def _complete_turn(turn: ActiveCodexTurn) -> None:
item_id = f"answer-{turn.turn_id}"
answer = "已完成遮阳帘模拟编排。"
turn.events.put(
{
"method": "item/completed",
"params": {
"item": {
"type": "agentMessage",
"id": item_id,
"text": answer,
"phase": "final_answer",
}
},
}
)
turn.events.put(
{
"method": "turn/completed",
"params": {
"turn": {
"id": turn.turn_id,
"status": "completed",
"items": [],
"error": None,
}
},
}
)
class _McpDrivingCodexClient:
"""Minimal model-side driver; all vehicle work still crosses the real MCP process."""
available = True
def __init__(self) -> None:
self.rpc: _StdioMcpClient | None = None
self.started_with: tuple[str, str] | None = None
self.capability_response: dict[str, Any] | None = None
self.wrong_token_response: dict[str, Any] | None = None
self.execution_response: dict[str, Any] | None = None
self.active_request_token = ""
@property
def status(self) -> str:
return "ready"
def start(self, callback_url: str, callback_token: str) -> None:
self.started_with = (callback_url, callback_token)
def close(self) -> None:
return None
def start_thread(self, *, developer_instructions: str, ephemeral: bool = True) -> str:
assert "get_n7web_capabilities" in developer_instructions
assert "execute_cockpit_request" in developer_instructions
assert ephemeral is True
return "thread-real-mcp-loopback"
def start_turn(
self,
thread_id: str,
text: str,
request_token: str,
*,
application_context: Mapping[str, object] | None = None,
) -> ActiveCodexTurn:
assert self.rpc is not None
assert thread_id == "thread-real-mcp-loopback"
assert text == ORIGINAL_INSTRUCTION
assert request_token != WRONG_REQUEST_TOKEN
assert len(request_token) >= 8
assert application_context
self.active_request_token = request_token
turn = ActiveCodexTurn(
thread_id=thread_id,
turn_id="turn-real-mcp-loopback",
events=queue.Queue(),
)
self.capability_response = self.rpc.request(
"tools/call",
{
"name": "get_n7web_capabilities",
"arguments": {"request_token": request_token, "query": "遮阳帘"},
},
)
self.wrong_token_response = self.rpc.request(
"tools/call",
{
"name": "execute_cockpit_request",
"arguments": {
"request_token": WRONG_REQUEST_TOKEN,
"plan": SHADE_PLAN,
},
},
)
self.execution_response = self.rpc.request(
"tools/call",
{
"name": "execute_cockpit_request",
# No instruction crosses MCP. The application must recover the
# exact original text from this turn-bound opaque token.
"arguments": {"request_token": request_token, "plan": SHADE_PLAN},
},
)
_complete_turn(turn)
return turn
def finish_turn(self, _turn: ActiveCodexTurn) -> None:
return None
def interrupt(self, _turn: ActiveCodexTurn) -> None:
return None
def unsubscribe_thread(self, _thread_id: str) -> None:
return None
def _auth_disabled() -> AuthRuntime:
return AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
def test_real_mcp_stdio_process_reaches_bound_loopback_dry_run(tmp_path: Path) -> None:
service = SeatDemoService(ROOT)
service.artifact_root = tmp_path / "runs"
catalog = AssistantCapabilityCatalog(
service,
peripheral_provider=list,
nfc_provider=lambda: {"reader": {"state": "ready"}, "card": None},
)
client = _McpDrivingCodexClient()
assistant = CodexAssistantService(client)
handler = type("RealMcpLoopbackHandler", (SeatDemoHandler,), {})
handler.service = service
handler.assistant = assistant
handler.auth = _auth_disabled()
handler.vehicle_display_sync = VehicleDisplaySync()
handler.assistant_capability_catalog = catalog
handler.log_message = lambda *_args: None
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
callback_url = (
f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control"
)
raw_results: list[dict[str, object]] = []
observed_instructions: list[str] = []
def execute_control(
instruction: str,
progress: Callable[[dict[str, object]], None],
assistant_plan: Mapping[str, object],
) -> dict[str, object]:
observed_instructions.append(instruction)
result = service.preview_native_can(
instruction,
provider="local",
conversation_id="conv-real-mcp-vehicle",
execution_mode="dry_run",
on_commands_validated=progress,
assistant_plan=dict(assistant_plan),
)
raw_results.append(result)
return result
try:
assistant.start(callback_url)
assert client.started_with == (callback_url, assistant.internal_token)
with _StdioMcpClient(callback_url, assistant.internal_token) as rpc:
client.rpc = rpc
initialized = rpc.request(
"initialize",
{
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "n7web-integration-test", "version": "1.0"},
},
)
assert initialized["result"]["protocolVersion"] == "2025-06-18"
assert initialized["result"]["serverInfo"]["name"] == "n7web-cockpit"
rpc.notify("notifications/initialized", {})
listed = rpc.request("tools/list", {})
tools = listed["result"]["tools"]
assert [tool["name"] for tool in tools] == [
"get_n7web_capabilities",
"execute_cockpit_request",
]
execute_schema = tools[1]["inputSchema"]["properties"]
assert set(execute_schema) == {"request_token", "plan"}
assert "instruction" not in execute_schema
snapshot = catalog.snapshot()
result = assistant.stream(
ORIGINAL_INSTRUCTION,
conversation_id="conv-real-mcp-assistant",
principal="real-mcp-integration-test",
history=[],
emit=lambda _event: True,
execute_control=execute_control,
capability_snapshot=snapshot,
capability_context=catalog.compact_context(snapshot),
control_terms=catalog.control_terms(snapshot),
query_capabilities=catalog.query,
)
assert result["route"] == "agent"
assert client.capability_response is not None
capability_result = client.capability_response["result"]
assert capability_result["isError"] is False
capability_catalog = capability_result["structuredContent"]["catalog"]
assert capability_catalog["matched"] is True
assert "roof.set_shade_motion" in {
item["id"] for item in capability_catalog["capabilities"]
}
assert client.wrong_token_response is not None
rejected = client.wrong_token_response["result"]
assert rejected["isError"] is True
assert rejected["structuredContent"]["error"]["code"] == "n7web_tool_failed"
assert "HTTP 409" in rejected["structuredContent"]["error"]["message"]
assert client.execution_response is not None
execution = client.execution_response["result"]
assert execution["isError"] is False
structured = execution["structuredContent"]
assert structured["status"] == "succeeded"
assert structured["execution_mode"] == "dry_run"
assert structured["verified"] is False
assert structured["verification_status"] == "simulated"
assert structured["utterance"] == ORIGINAL_INSTRUCTION
assert {action["capability"] for action in structured["actions"]} == {
"roof.set_shade_motion"
}
assert json.loads(execution["content"][0]["text"]) == structured
assert observed_instructions == [ORIGINAL_INSTRUCTION]
assert len(raw_results) == 1
assert raw_results[0]["utterance"] == ORIGINAL_INSTRUCTION
assert raw_results[0]["execution_mode"] == "dry_run"
assert raw_results[0]["status"] == "succeeded"
assert rpc.process.poll() == 0
finally:
assistant.close()
server.shutdown()
server.server_close()
server_thread.join(timeout=3)
service.close()
assert not server_thread.is_alive()