Files
browser-use/browser_use/agent/message_manager/utils.py
2025-06-13 17:02:29 -07:00

155 lines
5.3 KiB
Python

from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
import anyio
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
logger = logging.getLogger(__name__)
MODELS_WITHOUT_TOOL_SUPPORT_PATTERNS = [
'deepseek-reasoner',
'deepseek-r1',
'.*gemma.*-it',
]
def is_model_without_tool_support(model_name: str) -> bool:
return any(re.match(pattern, model_name) for pattern in MODELS_WITHOUT_TOOL_SUPPORT_PATTERNS)
def extract_json_from_model_output(content: str) -> dict:
"""Extract JSON from model output, handling both plain JSON and code-block-wrapped JSON."""
try:
# If content is wrapped in code blocks, extract just the JSON part
if '```' in content:
# Find the JSON content between code blocks
content = content.split('```')[1]
# Remove language identifier if present (e.g., 'json\n')
if '\n' in content:
content = content.split('\n', 1)[1]
# Parse the cleaned content
result_dict = json.loads(content)
# some models occasionally respond with a list containing one dict: https://github.com/browser-use/browser-use/issues/1458
if isinstance(result_dict, list) and len(result_dict) == 1 and isinstance(result_dict[0], dict):
result_dict = result_dict[0]
assert isinstance(result_dict, dict), f'Expected JSON dictionary in response, got JSON {type(result_dict)} instead'
return result_dict
except json.JSONDecodeError as e:
logger.warning(f'Failed to parse model output: {content} {str(e)}')
raise ValueError('Could not parse response.')
def convert_input_messages(input_messages: list[BaseMessage], model_name: str | None) -> list[BaseMessage]:
"""Convert input messages to a format that is compatible with the planner model"""
if model_name is None:
return input_messages
# TODO: use the auto-detected tool calling method from Agent._set_tool_calling_method(),
# or abstract that logic out to reuse so we can autodetect the planner model's tool calling method as well
if is_model_without_tool_support(model_name):
converted_input_messages = _convert_messages_for_non_function_calling_models(input_messages)
merged_input_messages = _merge_successive_messages(converted_input_messages, HumanMessage)
merged_input_messages = _merge_successive_messages(merged_input_messages, AIMessage)
return merged_input_messages
return input_messages
def _convert_messages_for_non_function_calling_models(input_messages: list[BaseMessage]) -> list[BaseMessage]:
"""Convert messages for non-function-calling models"""
output_messages = []
for message in input_messages:
if isinstance(message, HumanMessage):
output_messages.append(message)
elif isinstance(message, SystemMessage):
output_messages.append(message)
elif isinstance(message, ToolMessage):
output_messages.append(HumanMessage(content=message.content))
elif isinstance(message, AIMessage):
# check if tool_calls is a valid JSON object
if message.tool_calls:
tool_calls = json.dumps(message.tool_calls)
output_messages.append(AIMessage(content=tool_calls))
else:
output_messages.append(message)
else:
raise ValueError(f'Unknown message type: {type(message)}')
return output_messages
def _merge_successive_messages(messages: list[BaseMessage], class_to_merge: type[BaseMessage]) -> list[BaseMessage]:
"""Some models like deepseek-reasoner dont allow multiple human messages in a row. This function merges them into one."""
merged_messages = []
streak = 0
for message in messages:
if isinstance(message, class_to_merge):
streak += 1
if streak > 1:
if isinstance(message.content, list):
merged_messages[-1].content += message.content[0]['text'] # type:ignore
else:
merged_messages[-1].content += message.content
else:
merged_messages.append(message)
else:
merged_messages.append(message)
streak = 0
return merged_messages
async def save_conversation(
input_messages: list[BaseMessage], response: Any, target: str | Path, encoding: str | None = None
) -> None:
"""Save conversation history to file asynchronously."""
target_path = Path(target)
# create folders if not exists
if target_path.parent:
await anyio.Path(target_path.parent).mkdir(parents=True, exist_ok=True)
await anyio.Path(target_path).write_text(await _format_conversation(input_messages, response), encoding=encoding or 'utf-8')
async def _format_conversation(messages: list[BaseMessage], response: Any) -> str:
"""Format the conversation including messages and response."""
lines = []
# Format messages
for message in messages:
lines.append(f' {message.__class__.__name__} ')
if isinstance(message.content, list):
for item in message.content:
if isinstance(item, dict) and item.get('type') == 'text':
lines.append(item['text'].strip())
elif isinstance(message.content, str):
try:
content = json.loads(message.content)
lines.append(json.dumps(content, indent=2))
except json.JSONDecodeError:
lines.append(message.content.strip())
lines.append('') # Empty line after each message
# Format response
lines.append(' RESPONSE')
lines.append(json.dumps(json.loads(response.model_dump_json(exclude_unset=True)), indent=2))
return '\n'.join(lines)
# Note: _write_messages_to_file and _write_response_to_file have been merged into _format_conversation
# This is more efficient for async operations and reduces file I/O