This commit is contained in:
2026-07-19 18:57:43 +02:00
parent 48bb6c2ec2
commit c53e2a3319
179 changed files with 9307 additions and 897 deletions
+198 -9
View File
@@ -19,10 +19,18 @@ from ..config import Settings
from ..cost import CostTracker
from ..cost.tracker import Pricing
from ..http_client import PlatformClient
from ..interaction import InteractionController
from ..interaction.broker import InteractionBroker
from ..interaction.capabilities import (
CA_IWP_SYSTEM_FRAGMENT,
UI_TOOL_NAMES,
fragment_for,
)
from ..llm import LLMClient
from ..registry import CATALOG
from ..tasks import Scheduler, TaskController, TaskStore
from ..virtual_tools import VirtualToolController, VirtualToolStore
from ..text import normalize_newlines
from ._helpers import _format_offset, _now_iso, _repair_history
from .prompts import (
BEHAVIOR_HEADER,
@@ -98,6 +106,14 @@ class DeviiSession:
)
self._behavior_store = behavior_store
self.behavior = BehaviorController(behavior_store)
self.interaction_broker = InteractionBroker(
session_channel=channel,
owner_kind=owner_kind,
owner_id=owner_id,
)
self.interaction = InteractionController(
self.interaction_broker, owner_kind=owner_kind, owner_id=owner_id
)
self.dispatcher = Dispatcher(
CATALOG,
self.client,
@@ -113,6 +129,7 @@ class DeviiSession:
owner_id=owner_id,
virtual_tools=self.virtual_tools,
behavior=self.behavior,
interaction=self.interaction,
)
self.tools = self._builtin_tools()
self._system_prompt = (
@@ -164,8 +181,16 @@ class DeviiSession:
self._turn_epoch = 0
def restore_history(self, messages: list[dict[str, Any]]) -> None:
if messages:
self.agent._messages = messages
if not messages:
return
repaired: list[dict[str, Any]] = []
for message in messages:
item = dict(message)
content = item.get("content")
if isinstance(content, str):
item["content"] = normalize_newlines(content)
repaired.append(item)
self.agent._messages = repaired
def history(self) -> list[dict[str, Any]]:
visible: list[dict[str, Any]] = []
@@ -174,11 +199,12 @@ class DeviiSession:
content = message.get("content")
if role not in ("user", "assistant") or not content:
continue
if (
role == "user"
and isinstance(content, str)
and content.startswith(INTERNAL_PREFIXES)
):
if not isinstance(content, str):
continue
content = normalize_newlines(content).strip()
if not content:
continue
if role == "user" and content.startswith(INTERNAL_PREFIXES):
continue
visible.append({"role": role, "content": content})
return visible
@@ -228,6 +254,10 @@ class DeviiSession:
self._disconnected.clear()
self.avatar.bind(self._avatar_request)
self.browser.bind(self._client_request)
self.interaction_broker.bind(
emit=self._emit_interaction,
wait_site=self._interaction_wait,
)
self.ensure_scheduler_started()
if self._buffer:
pending = self._buffer
@@ -248,6 +278,14 @@ class DeviiSession:
self._disconnected.set()
self.avatar.unbind()
self.browser.unbind()
open_id = self.interaction_broker.open_id()
if open_id:
self.interaction_broker.resolve(
open_id,
status="cancelled",
values={},
error="Browser disconnected.",
)
logger.info(
"Session %s/%s detached (%d conns)",
self.owner_kind,
@@ -457,7 +495,13 @@ class DeviiSession:
for s in schemas
if s.get("function", {}).get("name") in DOCS_TOOLS
]
return schemas
allowed = set(self.interaction_broker.channel_context().tools)
return [
s
for s in schemas
if s.get("function", {}).get("name") not in UI_TOOL_NAMES
or s.get("function", {}).get("name") in allowed
]
def _refresh_tools(self) -> None:
if self.channel == "docs":
@@ -471,7 +515,14 @@ class DeviiSession:
return self._system_prompt
body = self._behavior_store.text().strip()
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
return f"{self._system_prompt}\n\n{self._clock_line()}\n\n{section}"
channel_block = fragment_for(self.interaction_broker.channel_context())
return (
f"{self._system_prompt}\n\n"
f"{CA_IWP_SYSTEM_FRAGMENT}\n\n"
f"{channel_block}\n\n"
f"{self._clock_line()}\n\n"
f"{section}"
)
def _clock_line(self) -> str:
from datetime import datetime, timedelta, timezone
@@ -745,6 +796,144 @@ class DeviiSession:
if len(self._buffer) > 100:
self._buffer = self._buffer[-100:]
async def _emit_interaction(self, payload: dict[str, Any]) -> None:
await self._emit(payload, buffer=False)
async def _interaction_wait(
self, interaction_id: str, frame: dict[str, Any], timeout: float
) -> Any:
loop = asyncio.get_event_loop()
deadline_seconds = timeout if timeout and timeout > 0 else BROWSER_REQUEST_DEADLINE_SECONDS
if timeout and timeout > 0:
deadline_seconds = max(timeout, 1.0)
else:
deadline_seconds = max(BROWSER_REQUEST_DEADLINE_SECONDS, 300.0)
deadline = loop.time() + deadline_seconds
request_id = f"ix:{interaction_id}"
future: asyncio.Future = loop.create_future()
self._pending[request_id] = future
wire = {
"type": "interaction",
"id": request_id,
"interaction_id": interaction_id,
"args": frame,
}
try:
while not future.done():
remaining = deadline - loop.time()
if remaining <= 0:
raise asyncio.TimeoutError()
try:
await asyncio.wait_for(self._connected.wait(), timeout=remaining)
except asyncio.TimeoutError as exc:
raise asyncio.TimeoutError() from exc
ws = self._pick_target()
if ws is None:
continue
try:
await self._send_to(ws, wire)
except Exception:
await asyncio.sleep(0.1)
continue
drop = asyncio.create_task(self._disconnected.wait())
try:
await asyncio.wait(
{future, drop},
timeout=max(0.0, deadline - loop.time()),
return_when=asyncio.FIRST_COMPLETED,
)
finally:
drop.cancel()
return future.result()
finally:
self._pending.pop(request_id, None)
def resolve_interaction(self, interaction_id: str, payload: Any) -> None:
request_id = str(interaction_id or "")
if not request_id.startswith("ix:"):
request_id = f"ix:{request_id}"
future = self._pending.get(request_id)
if future is not None and not future.done():
future.set_result(payload)
self._pending.pop(request_id, None)
bare = request_id[3:]
if isinstance(payload, dict):
self.interaction_broker.resolve(
bare,
status=str(payload.get("status") or "submitted"),
values=payload.get("values")
if isinstance(payload.get("values"), dict)
else {},
error=payload.get("error"),
meta=payload.get("meta")
if isinstance(payload.get("meta"), dict)
else None,
)
return
bare = request_id[3:] if request_id.startswith("ix:") else request_id
self.interaction_broker.resolve(
bare,
status=str((payload or {}).get("status") or "submitted")
if isinstance(payload, dict)
else "error",
values=(payload or {}).get("values")
if isinstance(payload, dict)
else {},
error=(payload or {}).get("error") if isinstance(payload, dict) else None,
meta=(payload or {}).get("meta") if isinstance(payload, dict) else None,
)
async def try_answer_interaction(self, text: str) -> bool:
open_id = self.interaction_broker.open_id()
if not open_id:
return False
pending = self._pending.get(f"ix:{open_id}")
if pending is not None and pending.done():
return False
outcome = self.interaction_broker.answer_text(text)
if not outcome or not outcome.get("handled"):
return False
if outcome.get("status") == "error":
result = {
"status": "cancelled",
"interaction_id": open_id,
"values": {},
"meta": {
"adapter": "site-chat",
"via": "text_non_answer",
"degraded": True,
},
"error": None,
}
self.resolve_interaction(open_id, result)
await self._emit(
{
"type": "interaction_closed",
"id": f"ix:{open_id}",
"interaction_id": open_id,
"result": result,
},
buffer=False,
)
return False
result = outcome.get("result") or {
"status": outcome.get("status") or "submitted",
"interaction_id": open_id,
"values": {},
"meta": {"adapter": "site-chat", "via": "text", "degraded": True},
}
self.resolve_interaction(open_id, result)
await self._emit(
{
"type": "interaction_closed",
"id": f"ix:{open_id}",
"interaction_id": open_id,
"result": result,
},
buffer=False,
)
return True
async def _avatar_request(self, action: str, args: dict[str, Any]) -> Any:
return await self._browser_request("avatar", action, args)