Merge branch 'main' of https://github.com/a-holm/OpenManus into fix-for-search-rate-limits
This commit is contained in:
commit
59a92257be
14
.github/ISSUE_TEMPLATE/request_new_features.md
vendored
14
.github/ISSUE_TEMPLATE/request_new_features.md
vendored
@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
name: "🤔 Request new features"
|
|
||||||
about: Suggest ideas or features you’d like to see implemented in OpenManus.
|
|
||||||
title: ''
|
|
||||||
labels: kind/features
|
|
||||||
assignees: ''
|
|
||||||
---
|
|
||||||
|
|
||||||
**Feature description**
|
|
||||||
<!-- Provide a clear and concise description of the proposed feature -->
|
|
||||||
|
|
||||||
**Your Feature**
|
|
||||||
<!-- Explain your idea or implementation process. Optionally, include a Pull Request URL. -->
|
|
||||||
<!-- Ensure accompanying docs/tests/examples are provided for review. -->
|
|
21
.github/ISSUE_TEMPLATE/request_new_features.yaml
vendored
Normal file
21
.github/ISSUE_TEMPLATE/request_new_features.yaml
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
name: "🤔 Request new features"
|
||||||
|
description: Suggest ideas or features you’d like to see implemented in OpenManus.
|
||||||
|
labels: enhancement
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
attributes:
|
||||||
|
label: Feature description
|
||||||
|
description: |
|
||||||
|
Provide a clear and concise description of the proposed feature
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: your-feature
|
||||||
|
attributes:
|
||||||
|
label: Your Feature
|
||||||
|
description: |
|
||||||
|
Explain your idea or implementation process, if any. Optionally, include a Pull Request URL.
|
||||||
|
Ensure accompanying docs/tests/examples are provided for review.
|
||||||
|
validations:
|
||||||
|
required: false
|
25
.github/ISSUE_TEMPLATE/show_me_the_bug.md
vendored
25
.github/ISSUE_TEMPLATE/show_me_the_bug.md
vendored
@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
name: "🪲 Show me the Bug"
|
|
||||||
about: Report a bug encountered while using OpenManus and seek assistance.
|
|
||||||
title: ''
|
|
||||||
labels: kind/bug
|
|
||||||
assignees: ''
|
|
||||||
---
|
|
||||||
|
|
||||||
**Bug description**
|
|
||||||
<!-- Clearly describe the bug you encountered -->
|
|
||||||
|
|
||||||
**Bug solved method**
|
|
||||||
<!-- If resolved, explain the solution. Optionally, include a Pull Request URL. -->
|
|
||||||
<!-- If unresolved, provide additional details to aid investigation -->
|
|
||||||
|
|
||||||
**Environment information**
|
|
||||||
<!-- System: e.g., Ubuntu 22.04, Python: e.g., 3.12, OpenManus version: e.g., 0.1.0 -->
|
|
||||||
|
|
||||||
- System version:
|
|
||||||
- Python version:
|
|
||||||
- OpenManus version or branch:
|
|
||||||
- Installation method (e.g., `pip install -r requirements.txt` or `pip install -e .`):
|
|
||||||
|
|
||||||
**Screenshots or logs**
|
|
||||||
<!-- Attach screenshots or logs to help diagnose the issue -->
|
|
44
.github/ISSUE_TEMPLATE/show_me_the_bug.yaml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/show_me_the_bug.yaml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: "🪲 Show me the Bug"
|
||||||
|
description: Report a bug encountered while using OpenManus and seek assistance.
|
||||||
|
labels: bug
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: bug-description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: |
|
||||||
|
Clearly describe the bug you encountered
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solve-method
|
||||||
|
attributes:
|
||||||
|
label: Bug solved method
|
||||||
|
description: |
|
||||||
|
If resolved, explain the solution. Optionally, include a Pull Request URL.
|
||||||
|
If unresolved, provide additional details to aid investigation
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: environment-information
|
||||||
|
attributes:
|
||||||
|
label: Environment information
|
||||||
|
description: |
|
||||||
|
System: e.g., Ubuntu 22.04
|
||||||
|
Python: e.g., 3.12
|
||||||
|
OpenManus version: e.g., 0.1.0
|
||||||
|
value: |
|
||||||
|
- System version:
|
||||||
|
- Python version:
|
||||||
|
- OpenManus version or branch:
|
||||||
|
- Installation method (e.g., `pip install -r requirements.txt` or `pip install -e .`):
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: extra-information
|
||||||
|
attributes:
|
||||||
|
label: Extra information
|
||||||
|
description: |
|
||||||
|
For example, attach screenshots or logs to help diagnose the issue
|
||||||
|
validations:
|
||||||
|
required: false
|
31
.github/workflows/pr-autodiff.yaml
vendored
31
.github/workflows/pr-autodiff.yaml
vendored
@ -15,21 +15,20 @@ jobs:
|
|||||||
(github.event_name == 'pull_request') ||
|
(github.event_name == 'pull_request') ||
|
||||||
(github.event_name == 'issue_comment' &&
|
(github.event_name == 'issue_comment' &&
|
||||||
contains(github.event.comment.body, '!pr-diff') &&
|
contains(github.event.comment.body, '!pr-diff') &&
|
||||||
(github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') &&
|
(github.event.comment.author_association == 'CONTRIBUTOR' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') &&
|
||||||
github.event.issue.pull_request)
|
github.event.issue.pull_request)
|
||||||
steps:
|
steps:
|
||||||
- name: Get PR head SHA
|
- name: Get PR head SHA
|
||||||
id: get-pr-sha
|
id: get-pr-sha
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
PR_URL="${{ github.event.issue.pull_request.url || github.event.pull_request.url }}"
|
||||||
echo "pr_sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
# https://api.github.com/repos/OpenManus/pulls/1
|
||||||
echo "Retrieved PR head SHA: ${{ github.event.pull_request.head.sha }}"
|
RESPONSE=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL)
|
||||||
else
|
SHA=$(echo $RESPONSE | jq -r '.head.sha')
|
||||||
PR_URL="${{ github.event.issue.pull_request.url }}"
|
TARGET_BRANCH=$(echo $RESPONSE | jq -r '.base.ref')
|
||||||
SHA=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL | jq -r '.head.sha')
|
|
||||||
echo "pr_sha=$SHA" >> $GITHUB_OUTPUT
|
echo "pr_sha=$SHA" >> $GITHUB_OUTPUT
|
||||||
echo "Retrieved PR head SHA from API: $SHA"
|
echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT
|
||||||
fi
|
echo "Retrieved PR head SHA from API: $SHA, target branch: $TARGET_BRANCH"
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@ -49,6 +48,7 @@ jobs:
|
|||||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||||
|
TARGET_BRANCH: ${{ steps.get-pr-sha.outputs.target_branch }}
|
||||||
run: |-
|
run: |-
|
||||||
cat << 'EOF' > /tmp/_workflow_core.py
|
cat << 'EOF' > /tmp/_workflow_core.py
|
||||||
import os
|
import os
|
||||||
@ -59,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
def get_diff():
|
def get_diff():
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['git', 'diff', 'origin/main...HEAD'],
|
['git', 'diff', 'origin/' + os.getenv('TARGET_BRANCH') + '...HEAD'],
|
||||||
capture_output=True, text=True, check=True)
|
capture_output=True, text=True, check=True)
|
||||||
return '\n'.join(
|
return '\n'.join(
|
||||||
line for line in result.stdout.split('\n')
|
line for line in result.stdout.split('\n')
|
||||||
@ -86,6 +86,17 @@ jobs:
|
|||||||
|
|
||||||
### Spelling/Offensive Content Check
|
### Spelling/Offensive Content Check
|
||||||
- No spelling mistakes or offensive content found in the code or comments.
|
- No spelling mistakes or offensive content found in the code or comments.
|
||||||
|
|
||||||
|
## 中文(简体)
|
||||||
|
- 新增了 `ABC` 类
|
||||||
|
- `foo` 模块中的 `f()` 行为已修复
|
||||||
|
|
||||||
|
### 评论高亮
|
||||||
|
- `config.toml` 需要正确配置才能确保新功能正常运行。
|
||||||
|
|
||||||
|
### 内容检查
|
||||||
|
- 没有发现代码或注释中的拼写错误或不当措辞。
|
||||||
|
|
||||||
3. Highlight non-English comments
|
3. Highlight non-English comments
|
||||||
4. Check for spelling/offensive content'''
|
4. Check for spelling/offensive content'''
|
||||||
|
|
||||||
|
@ -71,40 +71,42 @@ class ToolCallAgent(ReActAgent):
|
|||||||
return False
|
return False
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self.tool_calls = response.tool_calls
|
self.tool_calls = tool_calls = (
|
||||||
|
response.tool_calls if response and response.tool_calls else []
|
||||||
|
)
|
||||||
|
content = response.content if response and response.content else ""
|
||||||
|
|
||||||
# Log response info
|
# Log response info
|
||||||
logger.info(f"✨ {self.name}'s thoughts: {response.content}")
|
logger.info(f"✨ {self.name}'s thoughts: {content}")
|
||||||
logger.info(
|
logger.info(
|
||||||
f"🛠️ {self.name} selected {len(response.tool_calls) if response.tool_calls else 0} tools to use"
|
f"🛠️ {self.name} selected {len(tool_calls) if tool_calls else 0} tools to use"
|
||||||
)
|
)
|
||||||
if response.tool_calls:
|
if tool_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"🧰 Tools being prepared: {[call.function.name for call in response.tool_calls]}"
|
f"🧰 Tools being prepared: {[call.function.name for call in tool_calls]}"
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"🔧 Tool arguments: {response.tool_calls[0].function.arguments}"
|
|
||||||
)
|
)
|
||||||
|
logger.info(f"🔧 Tool arguments: {tool_calls[0].function.arguments}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if response is None:
|
||||||
|
raise RuntimeError("No response received from the LLM")
|
||||||
|
|
||||||
# Handle different tool_choices modes
|
# Handle different tool_choices modes
|
||||||
if self.tool_choices == ToolChoice.NONE:
|
if self.tool_choices == ToolChoice.NONE:
|
||||||
if response.tool_calls:
|
if tool_calls:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"🤔 Hmm, {self.name} tried to use tools when they weren't available!"
|
f"🤔 Hmm, {self.name} tried to use tools when they weren't available!"
|
||||||
)
|
)
|
||||||
if response.content:
|
if content:
|
||||||
self.memory.add_message(Message.assistant_message(response.content))
|
self.memory.add_message(Message.assistant_message(content))
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create and add assistant message
|
# Create and add assistant message
|
||||||
assistant_msg = (
|
assistant_msg = (
|
||||||
Message.from_tool_calls(
|
Message.from_tool_calls(content=content, tool_calls=self.tool_calls)
|
||||||
content=response.content, tool_calls=self.tool_calls
|
|
||||||
)
|
|
||||||
if self.tool_calls
|
if self.tool_calls
|
||||||
else Message.assistant_message(response.content)
|
else Message.assistant_message(content)
|
||||||
)
|
)
|
||||||
self.memory.add_message(assistant_msg)
|
self.memory.add_message(assistant_msg)
|
||||||
|
|
||||||
@ -113,7 +115,7 @@ class ToolCallAgent(ReActAgent):
|
|||||||
|
|
||||||
# For 'auto' mode, continue with content if no commands but content exists
|
# For 'auto' mode, continue with content if no commands but content exists
|
||||||
if self.tool_choices == ToolChoice.AUTO and not self.tool_calls:
|
if self.tool_choices == ToolChoice.AUTO and not self.tool_calls:
|
||||||
return bool(response.content)
|
return bool(content)
|
||||||
|
|
||||||
return bool(self.tool_calls)
|
return bool(self.tool_calls)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -209,7 +211,7 @@ class ToolCallAgent(ReActAgent):
|
|||||||
return f"Error: {error_msg}"
|
return f"Error: {error_msg}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"⚠️ Tool '{name}' encountered a problem: {str(e)}"
|
error_msg = f"⚠️ Tool '{name}' encountered a problem: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.exception(error_msg)
|
||||||
return f"Error: {error_msg}"
|
return f"Error: {error_msg}"
|
||||||
|
|
||||||
async def _handle_special_tool(self, name: str, result: Any, **kwargs):
|
async def _handle_special_tool(self, name: str, result: Any, **kwargs):
|
||||||
|
@ -25,7 +25,7 @@ class LLMSettings(BaseModel):
|
|||||||
description="Maximum input tokens to use across all requests (None for unlimited)",
|
description="Maximum input tokens to use across all requests (None for unlimited)",
|
||||||
)
|
)
|
||||||
temperature: float = Field(1.0, description="Sampling temperature")
|
temperature: float = Field(1.0, description="Sampling temperature")
|
||||||
api_type: str = Field(..., description="AzureOpenai or Openai")
|
api_type: str = Field(..., description="Azure, Openai, or Ollama")
|
||||||
api_version: str = Field(..., description="Azure Openai version if AzureOpenai")
|
api_version: str = Field(..., description="Azure Openai version if AzureOpenai")
|
||||||
|
|
||||||
|
|
||||||
|
110
app/llm.py
110
app/llm.py
@ -10,6 +10,7 @@ from openai import (
|
|||||||
OpenAIError,
|
OpenAIError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
)
|
)
|
||||||
|
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||||
from tenacity import (
|
from tenacity import (
|
||||||
retry,
|
retry,
|
||||||
retry_if_exception_type,
|
retry_if_exception_type,
|
||||||
@ -30,6 +31,14 @@ from app.schema import (
|
|||||||
|
|
||||||
|
|
||||||
REASONING_MODELS = ["o1", "o3-mini"]
|
REASONING_MODELS = ["o1", "o3-mini"]
|
||||||
|
MULTIMODAL_MODELS = [
|
||||||
|
"gpt-4-vision-preview",
|
||||||
|
"gpt-4o",
|
||||||
|
"gpt-4o-mini",
|
||||||
|
"claude-3-opus-20240229",
|
||||||
|
"claude-3-sonnet-20240229",
|
||||||
|
"claude-3-haiku-20240307",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TokenCounter:
|
class TokenCounter:
|
||||||
@ -259,12 +268,15 @@ class LLM:
|
|||||||
return "Token limit exceeded"
|
return "Token limit exceeded"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_messages(messages: List[Union[dict, Message]]) -> List[dict]:
|
def format_messages(
|
||||||
|
messages: List[Union[dict, Message]], supports_images: bool = False
|
||||||
|
) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Format messages for LLM by converting them to OpenAI message format.
|
Format messages for LLM by converting them to OpenAI message format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: List of messages that can be either dict or Message objects
|
messages: List of messages that can be either dict or Message objects
|
||||||
|
supports_images: Flag indicating if the target model supports image inputs
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[dict]: List of formatted messages in OpenAI format
|
List[dict]: List of formatted messages in OpenAI format
|
||||||
@ -288,20 +300,20 @@ class LLM:
|
|||||||
if isinstance(message, Message):
|
if isinstance(message, Message):
|
||||||
message = message.to_dict()
|
message = message.to_dict()
|
||||||
|
|
||||||
if not isinstance(message, dict):
|
if isinstance(message, dict):
|
||||||
raise TypeError(f"Unsupported message type: {type(message)}")
|
# If message is a dict, ensure it has required fields
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if "role" not in message:
|
if "role" not in message:
|
||||||
raise ValueError("Message dict must contain 'role' field")
|
raise ValueError("Message dict must contain 'role' field")
|
||||||
|
|
||||||
# Process base64 images if present
|
# Process base64 images if present and model supports images
|
||||||
if message.get("base64_image"):
|
if supports_images and message.get("base64_image"):
|
||||||
# Initialize or convert content to appropriate format
|
# Initialize or convert content to appropriate format
|
||||||
if not message.get("content"):
|
if not message.get("content"):
|
||||||
message["content"] = []
|
message["content"] = []
|
||||||
elif isinstance(message["content"], str):
|
elif isinstance(message["content"], str):
|
||||||
message["content"] = [{"type": "text", "text": message["content"]}]
|
message["content"] = [
|
||||||
|
{"type": "text", "text": message["content"]}
|
||||||
|
]
|
||||||
elif isinstance(message["content"], list):
|
elif isinstance(message["content"], list):
|
||||||
# Convert string items to proper text objects
|
# Convert string items to proper text objects
|
||||||
message["content"] = [
|
message["content"] = [
|
||||||
@ -325,17 +337,21 @@ class LLM:
|
|||||||
|
|
||||||
# Remove the base64_image field
|
# Remove the base64_image field
|
||||||
del message["base64_image"]
|
del message["base64_image"]
|
||||||
|
# If model doesn't support images but message has base64_image, handle gracefully
|
||||||
|
elif not supports_images and message.get("base64_image"):
|
||||||
|
# Just remove the base64_image field and keep the text content
|
||||||
|
del message["base64_image"]
|
||||||
|
|
||||||
# Only include messages with content or tool_calls
|
|
||||||
if "content" in message or "tool_calls" in message:
|
if "content" in message or "tool_calls" in message:
|
||||||
formatted_messages.append(message)
|
formatted_messages.append(message)
|
||||||
|
# else: do not include the message
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Unsupported message type: {type(message)}")
|
||||||
|
|
||||||
# Validate all roles
|
# Validate all messages have required fields
|
||||||
invalid_roles = [
|
for msg in formatted_messages:
|
||||||
msg for msg in formatted_messages if msg["role"] not in ROLE_VALUES
|
if msg["role"] not in ROLE_VALUES:
|
||||||
]
|
raise ValueError(f"Invalid role: {msg['role']}")
|
||||||
if invalid_roles:
|
|
||||||
raise ValueError(f"Invalid role: {invalid_roles[0]['role']}")
|
|
||||||
|
|
||||||
return formatted_messages
|
return formatted_messages
|
||||||
|
|
||||||
@ -372,12 +388,15 @@ class LLM:
|
|||||||
Exception: For unexpected errors
|
Exception: For unexpected errors
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Format system and user messages
|
# Check if the model supports images
|
||||||
|
supports_images = self.model in MULTIMODAL_MODELS
|
||||||
|
|
||||||
|
# Format system and user messages with image support check
|
||||||
if system_msgs:
|
if system_msgs:
|
||||||
system_msgs = self.format_messages(system_msgs)
|
system_msgs = self.format_messages(system_msgs, supports_images)
|
||||||
messages = system_msgs + self.format_messages(messages)
|
messages = system_msgs + self.format_messages(messages, supports_images)
|
||||||
else:
|
else:
|
||||||
messages = self.format_messages(messages)
|
messages = self.format_messages(messages, supports_images)
|
||||||
|
|
||||||
# Calculate input token count
|
# Calculate input token count
|
||||||
input_tokens = self.count_message_tokens(messages)
|
input_tokens = self.count_message_tokens(messages)
|
||||||
@ -403,9 +422,9 @@ class LLM:
|
|||||||
|
|
||||||
if not stream:
|
if not stream:
|
||||||
# Non-streaming request
|
# Non-streaming request
|
||||||
params["stream"] = False
|
response = await self.client.chat.completions.create(
|
||||||
|
**params, stream=False
|
||||||
response = await self.client.chat.completions.create(**params)
|
)
|
||||||
|
|
||||||
if not response.choices or not response.choices[0].message.content:
|
if not response.choices or not response.choices[0].message.content:
|
||||||
raise ValueError("Empty or invalid response from LLM")
|
raise ValueError("Empty or invalid response from LLM")
|
||||||
@ -420,8 +439,7 @@ class LLM:
|
|||||||
# Streaming request, For streaming, update estimated token count before making the request
|
# Streaming request, For streaming, update estimated token count before making the request
|
||||||
self.update_token_count(input_tokens)
|
self.update_token_count(input_tokens)
|
||||||
|
|
||||||
params["stream"] = True
|
response = await self.client.chat.completions.create(**params, stream=True)
|
||||||
response = await self.client.chat.completions.create(**params)
|
|
||||||
|
|
||||||
collected_messages = []
|
collected_messages = []
|
||||||
completion_text = ""
|
completion_text = ""
|
||||||
@ -448,11 +466,11 @@ class LLM:
|
|||||||
except TokenLimitExceeded:
|
except TokenLimitExceeded:
|
||||||
# Re-raise token limit errors without logging
|
# Re-raise token limit errors without logging
|
||||||
raise
|
raise
|
||||||
except ValueError as ve:
|
except ValueError:
|
||||||
logger.error(f"Validation error: {ve}")
|
logger.exception(f"Validation error")
|
||||||
raise
|
raise
|
||||||
except OpenAIError as oe:
|
except OpenAIError as oe:
|
||||||
logger.error(f"OpenAI API error: {oe}")
|
logger.exception(f"OpenAI API error")
|
||||||
if isinstance(oe, AuthenticationError):
|
if isinstance(oe, AuthenticationError):
|
||||||
logger.error("Authentication failed. Check API key.")
|
logger.error("Authentication failed. Check API key.")
|
||||||
elif isinstance(oe, RateLimitError):
|
elif isinstance(oe, RateLimitError):
|
||||||
@ -460,8 +478,8 @@ class LLM:
|
|||||||
elif isinstance(oe, APIError):
|
elif isinstance(oe, APIError):
|
||||||
logger.error(f"API error: {oe}")
|
logger.error(f"API error: {oe}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Unexpected error in ask: {e}")
|
logger.exception(f"Unexpected error in ask")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@retry(
|
@retry(
|
||||||
@ -499,8 +517,15 @@ class LLM:
|
|||||||
Exception: For unexpected errors
|
Exception: For unexpected errors
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Format messages
|
# For ask_with_images, we always set supports_images to True because
|
||||||
formatted_messages = self.format_messages(messages)
|
# this method should only be called with models that support images
|
||||||
|
if self.model not in MULTIMODAL_MODELS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Model {self.model} does not support images. Use a model from {MULTIMODAL_MODELS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format messages with image support
|
||||||
|
formatted_messages = self.format_messages(messages, supports_images=True)
|
||||||
|
|
||||||
# Ensure the last message is from the user to attach images
|
# Ensure the last message is from the user to attach images
|
||||||
if not formatted_messages or formatted_messages[-1]["role"] != "user":
|
if not formatted_messages or formatted_messages[-1]["role"] != "user":
|
||||||
@ -539,7 +564,10 @@ class LLM:
|
|||||||
|
|
||||||
# Add system messages if provided
|
# Add system messages if provided
|
||||||
if system_msgs:
|
if system_msgs:
|
||||||
all_messages = self.format_messages(system_msgs) + formatted_messages
|
all_messages = (
|
||||||
|
self.format_messages(system_msgs, supports_images=True)
|
||||||
|
+ formatted_messages
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
all_messages = formatted_messages
|
all_messages = formatted_messages
|
||||||
|
|
||||||
@ -626,7 +654,7 @@ class LLM:
|
|||||||
tool_choice: TOOL_CHOICE_TYPE = ToolChoice.AUTO, # type: ignore
|
tool_choice: TOOL_CHOICE_TYPE = ToolChoice.AUTO, # type: ignore
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
) -> ChatCompletionMessage | None:
|
||||||
"""
|
"""
|
||||||
Ask LLM using functions/tools and return the response.
|
Ask LLM using functions/tools and return the response.
|
||||||
|
|
||||||
@ -653,12 +681,15 @@ class LLM:
|
|||||||
if tool_choice not in TOOL_CHOICE_VALUES:
|
if tool_choice not in TOOL_CHOICE_VALUES:
|
||||||
raise ValueError(f"Invalid tool_choice: {tool_choice}")
|
raise ValueError(f"Invalid tool_choice: {tool_choice}")
|
||||||
|
|
||||||
|
# Check if the model supports images
|
||||||
|
supports_images = self.model in MULTIMODAL_MODELS
|
||||||
|
|
||||||
# Format messages
|
# Format messages
|
||||||
if system_msgs:
|
if system_msgs:
|
||||||
system_msgs = self.format_messages(system_msgs)
|
system_msgs = self.format_messages(system_msgs, supports_images)
|
||||||
messages = system_msgs + self.format_messages(messages)
|
messages = system_msgs + self.format_messages(messages, supports_images)
|
||||||
else:
|
else:
|
||||||
messages = self.format_messages(messages)
|
messages = self.format_messages(messages, supports_images)
|
||||||
|
|
||||||
# Calculate input token count
|
# Calculate input token count
|
||||||
input_tokens = self.count_message_tokens(messages)
|
input_tokens = self.count_message_tokens(messages)
|
||||||
@ -701,12 +732,15 @@ class LLM:
|
|||||||
temperature if temperature is not None else self.temperature
|
temperature if temperature is not None else self.temperature
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await self.client.chat.completions.create(**params)
|
response: ChatCompletion = await self.client.chat.completions.create(
|
||||||
|
**params, stream=False
|
||||||
|
)
|
||||||
|
|
||||||
# Check if response is valid
|
# Check if response is valid
|
||||||
if not response.choices or not response.choices[0].message:
|
if not response.choices or not response.choices[0].message:
|
||||||
print(response)
|
print(response)
|
||||||
raise ValueError("Invalid or empty response from LLM")
|
# raise ValueError("Invalid or empty response from LLM")
|
||||||
|
return None
|
||||||
|
|
||||||
# Update token counts
|
# Update token counts
|
||||||
self.update_token_count(
|
self.update_token_count(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from typing import Generic, Optional, TypeVar
|
from typing import Generic, Optional, TypeVar
|
||||||
|
|
||||||
@ -418,17 +419,7 @@ class BrowserUseTool(BaseTool, Generic[Context]):
|
|||||||
|
|
||||||
# Create prompt for LLM
|
# Create prompt for LLM
|
||||||
prompt_text = """
|
prompt_text = """
|
||||||
Your task is to extract the content of the page. You will be given a page and a goal, and you should extract all relevant information around this goal from the page.
|
Your task is to extract the content of the page. You will be given a page and a goal, and you should extract all relevant information around this goal from the page. If the goal is vague, summarize the page. Respond in json format.
|
||||||
|
|
||||||
Examples of extraction goals:
|
|
||||||
- Extract all company names
|
|
||||||
- Extract specific descriptions
|
|
||||||
- Extract all information about a topic
|
|
||||||
- Extract links with companies in structured format
|
|
||||||
- Extract all links
|
|
||||||
|
|
||||||
If the goal is vague, summarize the page. Respond in JSON format.
|
|
||||||
|
|
||||||
Extraction goal: {goal}
|
Extraction goal: {goal}
|
||||||
|
|
||||||
Page content:
|
Page content:
|
||||||
@ -445,10 +436,54 @@ Page content:
|
|||||||
|
|
||||||
messages = [Message.user_message(formatted_prompt)]
|
messages = [Message.user_message(formatted_prompt)]
|
||||||
|
|
||||||
# Use LLM to extract content based on the goal
|
# Define extraction function for the tool
|
||||||
response = await self.llm.ask(messages)
|
extraction_function = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "extract_content",
|
||||||
|
"description": "Extract specific information from a webpage based on a goal",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"extracted_content": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The content extracted from the page according to the goal",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["extracted_content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use LLM to extract content with required function calling
|
||||||
|
response = await self.llm.ask_tool(
|
||||||
|
messages,
|
||||||
|
tools=[extraction_function],
|
||||||
|
tool_choice="required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract content from function call response
|
||||||
|
if (
|
||||||
|
response
|
||||||
|
and response.tool_calls
|
||||||
|
and len(response.tool_calls) > 0
|
||||||
|
):
|
||||||
|
# Get the first tool call arguments
|
||||||
|
tool_call = response.tool_calls[0]
|
||||||
|
# Parse the JSON arguments
|
||||||
|
try:
|
||||||
|
args = json.loads(tool_call.function.arguments)
|
||||||
|
extracted_content = args.get("extracted_content", {})
|
||||||
|
# Format extracted content as JSON string
|
||||||
|
content_json = json.dumps(
|
||||||
|
extracted_content, indent=2, ensure_ascii=False
|
||||||
|
)
|
||||||
|
msg = f"Extracted from page:\n{content_json}\n"
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Error parsing extraction result: {str(e)}\nRaw response: {tool_call.function.arguments}"
|
||||||
|
else:
|
||||||
|
msg = "No content was extracted from the page."
|
||||||
|
|
||||||
msg = f"Extracted from page:\n{response}\n"
|
|
||||||
return ToolResult(output=msg)
|
return ToolResult(output=msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Provide a more helpful error message
|
# Provide a more helpful error message
|
||||||
@ -518,7 +553,16 @@ Page content:
|
|||||||
viewport_height = ctx.config.browser_window_size.get("height", 0)
|
viewport_height = ctx.config.browser_window_size.get("height", 0)
|
||||||
|
|
||||||
# Take a screenshot for the state
|
# Take a screenshot for the state
|
||||||
screenshot = await ctx.take_screenshot(full_page=True)
|
page = await ctx.get_current_page()
|
||||||
|
|
||||||
|
await page.bring_to_front()
|
||||||
|
await page.wait_for_load_state()
|
||||||
|
|
||||||
|
screenshot = await page.screenshot(
|
||||||
|
full_page=True, animations="disabled", type="jpeg", quality=100
|
||||||
|
)
|
||||||
|
|
||||||
|
screenshot = base64.b64encode(screenshot).decode("utf-8")
|
||||||
|
|
||||||
# Build the state info with all required fields
|
# Build the state info with all required fields
|
||||||
state_info = {
|
state_info = {
|
||||||
|
@ -42,17 +42,19 @@ class FileOperator(Protocol):
|
|||||||
class LocalFileOperator(FileOperator):
|
class LocalFileOperator(FileOperator):
|
||||||
"""File operations implementation for local filesystem."""
|
"""File operations implementation for local filesystem."""
|
||||||
|
|
||||||
|
encoding: str = "utf-8"
|
||||||
|
|
||||||
async def read_file(self, path: PathLike) -> str:
|
async def read_file(self, path: PathLike) -> str:
|
||||||
"""Read content from a local file."""
|
"""Read content from a local file."""
|
||||||
try:
|
try:
|
||||||
return Path(path).read_text()
|
return Path(path).read_text(encoding=self.encoding)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ToolError(f"Failed to read {path}: {str(e)}") from None
|
raise ToolError(f"Failed to read {path}: {str(e)}") from None
|
||||||
|
|
||||||
async def write_file(self, path: PathLike, content: str) -> None:
|
async def write_file(self, path: PathLike, content: str) -> None:
|
||||||
"""Write content to a local file."""
|
"""Write content to a local file."""
|
||||||
try:
|
try:
|
||||||
Path(path).write_text(content)
|
Path(path).write_text(content, encoding=self.encoding)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ToolError(f"Failed to write to {path}: {str(e)}") from None
|
raise ToolError(f"Failed to write to {path}: {str(e)}") from None
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from app.tool.search.baidu_search import BaiduSearchEngine
|
from app.tool.search.baidu_search import BaiduSearchEngine
|
||||||
from app.tool.search.base import WebSearchEngine
|
from app.tool.search.base import WebSearchEngine
|
||||||
|
from app.tool.search.bing_search import BingSearchEngine
|
||||||
from app.tool.search.duckduckgo_search import DuckDuckGoSearchEngine
|
from app.tool.search.duckduckgo_search import DuckDuckGoSearchEngine
|
||||||
from app.tool.search.google_search import GoogleSearchEngine
|
from app.tool.search.google_search import GoogleSearchEngine
|
||||||
|
|
||||||
@ -9,4 +10,5 @@ __all__ = [
|
|||||||
"BaiduSearchEngine",
|
"BaiduSearchEngine",
|
||||||
"DuckDuckGoSearchEngine",
|
"DuckDuckGoSearchEngine",
|
||||||
"GoogleSearchEngine",
|
"GoogleSearchEngine",
|
||||||
|
"BingSearchEngine",
|
||||||
]
|
]
|
||||||
|
146
app/tool/search/bing_search.py
Normal file
146
app/tool/search/bing_search.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from app.logger import logger
|
||||||
|
from app.tool.search.base import WebSearchEngine
|
||||||
|
|
||||||
|
|
||||||
|
ABSTRACT_MAX_LENGTH = 300
|
||||||
|
|
||||||
|
USER_AGENTS = [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/49.0.2623.108 Chrome/49.0.2623.108 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows; U; Windows NT 5.1; pt-BR) AppleWebKit/533.3 (KHTML, like Gecko) QtWeb Internet Browser/3.7 http://www.QtWeb.net",
|
||||||
|
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.2 (KHTML, like Gecko) ChromePlus/4.0.222.3 Chrome/4.0.222.3 Safari/532.2",
|
||||||
|
"Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4pre) Gecko/20070404 K-Ninja/2.1.3",
|
||||||
|
"Mozilla/5.0 (Future Star Technologies Corp.; Star-Blade OS; x86_64; U; en-US) iNet Browser 4.7",
|
||||||
|
"Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201",
|
||||||
|
"Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080414 Firefox/2.0.0.13 Pogo/2.0.0.13.6866",
|
||||||
|
]
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": USER_AGENTS[0],
|
||||||
|
"Referer": "https://www.bing.com/",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
BING_HOST_URL = "https://www.bing.com"
|
||||||
|
BING_SEARCH_URL = "https://www.bing.com/search?q="
|
||||||
|
|
||||||
|
|
||||||
|
class BingSearchEngine(WebSearchEngine):
|
||||||
|
session: requests.Session = None
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
"""Initialize the BingSearch tool with a requests session."""
|
||||||
|
super().__init__(**data)
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(HEADERS)
|
||||||
|
|
||||||
|
def _search_sync(self, query: str, num_results: int = 10) -> List[str]:
|
||||||
|
"""
|
||||||
|
Synchronous Bing search implementation to retrieve a list of URLs matching a query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query (str): The search query to submit to Bing. Must not be empty.
|
||||||
|
num_results (int, optional): The maximum number of URLs to return. Defaults to 10.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: A list of URLs from the search results, capped at `num_results`.
|
||||||
|
Returns an empty list if the query is empty or no results are found.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pagination is handled by incrementing the `first` parameter and following `next_url` links.
|
||||||
|
- If fewer results than `num_results` are available, all found URLs are returned.
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
list_result = []
|
||||||
|
first = 1
|
||||||
|
next_url = BING_SEARCH_URL + query
|
||||||
|
|
||||||
|
while len(list_result) < num_results:
|
||||||
|
data, next_url = self._parse_html(
|
||||||
|
next_url, rank_start=len(list_result), first=first
|
||||||
|
)
|
||||||
|
if data:
|
||||||
|
list_result.extend([item["url"] for item in data])
|
||||||
|
if not next_url:
|
||||||
|
break
|
||||||
|
first += 10
|
||||||
|
|
||||||
|
return list_result[:num_results]
|
||||||
|
|
||||||
|
def _parse_html(self, url: str, rank_start: int = 0, first: int = 1) -> tuple:
|
||||||
|
"""
|
||||||
|
Parse Bing search result HTML synchronously to extract search results and the next page URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): The URL of the Bing search results page to parse.
|
||||||
|
rank_start (int, optional): The starting rank for numbering the search results. Defaults to 0.
|
||||||
|
first (int, optional): Unused parameter (possibly legacy). Defaults to 1.
|
||||||
|
Returns:
|
||||||
|
tuple: A tuple containing:
|
||||||
|
- list: A list of dictionaries with keys 'title', 'abstract', 'url', and 'rank' for each result.
|
||||||
|
- str or None: The URL of the next results page, or None if there is no next page.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = self.session.get(url=url)
|
||||||
|
res.encoding = "utf-8"
|
||||||
|
root = BeautifulSoup(res.text, "lxml")
|
||||||
|
|
||||||
|
list_data = []
|
||||||
|
ol_results = root.find("ol", id="b_results")
|
||||||
|
if not ol_results:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
for li in ol_results.find_all("li", class_="b_algo"):
|
||||||
|
title = ""
|
||||||
|
url = ""
|
||||||
|
abstract = ""
|
||||||
|
try:
|
||||||
|
h2 = li.find("h2")
|
||||||
|
if h2:
|
||||||
|
title = h2.text.strip()
|
||||||
|
url = h2.a["href"].strip()
|
||||||
|
|
||||||
|
p = li.find("p")
|
||||||
|
if p:
|
||||||
|
abstract = p.text.strip()
|
||||||
|
|
||||||
|
if ABSTRACT_MAX_LENGTH and len(abstract) > ABSTRACT_MAX_LENGTH:
|
||||||
|
abstract = abstract[:ABSTRACT_MAX_LENGTH]
|
||||||
|
|
||||||
|
rank_start += 1
|
||||||
|
list_data.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"abstract": abstract,
|
||||||
|
"url": url,
|
||||||
|
"rank": rank_start,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_btn = root.find("a", title="Next page")
|
||||||
|
if not next_btn:
|
||||||
|
return list_data, None
|
||||||
|
|
||||||
|
next_url = BING_HOST_URL + next_btn["href"]
|
||||||
|
return list_data, next_url
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error parsing HTML: {e}")
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
def perform_search(self, query, num_results=10, *args, **kwargs):
|
||||||
|
"""Bing search engine."""
|
||||||
|
return self._search_sync(query, num_results=num_results)
|
@ -8,6 +8,7 @@ from app.logger import logger
|
|||||||
from app.tool.base import BaseTool
|
from app.tool.base import BaseTool
|
||||||
from app.tool.search import (
|
from app.tool.search import (
|
||||||
BaiduSearchEngine,
|
BaiduSearchEngine,
|
||||||
|
BingSearchEngine,
|
||||||
DuckDuckGoSearchEngine,
|
DuckDuckGoSearchEngine,
|
||||||
GoogleSearchEngine,
|
GoogleSearchEngine,
|
||||||
WebSearchEngine,
|
WebSearchEngine,
|
||||||
@ -38,6 +39,7 @@ class WebSearch(BaseTool):
|
|||||||
"google": GoogleSearchEngine(),
|
"google": GoogleSearchEngine(),
|
||||||
"baidu": BaiduSearchEngine(),
|
"baidu": BaiduSearchEngine(),
|
||||||
"duckduckgo": DuckDuckGoSearchEngine(),
|
"duckduckgo": DuckDuckGoSearchEngine(),
|
||||||
|
"bing": BingSearchEngine(),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def execute(self, query: str, num_results: int = 10) -> List[str]:
|
async def execute(self, query: str, num_results: int = 10) -> List[str]:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user