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
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user