feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user