mirror of
https://github.com/zenlm/zen-agent.git
synced 2026-11-03 14:07:57 +00:00
403 lines
15 KiB
Python
403 lines
15 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
|
|
from typing import Dict, List, Literal, Union
|
|
|
|
from qwen_agent.llm.fncall_prompts.base_fncall_prompt import BaseFnCallPrompt
|
|
from qwen_agent.llm.schema import ASSISTANT, FUNCTION, SYSTEM, USER, ContentItem, FunctionCall, Message
|
|
from qwen_agent.utils.utils import extract_text_from_message
|
|
|
|
|
|
class QwenFnCallPrompt(BaseFnCallPrompt):
|
|
|
|
@staticmethod
|
|
def preprocess_fncall_messages(messages: List[Message],
|
|
functions: List[dict],
|
|
lang: Literal['en', 'zh'],
|
|
parallel_function_calls: bool = True,
|
|
function_choice: Union[Literal['auto'], str] = 'auto',
|
|
**kwargs) -> List[Message]:
|
|
ori_messages = messages
|
|
|
|
# Change function_call responses to plaintext responses:
|
|
messages = []
|
|
for msg in copy.deepcopy(ori_messages):
|
|
role, content = msg.role, msg.content
|
|
if role in (SYSTEM, USER):
|
|
messages.append(msg)
|
|
elif role == ASSISTANT:
|
|
content = (content or [])
|
|
fn_call = msg.function_call
|
|
if fn_call:
|
|
f_name = fn_call.name
|
|
f_args = fn_call.arguments
|
|
if f_args.startswith('```'): # if code snippet
|
|
f_args = '\n' + f_args # for markdown rendering
|
|
func_content = '\n' if messages[-1].role == ASSISTANT else ''
|
|
func_content += f'{FN_NAME}: {f_name}'
|
|
func_content += f'\n{FN_ARGS}: {f_args}'
|
|
content.append(ContentItem(text=func_content))
|
|
if messages[-1].role == ASSISTANT:
|
|
messages[-1].content += content
|
|
else:
|
|
messages.append(Message(role=role, content=content))
|
|
elif role == FUNCTION:
|
|
assert messages[-1].role == ASSISTANT
|
|
assert isinstance(content, list)
|
|
assert all(isinstance(item, ContentItem) for item in content)
|
|
if content:
|
|
f_result = copy.deepcopy(content)
|
|
else:
|
|
f_result = [ContentItem(text='')]
|
|
f_exit = f'\n{FN_EXIT}: '
|
|
last_text_content = messages[-1].content[-1].text
|
|
if last_text_content.endswith(f_exit):
|
|
messages[-1].content[-1].text = last_text_content[:-len(f_exit)]
|
|
f_result = [ContentItem(text=f'\n{FN_RESULT}: ')] + f_result + [ContentItem(text=f_exit)]
|
|
messages[-1].content += f_result
|
|
else:
|
|
raise TypeError
|
|
|
|
# Add a system prompt for function calling:
|
|
tool_desc_template = FN_CALL_TEMPLATE[lang + ('_parallel' if parallel_function_calls else '')]
|
|
tool_descs = '\n\n'.join(get_function_description(function, lang=lang) for function in functions)
|
|
tool_names = ','.join(function.get('name_for_model', function.get('name', '')) for function in functions)
|
|
tool_system = tool_desc_template.format(tool_descs=tool_descs, tool_names=tool_names)
|
|
if messages and messages[0].role == SYSTEM:
|
|
messages[0].content.append(ContentItem(text='\n\n' + tool_system))
|
|
else:
|
|
messages = [Message(role=SYSTEM, content=[ContentItem(text=tool_system)])] + messages
|
|
|
|
# Remove ': ' for continued generation of function calling,
|
|
# because ': ' may form a single token with its following words:
|
|
if messages[-1].role == ASSISTANT:
|
|
last_msg = messages[-1].content
|
|
for i in range(len(last_msg) - 1, -1, -1):
|
|
item_type, item_text = last_msg[i].get_type_and_value()
|
|
if item_type == 'text':
|
|
if item_text.endswith(f'{FN_EXIT}: '):
|
|
last_msg[i].text = item_text[:-2]
|
|
break
|
|
|
|
# Add the function_choice prefix:
|
|
if function_choice not in ('auto', 'none'):
|
|
if messages[-1].role == ASSISTANT:
|
|
last_msg = messages[-1]
|
|
if last_msg.content:
|
|
if extract_text_from_message(last_msg, add_upload_info=False).endswith(FN_EXIT):
|
|
last_msg.content.append(ContentItem(text=': \n'))
|
|
else:
|
|
last_msg.content.append(ContentItem(text='\n'))
|
|
messages = messages[:-1]
|
|
else:
|
|
last_msg = Message(role=ASSISTANT, content=[])
|
|
last_msg.content.append(ContentItem(text=f'{FN_NAME}: {function_choice}'))
|
|
messages = messages + [last_msg]
|
|
|
|
return messages
|
|
|
|
@staticmethod
|
|
def postprocess_fncall_messages(messages: List[Message],
|
|
parallel_function_calls: bool = True,
|
|
function_choice: Union[Literal['auto'], str] = 'auto',
|
|
**kwargs) -> List[Message]:
|
|
messages = copy.deepcopy(messages)
|
|
|
|
# Prepend a prefix for function_choice:
|
|
if function_choice not in ('auto', 'none'):
|
|
if messages and messages[0].content:
|
|
output = messages[0].content[0].text
|
|
if output.lstrip().startswith(FN_ARGS):
|
|
# Prepend this prefix only if the model correctly completes it
|
|
output = f'{FN_NAME}: {function_choice}\n' + output
|
|
messages[0].content[0].text = output
|
|
|
|
# Remove ': ' brought by continued generation of function calling
|
|
last_msg = messages[-1].content
|
|
for i in range(len(last_msg)):
|
|
item_type, item_text = last_msg[i].get_type_and_value()
|
|
if item_type == 'text':
|
|
if item_text.startswith(': '):
|
|
last_msg[i].text = item_text[2:]
|
|
elif item_text.startswith(':'):
|
|
last_msg[i].text = item_text[1:]
|
|
break
|
|
|
|
# Convert plaintext responses to function_call responses:
|
|
new_messages = []
|
|
for msg in messages:
|
|
role, content, extra = msg.role, msg.content, msg.extra
|
|
assert isinstance(content, list)
|
|
|
|
if role in (SYSTEM, USER):
|
|
new_messages.append(Message(role=role, content=content, extra=extra))
|
|
continue
|
|
|
|
new_content = []
|
|
for item in content:
|
|
item_type, item_text = item.get_type_and_value()
|
|
|
|
if item_type != 'text': # multimodal
|
|
new_content.append(item)
|
|
continue
|
|
|
|
for stop_word in FN_STOP_WORDS:
|
|
assert stop_word not in item_text, 'Something wrong, stop words are expected to be excluded.'
|
|
|
|
i = item_text.find(f'{FN_NAME}:')
|
|
|
|
# If no function call:
|
|
if i < 0:
|
|
show_text = remove_incomplete_special_tokens(item_text)
|
|
if show_text:
|
|
new_content.append(ContentItem(text=show_text))
|
|
continue
|
|
|
|
# If it says something before function call:
|
|
if i > 0:
|
|
answer = item_text[:i].lstrip('\n').rstrip()
|
|
if answer.endswith('\n'):
|
|
answer = answer[:-1]
|
|
show_text = remove_incomplete_special_tokens(answer)
|
|
if show_text:
|
|
new_content.append(ContentItem(text=show_text))
|
|
if new_content:
|
|
new_messages.append(Message(
|
|
role=role,
|
|
content=new_content,
|
|
extra=extra,
|
|
)) # split thought and function call
|
|
new_content = []
|
|
item_text = item_text[i:]
|
|
|
|
# If has function call:
|
|
for part in item_text.split(f'{FN_NAME}:'):
|
|
if not part:
|
|
continue
|
|
if part.endswith('\n'):
|
|
part = part[:-1]
|
|
|
|
arg_sep = f'{FN_ARGS}:'
|
|
i = part.find(arg_sep)
|
|
if i < 0:
|
|
fn_name = part.strip()
|
|
list_of_fn_args = ['']
|
|
else:
|
|
fn_name = part[:i].strip()
|
|
list_of_fn_args = [_.strip() for _ in part[i + len(arg_sep):].split(arg_sep)]
|
|
fn_name = remove_incomplete_special_tokens(fn_name)
|
|
for fn_args in list_of_fn_args:
|
|
fn_args = remove_incomplete_special_tokens(fn_args)
|
|
fn_args = remove_trailing_comment_of_fn_args(fn_args)
|
|
new_messages.append(
|
|
Message(
|
|
role=ASSISTANT,
|
|
content=[],
|
|
function_call=FunctionCall(
|
|
name=fn_name,
|
|
arguments=fn_args,
|
|
),
|
|
extra=extra,
|
|
))
|
|
|
|
# Keep only one function call if parallelism is disabled
|
|
if not parallel_function_calls:
|
|
tmp_messages = []
|
|
for tmp_m in new_messages:
|
|
tmp_messages.append(tmp_m)
|
|
if tmp_m.function_call:
|
|
break
|
|
new_messages = tmp_messages
|
|
|
|
# Break here and discard the text after function call
|
|
return new_messages
|
|
|
|
if new_content:
|
|
new_messages.append(Message(role=role, content=new_content, extra=extra))
|
|
return new_messages
|
|
|
|
|
|
FN_NAME = '✿FUNCTION✿'
|
|
FN_ARGS = '✿ARGS✿'
|
|
FN_RESULT = '✿RESULT✿'
|
|
FN_EXIT = '✿RETURN✿'
|
|
FN_STOP_WORDS = [FN_RESULT, FN_EXIT]
|
|
|
|
FN_CALL_TEMPLATE_INFO_ZH = """# 工具
|
|
|
|
## 你拥有如下工具:
|
|
|
|
{tool_descs}"""
|
|
|
|
FN_CALL_TEMPLATE_INFO_EN = """# Tools
|
|
|
|
## You have access to the following tools:
|
|
|
|
{tool_descs}"""
|
|
|
|
FN_CALL_TEMPLATE_FMT_ZH = """## 你可以在回复中插入零次、一次或多次以下命令以调用工具:
|
|
|
|
%s: 工具名称,必须是[{tool_names}]之一。
|
|
%s: 工具输入
|
|
%s: 工具结果
|
|
%s: 根据工具结果进行回复,需将图片用渲染出来""" % (
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_RESULT,
|
|
FN_EXIT,
|
|
)
|
|
|
|
FN_CALL_TEMPLATE_FMT_EN = """## When you need to call a tool, please insert the following command in your reply, which can be called zero or multiple times according to your needs:
|
|
|
|
%s: The tool to use, should be one of [{tool_names}]
|
|
%s: The input of the tool
|
|
%s: Tool results
|
|
%s: Reply based on tool results. Images need to be rendered as """ % (
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_RESULT,
|
|
FN_EXIT,
|
|
)
|
|
|
|
FN_CALL_TEMPLATE_FMT_PARA_ZH = """## 你可以在回复中插入以下命令以并行调用N个工具:
|
|
|
|
%s: 工具1的名称,必须是[{tool_names}]之一
|
|
%s: 工具1的输入
|
|
%s: 工具2的名称
|
|
%s: 工具2的输入
|
|
...
|
|
%s: 工具N的名称
|
|
%s: 工具N的输入
|
|
%s: 工具1的结果
|
|
%s: 工具2的结果
|
|
...
|
|
%s: 工具N的结果
|
|
%s: 根据工具结果进行回复,需将图片用渲染出来""" % (
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_RESULT,
|
|
FN_RESULT,
|
|
FN_RESULT,
|
|
FN_EXIT,
|
|
)
|
|
|
|
FN_CALL_TEMPLATE_FMT_PARA_EN = """## Insert the following command in your reply when you need to call N tools in parallel:
|
|
|
|
%s: The name of tool 1, should be one of [{tool_names}]
|
|
%s: The input of tool 1
|
|
%s: The name of tool 2
|
|
%s: The input of tool 2
|
|
...
|
|
%s: The name of tool N
|
|
%s: The input of tool N
|
|
%s: The result of tool 1
|
|
%s: The result of tool 2
|
|
...
|
|
%s: The result of tool N
|
|
%s: Reply based on tool results. Images need to be rendered as """ % (
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_NAME,
|
|
FN_ARGS,
|
|
FN_RESULT,
|
|
FN_RESULT,
|
|
FN_RESULT,
|
|
FN_EXIT,
|
|
)
|
|
|
|
FN_CALL_TEMPLATE = {
|
|
'zh': FN_CALL_TEMPLATE_INFO_ZH + '\n\n' + FN_CALL_TEMPLATE_FMT_ZH,
|
|
'en': FN_CALL_TEMPLATE_INFO_EN + '\n\n' + FN_CALL_TEMPLATE_FMT_EN,
|
|
'zh_parallel': FN_CALL_TEMPLATE_INFO_ZH + '\n\n' + FN_CALL_TEMPLATE_FMT_PARA_ZH,
|
|
'en_parallel': FN_CALL_TEMPLATE_INFO_EN + '\n\n' + FN_CALL_TEMPLATE_FMT_PARA_EN,
|
|
}
|
|
|
|
|
|
def get_function_description(function: Dict, lang: Literal['en', 'zh']) -> str:
|
|
"""
|
|
Text description of function
|
|
"""
|
|
tool_desc_template = {
|
|
'zh': '### {name_for_human}\n\n{name_for_model}: {description_for_model} 输入参数:{parameters} {args_format}',
|
|
'en': '### {name_for_human}\n\n{name_for_model}: {description_for_model} Parameters: {parameters} {args_format}'
|
|
}
|
|
tool_desc = tool_desc_template[lang]
|
|
name = function.get('name', None)
|
|
name_for_human = function.get('name_for_human', name)
|
|
name_for_model = function.get('name_for_model', name)
|
|
assert name_for_human and name_for_model
|
|
|
|
if name_for_model == 'code_interpreter':
|
|
args_format = {
|
|
'zh': '此工具的输入应为Markdown代码块。',
|
|
'en': 'Enclose the code within triple backticks (`) at the beginning and end of the code.',
|
|
}
|
|
else:
|
|
args_format = {
|
|
'zh': '此工具的输入应为JSON对象。',
|
|
'en': 'Format the arguments as a JSON object.',
|
|
}
|
|
args_format = function.get('args_format', args_format[lang])
|
|
|
|
return tool_desc.format(name_for_human=name_for_human,
|
|
name_for_model=name_for_model,
|
|
description_for_model=function['description'],
|
|
parameters=json.dumps(function['parameters'], ensure_ascii=False),
|
|
args_format=args_format).rstrip()
|
|
|
|
|
|
# Mainly for removing incomplete trailing special tokens when streaming the output
|
|
def remove_incomplete_special_tokens(text: str) -> str:
|
|
special_tokens = (FN_NAME, FN_ARGS, FN_RESULT, FN_EXIT)
|
|
text = text.rstrip()
|
|
if text.endswith(special_tokens):
|
|
for s in special_tokens:
|
|
if text.endswith(s):
|
|
text = text[:-len(s)]
|
|
break
|
|
else:
|
|
trail_start = text.rfind('✿')
|
|
trail_token = text[trail_start:]
|
|
for s in special_tokens:
|
|
if s.startswith(trail_token):
|
|
text = text[:trail_start]
|
|
break
|
|
text = text.lstrip('\n').rstrip()
|
|
return text
|
|
|
|
|
|
# For hotfix badcases such as `{"arg1": "value1"} <!-- this is an example comment -->`.
|
|
def remove_trailing_comment_of_fn_args(fn_args: str):
|
|
fn_args = fn_args.strip()
|
|
|
|
if fn_args.startswith('{'):
|
|
k = fn_args.rfind('}')
|
|
if k > 0:
|
|
fn_args = fn_args[:k + 1]
|
|
|
|
if fn_args.startswith('```'):
|
|
k = fn_args.rfind('\n```')
|
|
if k > 0:
|
|
fn_args = fn_args[:k + 4]
|
|
|
|
return fn_args
|