diff --git a/README.md b/README.md index 7c27f6a..309f1f7 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/apps/device-host-agent/host_agent/conversation.py b/apps/device-host-agent/host_agent/conversation.py index 8db57d8..64a3368 100644 --- a/apps/device-host-agent/host_agent/conversation.py +++ b/apps/device-host-agent/host_agent/conversation.py @@ -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 diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py index 06123ba..8d3ae7d 100644 --- a/apps/device-host-agent/host_agent/web/app.py +++ b/apps/device-host-agent/host_agent/web/app.py @@ -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} )