Files
livekit--agents/livekit-plugins/livekit-plugins-browser/livekit/plugins/browser/browser_agent.py
T
2026-07-13 13:39:38 +08:00

377 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import logging
from typing import TYPE_CHECKING, Any
from livekit import rtc
from livekit.agents import llm
from livekit.agents.llm import function_tool
from livekit.browser import BrowserContext, BrowserPage # type: ignore[import-untyped]
from .page_actions import PageActions
from .session import BrowserSession
if TYPE_CHECKING:
from livekit.plugins.anthropic.computer_tool import ComputerTool
logger = logging.getLogger(__name__)
_POST_ACTION_DELAY = 0.8
_CHAT_TOPIC = "browser-agent-chat"
_STATUS_TOPIC = "browser-agent-status"
_CURSOR_TOPIC = "browser-agent-cursor"
class BrowserAgent:
def __init__(
self,
*,
url: str = "https://www.google.com/",
llm: llm.LLM,
instructions: str = "You are a helpful AI assistant that can browse the web. Use the computer tool to interact with the browser.",
width: int = 1280,
height: int = 720,
framerate: int = 30,
tools: list[llm.Tool] | None = None,
chat_enabled: bool = True,
) -> None:
self._url = url
self._llm = llm
self._instructions = instructions
self._width = width
self._height = height
self._framerate = framerate
self._extra_tools = tools or []
self._chat_enabled = chat_enabled
self._room: rtc.Room | None = None
self._browser_ctx: BrowserContext | None = None
self._page: BrowserPage | None = None
self._session: BrowserSession | None = None
self._computer_tool: ComputerTool | None = None
self._chat_ctx: llm.ChatContext | None = None
self._agent_loop_task: asyncio.Task[None] | None = None
self._pending_messages: asyncio.Queue[str] = asyncio.Queue()
self._started = False
@property
def page(self) -> BrowserPage | None:
return self._page
@property
def session(self) -> BrowserSession | None:
return self._session
@property
def computer_tool(self) -> ComputerTool | None:
return self._computer_tool
@property
def chat_ctx(self) -> llm.ChatContext | None:
return self._chat_ctx
async def start(self, *, room: rtc.Room) -> None:
if self._started:
return
self._started = True
self._room = room
self._browser_ctx = BrowserContext(dev_mode=False)
await self._browser_ctx.initialize()
self._page = await self._browser_ctx.new_page(
url=self._url,
width=self._width,
height=self._height,
framerate=self._framerate,
)
self._session = BrowserSession(page=self._page, room=room)
await self._session.start()
from livekit.plugins.anthropic.computer_tool import ComputerTool
self._page_actions = PageActions(page=self._page)
self._computer_tool = ComputerTool(
actions=self._page_actions,
width=self._width,
height=self._height,
)
@function_tool(name="navigate", description="Navigate the browser to a URL.")
async def _navigate(url: str) -> None:
pass
@function_tool(name="go_back", description="Go back to the previous page.")
async def _go_back() -> None:
pass
@function_tool(name="go_forward", description="Go forward to the next page.")
async def _go_forward() -> None:
pass
self._nav_tools: list[llm.Tool] = [_navigate, _go_back, _go_forward]
self._chat_ctx = llm.ChatContext()
self._chat_ctx.add_message(role="system", content=self._instructions)
await self._session.reclaim_agent_focus()
if self._chat_enabled:
@room.on("data_received")
def _on_chat_data(packet: rtc.DataPacket) -> None:
if packet.topic != _CHAT_TOPIC:
return
try:
data = json.loads(packet.data)
text = data.get("text", "")
if text:
self._pending_messages.put_nowait(text)
except (json.JSONDecodeError, UnicodeDecodeError):
pass
self._on_chat_data = _on_chat_data
self._agent_loop_task = asyncio.create_task(self._agent_loop())
async def send_message(self, text: str) -> None:
self._pending_messages.put_nowait(text)
async def _agent_loop(self) -> None:
assert self._chat_ctx is not None
assert self._computer_tool is not None
assert self._session is not None
while True:
try:
text = await self._pending_messages.get()
if self._session.agent_interrupted.is_set():
self._session.agent_interrupted.clear()
await self._session.reclaim_agent_focus()
self._chat_ctx.add_message(role="user", content=text)
await self._send_status("thinking")
await self._run_llm_loop()
await self._send_cursor_hide()
await self._send_status("idle")
except asyncio.CancelledError:
break
except Exception:
logger.exception("error in agent loop")
await self._send_cursor_hide()
await self._send_status("idle")
async def _run_llm_loop(self) -> None:
assert self._chat_ctx is not None
assert self._computer_tool is not None
assert self._session is not None
all_tools: list[llm.Tool] = [
*self._computer_tool.tools,
*self._nav_tools,
*self._extra_tools,
]
tool_ctx = llm.ToolContext(all_tools)
while True:
if self._session.agent_interrupted.is_set():
logger.info("agent interrupted by human, pausing")
await self._send_chat("(paused — you have control)")
return
response = await self._llm.chat(
chat_ctx=self._chat_ctx,
tools=all_tools,
).collect()
if response.text:
self._chat_ctx.add_message(role="assistant", content=response.text)
await self._send_chat(response.text)
if not response.tool_calls:
return
for tc in response.tool_calls:
if self._session.agent_interrupted.is_set():
logger.info("agent interrupted between tool calls")
await self._send_chat("(paused — you have control)")
return
if tc.name == "computer":
import json as _json
args = _json.loads(tc.arguments or "{}")
action = args.pop("action", "screenshot")
# Broadcast cursor position for frontend overlay
coord = args.get("coordinate")
if coord and len(coord) == 2:
await self._send_cursor_position(int(coord[0]), int(coord[1]), action)
await self._send_status("acting")
screenshot_content = await self._computer_tool.execute(action, **args)
# Wait for page to settle after clicks/typing
if action in (
"left_click",
"middle_click",
"key",
"type",
):
await asyncio.sleep(_POST_ACTION_DELAY)
fnc_call = llm.FunctionCall(
call_id=tc.call_id,
name=tc.name,
arguments=tc.arguments or "{}",
)
fnc_output = llm.FunctionCallOutput(
call_id=tc.call_id,
name=tc.name,
output=json.dumps(screenshot_content),
is_error=False,
)
self._chat_ctx.items.append(fnc_call)
self._chat_ctx.items.append(fnc_output)
elif tc.name in ("navigate", "go_back", "go_forward"):
await self._send_status("acting")
if tc.name == "navigate":
import json as _json
url = _json.loads(tc.arguments or "{}").get("url", "")
await self._page_actions.navigate(url)
elif tc.name == "go_back":
await self._page_actions.go_back()
else:
await self._page_actions.go_forward()
await asyncio.sleep(_POST_ACTION_DELAY)
screenshot_content = _screenshot_content(self._page_actions)
fnc_call = llm.FunctionCall(
call_id=tc.call_id,
name=tc.name,
arguments=tc.arguments or "{}",
)
fnc_output = llm.FunctionCallOutput(
call_id=tc.call_id,
name=tc.name,
output=json.dumps(screenshot_content),
is_error=False,
)
self._chat_ctx.items.append(fnc_call)
self._chat_ctx.items.append(fnc_output)
else:
result = await llm.execute_function_call(tc, tool_ctx)
self._chat_ctx.items.append(result.fnc_call)
if result.fnc_call_out:
self._chat_ctx.items.append(result.fnc_call_out)
await self._send_status("thinking")
async def _send_chat(self, text: str) -> None:
if self._room is None:
return
payload = json.dumps({"text": text, "sender": "agent"}).encode()
try:
await self._room.local_participant.publish_data(
payload, reliable=True, topic=_CHAT_TOPIC
)
except Exception:
logger.debug("failed to send chat message")
async def _send_status(self, status: str) -> None:
if self._room is None:
return
payload = json.dumps({"status": status}).encode()
try:
await self._room.local_participant.publish_data(
payload, reliable=True, topic=_STATUS_TOPIC
)
except Exception:
logger.debug("failed to send status")
async def _send_cursor_position(self, x: int, y: int, action: str) -> None:
if self._room is None:
return
payload = json.dumps(
{
"x": x,
"y": y,
"action": action,
"visible": True,
"width": self._width,
"height": self._height,
}
).encode()
try:
await self._room.local_participant.publish_data(
payload, reliable=True, topic=_CURSOR_TOPIC
)
except Exception:
logger.debug("failed to send cursor position")
async def _send_cursor_hide(self) -> None:
if self._room is None:
return
payload = json.dumps({"visible": False}).encode()
try:
await self._room.local_participant.publish_data(
payload, reliable=True, topic=_CURSOR_TOPIC
)
except Exception:
pass
async def aclose(self) -> None:
if self._agent_loop_task:
self._agent_loop_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._agent_loop_task
if self._computer_tool:
await self._computer_tool.aclose()
if self._session:
await self._session.aclose()
if self._page:
await self._page.aclose()
if self._browser_ctx:
await self._browser_ctx.aclose()
if self._room and hasattr(self, "_on_chat_data"):
self._room.off("data_received", self._on_chat_data)
def _screenshot_content(actions: PageActions) -> list[dict[str, Any]]:
from livekit.agents.utils.images import EncodeOptions, encode
frame = actions.last_frame
if frame is None:
return [{"type": "text", "text": "(no frame available yet)"}]
png_bytes = encode(frame, EncodeOptions(format="PNG"))
b64 = base64.b64encode(png_bytes).decode("utf-8")
return [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": b64,
},
}
]