Bind chat agent sessions to individual devices
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user