Bind chat agent sessions to individual devices

This commit is contained in:
showtan001
2026-08-30 21:48:39 +08:00
parent fdaca7539b
commit 697e54427b
3 changed files with 37 additions and 4 deletions
+5 -1
View File
@@ -86,12 +86,16 @@ The local Host console also exposes an authenticated conversational Agent API:
POST http://127.0.0.1:8765/api/chat POST http://127.0.0.1:8765/api/chat
``` ```
Send a JSON body containing `messages` (`user`/`assistant` roles). The Agent can Send a JSON body containing `device_id` and `messages` (`user`/`assistant` roles). The Agent can
return ordinary assistant text or call the same device-operation tool contracts return ordinary assistant text or call the same device-operation tool contracts
used by the Runtime; each tool result is fed back to the model before the final used by the Runtime; each tool result is fed back to the model before the final
reply is returned. The endpoint uses the Host console session cookie, so it is reply is returned. The endpoint uses the Host console session cookie, so it is
not an unauthenticated device-control endpoint. not an unauthenticated device-control endpoint.
Every chat request is bound to exactly one registered `device_id`. Keep a
separate message history and client session for each phone; the Agent injects
the bound device into device tools and rejects cross-device tool arguments.
For vision-capable OpenAI models, a user message may contain standard OpenAI For vision-capable OpenAI models, a user message may contain standard OpenAI
multimodal blocks: multimodal blocks:
@@ -40,12 +40,13 @@ class ConversationAgent:
self.event_logger = event_logger self.event_logger = event_logger
self._client: Any | None = None self._client: Any | None = None
def chat(self, messages: list[dict[str, Any]]) -> ChatResult: def chat(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
if self.config.provider not in {"openai", "openai_compatible"}: if self.config.provider not in {"openai", "openai_compatible"}:
raise RuntimeError( raise RuntimeError(
"conversation chat currently requires an OpenAI-compatible provider" "conversation chat currently requires an OpenAI-compatible provider"
) )
client = self._get_client() client = self._get_client()
active_tools = tools or self.tools
history: list[dict[str, Any]] = [ history: list[dict[str, Any]] = [
{ {
"role": "system", "role": "system",
@@ -88,7 +89,7 @@ class ConversationAgent:
arguments = decoded_arguments arguments = decoded_arguments
arguments.pop("purpose", None) arguments.pop("purpose", None)
arguments.pop("expected_outcome", None) arguments.pop("expected_outcome", None)
result = self.tools[name](**arguments) result = active_tools[name](**arguments)
except Exception as exc: except Exception as exc:
result = {"ok": False, "error": str(exc)} result = {"ok": False, "error": str(exc)}
calls += 1 calls += 1
@@ -103,6 +104,14 @@ class ConversationAgent:
) )
raise RuntimeError("conversation exceeded the maximum tool-call rounds") raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def chat_for_device(self, device_id: str, messages: list[dict[str, Any]]) -> ChatResult:
"""Run a conversation bound to exactly one device."""
device_tools = {
name: _bind_device(tool, device_id, name=name)
for name, tool in original_tools.items()
}
return self.chat(messages, tools=device_tools)
def _log(self, event: dict[str, Any]) -> None: def _log(self, event: dict[str, Any]) -> None:
if self.event_logger is not None: if self.event_logger is not None:
try: try:
@@ -192,3 +201,16 @@ def _normalize_message(message: dict[str, Any]) -> dict[str, Any]:
blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image}"}}) blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image}"}})
return {"role": role, "content": blocks} return {"role": role, "content": blocks}
raise ValueError("message content must be text, image blocks, or image_base64") raise ValueError("message content must be text, image blocks, or image_base64")
def _bind_device(tool: Callable[..., Any], device_id: str, *, name: str) -> Callable[..., Any]:
def bound(**arguments: Any) -> Any:
if name == "list_devices":
return tool(**arguments)
requested = arguments.get("device_id")
if requested is not None and requested != device_id:
raise ValueError(f"conversation is bound to device {device_id}")
arguments["device_id"] = device_id
return tool(**arguments)
return bound
+8 -1
View File
@@ -427,6 +427,11 @@ def create_console_app(
if conversation_agent is None: if conversation_agent is None:
raise HTTPException(status_code=503, detail="chat agent is not configured") raise HTTPException(status_code=503, detail="chat agent is not configured")
payload = await request.json() payload = await request.json()
device_id = payload.get("device_id") if isinstance(payload, dict) else None
if not isinstance(device_id, str) or not device_id.strip():
raise HTTPException(status_code=400, detail="device_id is required")
if device_id not in {device.id for device in manager.list_devices()}:
raise HTTPException(status_code=404, detail="unknown device")
raw_messages = payload.get("messages") if isinstance(payload, dict) else None raw_messages = payload.get("messages") if isinstance(payload, dict) else None
if not isinstance(raw_messages, list) or not raw_messages: if not isinstance(raw_messages, list) or not raw_messages:
raise HTTPException(status_code=400, detail="messages must be a non-empty list") raise HTTPException(status_code=400, detail="messages must be a non-empty list")
@@ -439,7 +444,9 @@ def create_console_app(
] ]
if not messages: if not messages:
raise HTTPException(status_code=400, detail="messages are invalid") raise HTTPException(status_code=400, detail="messages are invalid")
result = await asyncio.to_thread(conversation_agent.chat, messages) result = await asyncio.to_thread(
conversation_agent.chat_for_device, device_id.strip(), messages
)
return JSONResponse( return JSONResponse(
{"content": result.content, "tool_calls": result.tool_calls} {"content": result.content, "tool_calls": result.tool_calls}
) )