Files

270 lines
11 KiB
Python

# Copyright 2023 The Qwen team, Alibaba Group. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
import json
import traceback
from abc import ABC, abstractmethod
from typing import Dict, Iterator, List, Optional, Tuple, Union
from qwen_agent.llm import get_chat_model
from qwen_agent.llm.base import BaseChatModel
from qwen_agent.llm.schema import CONTENT, DEFAULT_SYSTEM_MESSAGE, ROLE, SYSTEM, ContentItem, Message
from qwen_agent.log import logger
from qwen_agent.tools import TOOL_REGISTRY, BaseTool, MCPManager
from qwen_agent.tools.base import ToolServiceError
from qwen_agent.tools.simple_doc_parser import DocParserError
from qwen_agent.utils.utils import has_chinese_messages, merge_generate_cfgs
class Agent(ABC):
"""A base class for Agent.
An agent can receive messages and provide response by LLM or Tools.
Different agents have distinct workflows for processing messages and generating responses in the `_run` method.
"""
def __init__(self,
function_list: Optional[List[Union[str, Dict, BaseTool]]] = None,
llm: Optional[Union[dict, BaseChatModel]] = None,
system_message: Optional[str] = DEFAULT_SYSTEM_MESSAGE,
name: Optional[str] = None,
description: Optional[str] = None,
**kwargs):
"""Initialization the agent.
Args:
function_list: One list of tool name, tool configuration or Tool object,
such as 'code_interpreter', {'name': 'code_interpreter', 'timeout': 10}, or CodeInterpreter().
llm: The LLM model configuration or LLM model object.
Set the configuration as {'model': '', 'api_key': '', 'model_server': ''}.
system_message: The specified system message for LLM chat.
name: The name of this agent.
description: The description of this agent, which will be used for multi_agent.
"""
if isinstance(llm, dict):
self.llm = get_chat_model(llm)
else:
self.llm = llm
self.extra_generate_cfg: dict = {}
self.function_map = {}
if function_list:
for tool in function_list:
self._init_tool(tool)
self.system_message = system_message
self.name = name
self.description = description
def run_nonstream(self, messages: List[Union[Dict, Message]], **kwargs) -> Union[List[Message], List[Dict]]:
"""Same as self.run, but with stream=False,
meaning it returns the complete response directly
instead of streaming the response incrementally."""
*_, last_responses = self.run(messages, **kwargs)
return last_responses
def run(self, messages: List[Union[Dict, Message]],
**kwargs) -> Union[Iterator[List[Message]], Iterator[List[Dict]]]:
"""Return one response generator based on the received messages.
This method performs a uniform type conversion for the inputted messages,
and calls the _run method to generate a reply.
Args:
messages: A list of messages.
Yields:
The response generator.
"""
messages = copy.deepcopy(messages)
_return_message_type = 'dict'
new_messages = []
# Only return dict when all input messages are dict
if not messages:
_return_message_type = 'message'
for msg in messages:
if isinstance(msg, dict):
new_messages.append(Message(**msg))
else:
new_messages.append(msg)
_return_message_type = 'message'
if 'lang' not in kwargs:
if has_chinese_messages(new_messages):
kwargs['lang'] = 'zh'
else:
kwargs['lang'] = 'en'
if self.system_message:
if not new_messages or new_messages[0][ROLE] != SYSTEM:
# Add the system instruction to the agent
new_messages.insert(0, Message(role=SYSTEM, content=self.system_message))
else:
# Already got system message in new_messages
if isinstance(new_messages[0][CONTENT], str):
new_messages[0][CONTENT] = self.system_message + '\n\n' + new_messages[0][CONTENT]
else:
assert isinstance(new_messages[0][CONTENT], list)
assert new_messages[0][CONTENT][0].text
new_messages[0][CONTENT] = [ContentItem(text=self.system_message + '\n\n')
] + new_messages[0][CONTENT] # noqa
for rsp in self._run(messages=new_messages, **kwargs):
for i in range(len(rsp)):
if not rsp[i].name and self.name:
rsp[i].name = self.name
if _return_message_type == 'message':
yield [Message(**x) if isinstance(x, dict) else x for x in rsp]
else:
yield [x.model_dump() if not isinstance(x, dict) else x for x in rsp]
@abstractmethod
def _run(self, messages: List[Message], lang: str = 'en', **kwargs) -> Iterator[List[Message]]:
"""Return one response generator based on the received messages.
The workflow for an agent to generate a reply.
Each agent subclass needs to implement this method.
Args:
messages: A list of messages.
lang: Language, which will be used to select the language of the prompt
during the agent's execution process.
Yields:
The response generator.
"""
raise NotImplementedError
def _call_llm(
self,
messages: List[Message],
functions: Optional[List[Dict]] = None,
stream: bool = True,
extra_generate_cfg: Optional[dict] = None,
) -> Iterator[List[Message]]:
"""The interface of calling LLM for the agent.
We prepend the system_message of this agent to the messages, and call LLM.
Args:
messages: A list of messages.
functions: The list of functions provided to LLM.
stream: LLM streaming output or non-streaming output.
For consistency, we default to using streaming output across all agents.
Yields:
The response generator of LLM.
"""
return self.llm.chat(messages=messages,
functions=functions,
stream=stream,
extra_generate_cfg=merge_generate_cfgs(
base_generate_cfg=self.extra_generate_cfg,
new_generate_cfg=extra_generate_cfg,
))
def _call_tool(self, tool_name: str, tool_args: Union[str, dict] = '{}', **kwargs) -> Union[str, List[ContentItem]]:
"""The interface of calling tools for the agent.
Args:
tool_name: The name of one tool.
tool_args: Model generated or user given tool parameters.
Returns:
The output of tools.
"""
if tool_name not in self.function_map:
return f'Tool {tool_name} does not exists.'
tool = self.function_map[tool_name]
try:
tool_result = tool.call(tool_args, **kwargs)
except (ToolServiceError, DocParserError) as ex:
raise ex
except Exception as ex:
exception_type = type(ex).__name__
exception_message = str(ex)
traceback_info = ''.join(traceback.format_tb(ex.__traceback__))
error_message = f'An error occurred when calling tool `{tool_name}`:\n' \
f'{exception_type}: {exception_message}\n' \
f'Traceback:\n{traceback_info}'
logger.warning(error_message)
return error_message
if isinstance(tool_result, str):
return tool_result
elif isinstance(tool_result, list) and all(isinstance(item, ContentItem) for item in tool_result):
return tool_result # multimodal tool results
else:
return json.dumps(tool_result, ensure_ascii=False, indent=4)
def _init_tool(self, tool: Union[str, Dict, BaseTool]):
if isinstance(tool, BaseTool):
tool_name = tool.name
if tool_name in self.function_map:
logger.warning(f'Repeatedly adding tool {tool_name}, will use the newest tool in function list')
self.function_map[tool_name] = tool
elif isinstance(tool, dict) and 'mcpServers' in tool:
tools = MCPManager().initConfig(tool)
for tool in tools:
tool_name = tool.name
if tool_name in self.function_map:
logger.warning(f'Repeatedly adding tool {tool_name}, will use the newest tool in function list')
self.function_map[tool_name] = tool
else:
if isinstance(tool, dict):
tool_name = tool['name']
tool_cfg = tool
else:
tool_name = tool
tool_cfg = None
if tool_name not in TOOL_REGISTRY:
raise ValueError(f'Tool {tool_name} is not registered.')
if tool_name in self.function_map:
logger.warning(f'Repeatedly adding tool {tool_name}, will use the newest tool in function list')
self.function_map[tool_name] = TOOL_REGISTRY[tool_name](tool_cfg)
def _detect_tool(self, message: Message) -> Tuple[bool, str, str, str]:
"""A built-in tool call detection for func_call format message.
Args:
message: one message generated by LLM.
Returns:
Need to call tool or not, tool name, tool args, text replies.
"""
func_name = None
func_args = None
if message.function_call:
func_call = message.function_call
func_name = func_call.name
func_args = func_call.arguments
text = message.content
if not text:
text = ''
return (func_name is not None), func_name, func_args, text
# The most basic form of an agent is just a LLM, not augmented with any tool or workflow.
class BasicAgent(Agent):
def _run(self, messages: List[Message], lang: str = 'en', **kwargs) -> Iterator[List[Message]]:
extra_generate_cfg = {'lang': lang}
if kwargs.get('seed') is not None:
extra_generate_cfg['seed'] = kwargs['seed']
return self._call_llm(messages, extra_generate_cfg=extra_generate_cfg)