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
```
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
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
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
multimodal blocks:
@@ -40,12 +40,13 @@ class ConversationAgent:
self.event_logger = event_logger
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"}:
raise RuntimeError(
"conversation chat currently requires an OpenAI-compatible provider"
)
client = self._get_client()
active_tools = tools or self.tools
history: list[dict[str, Any]] = [
{
"role": "system",
@@ -88,7 +89,7 @@ class ConversationAgent:
arguments = decoded_arguments
arguments.pop("purpose", None)
arguments.pop("expected_outcome", None)
result = self.tools[name](**arguments)
result = active_tools[name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
@@ -103,6 +104,14 @@ class ConversationAgent:
)
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:
if self.event_logger is not None:
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}"}})
return {"role": role, "content": blocks}
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:
raise HTTPException(status_code=503, detail="chat agent is not configured")
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
if not isinstance(raw_messages, list) or not raw_messages:
raise HTTPException(status_code=400, detail="messages must be a non-empty list")
@@ -439,7 +444,9 @@ def create_console_app(
]
if not messages:
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(
{"content": result.content, "tool_calls": result.tool_calls}
)