commit 8da954425d43d2e645609eadd7fe40c94efb651c Author: tjb-tech <1193992557@qq.com> Date: Fri Feb 7 21:37:18 2025 +0800 upload diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b565ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +workplace_*/ +workspace_*/ +*.log +code_db/* +results/* + +__pycache__/ +tmp/* +logs/* +*.tar.gz + +*.egg-info + +.DS_Store +*.csv + +eval_data/* +evaluation_results/* +casestudy_results/* + +evaluation/*/data/ +evaluation/*/data/* +evaluation/**/data/ + +.env + +terminal_tmp/* \ No newline at end of file diff --git a/constant.py b/constant.py new file mode 100644 index 0000000..b9449f7 --- /dev/null +++ b/constant.py @@ -0,0 +1,53 @@ +import os +from dotenv import load_dotenv +# utils: +load_dotenv() # 加载.env文件 +def str_to_bool(value): + """convert string to bool""" + true_values = {'true', 'yes', '1', 'on', 't', 'y'} + false_values = {'false', 'no', '0', 'off', 'f', 'n'} + + if isinstance(value, bool): + return value + + if not value: + return False + + value = str(value).lower().strip() + if value in true_values: + return True + if value in false_values: + return False + return True # default return True + + +DOCKER_WORKPLACE_NAME = os.getenv('DOCKER_WORKPLACE_NAME', 'workplace_meta') +GITHUB_AI_TOKEN = os.getenv('GITHUB_AI_TOKEN', None) +AI_USER = os.getenv('AI_USER', None) +LOCAL_ROOT = os.getenv('LOCAL_ROOT', os.getcwd()) + +DEBUG = str_to_bool(os.getenv('DEBUG', True)) + +DEFAULT_LOG = str_to_bool(os.getenv('DEFAULT_LOG', False)) +LOG_PATH = os.getenv('LOG_PATH', None) +EVAL_MODE = str_to_bool(os.getenv('EVAL_MODE', False)) +BASE_IMAGES = os.getenv('BASE_IMAGES', "tjb-gaia-bookworm:v2") + +COMPLETION_MODEL = os.getenv('COMPLETION_MODEL', "claude-3-5-haiku-20241022") +EMBEDDING_MODEL = os.getenv('EMBEDDING_MODEL', "text-embedding-3-small") + +MC_MODE = str_to_bool(os.getenv('MC_MODE', False)) + +# add Env for function call and non-function call + +FN_CALL = str_to_bool(os.getenv('FN_CALL', True)) +API_BASE_URL = os.getenv('API_BASE_URL', None) +ADD_USER = str_to_bool(os.getenv('ADD_USER', False)) + +NON_FN_CALL = str_to_bool(os.getenv('NON_FN_CALL', False)) + +NOT_SUPPORT_SENDER = ["mistral", "groq"] + +if EVAL_MODE: + DEFAULT_LOG = False + diff --git a/evaluation/README.md b/evaluation/README.md new file mode 100644 index 0000000..e69de29 diff --git a/evaluation/gaia/get_score.py b/evaluation/gaia/get_score.py new file mode 100644 index 0000000..8e42d94 --- /dev/null +++ b/evaluation/gaia/get_score.py @@ -0,0 +1,49 @@ +import argparse +import json + + +def main(): + parser = argparse.ArgumentParser(description="Get agent's gaia score") + parser.add_argument('--file', default='/Users/tangjiabin/Documents/reasoning/metachain/evaluation_results/gaia/system_triage_agent/claude-3-5-sonnet-20241022_maxiter/output.jsonl', type=str, help="Path to the agent's output.jsonl") + args = parser.parse_args() + this_log = args.file + outs = [] + with open(this_log, 'r') as f: + lines = f.readlines() + for line in lines: + outs.append(json.loads(line)) + print(f'Reading {this_log}') + print(f'Metadata:\n {outs[0]["metadata"]}') + + total = 0 + success = 0 + l1_total = 0 + l1_success = 0 + l2_total = 0 + l2_success = 0 + l3_total = 0 + l3_success = 0 + for out in outs: + total += 1 + if out['test_result']['score']: + success += 1 + if out['instance']['Level'] == "1": + l1_total += 1 + if out['test_result']['score']: + l1_success += 1 + elif out['instance']['Level'] == "2": + l2_total += 1 + if out['test_result']['score']: + l2_success += 1 + elif out['instance']['Level'] == "3": + l3_total += 1 + if out['test_result']['score']: + l3_success += 1 + print(f'Success rate: {success}/{total} = {success/total * 100:.4f}%') + print(f'L1 success rate: {l1_success}/{l1_total} = {l1_success/l1_total * 100:.4f}%') + print(f'L2 success rate: {l2_success}/{l2_total} = {l2_success/l2_total * 100:.4f}%') + print(f'L3 success rate: {l3_success}/{l3_total} = {l3_success/l3_total * 100:.4f}%') + + +if __name__ == '__main__': + main() diff --git a/evaluation/gaia/run_infer.py b/evaluation/gaia/run_infer.py new file mode 100644 index 0000000..d6bdb3d --- /dev/null +++ b/evaluation/gaia/run_infer.py @@ -0,0 +1,299 @@ +from metachain.environment.docker_container import init_container +import argparse +from constant import DOCKER_WORKPLACE_NAME +from datasets import load_dataset +import huggingface_hub +from metachain import MetaChain +from metachain.logger import MetaChainLogger, LoggerManager +from evaluation.utils import make_metadata, prepare_dataset, update_progress, check_port_available, run_evaluation, clean_msg +from evaluation.types import EvalMetadata, EvalOutput +import metachain.agents as agenthub +import os.path as osp +import pandas as pd +import asyncio +import re +import os +import shutil +from metachain.registry import registry +from evaluation.gaia.scorer import question_scorer +import json +# from metachain.util import run_command_in_container +from metachain.environment.docker_env import DockerEnv, DockerConfig, check_container_ports, check_container_exist, check_container_running +from metachain.environment.browser_env import BrowserEnv +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +from metachain.types import Response +from metachain.util import function_to_json +from metachain.main import run_in_client, run_in_client_non_async +from metachain.agents.meta_agent.tool_editor import get_tool_editor_agent +from metachain.environment.utils import setup_metachain +import subprocess +DATASET_CACHE_DIR = osp.join(osp.dirname(__file__), 'data') +# Note: You should run this script in the root directory of the project metachain +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--container_name', type=str, default='gaia_test') + parser.add_argument('--model', type=str, default='claude-3-5-sonnet-20241022') + parser.add_argument('--git_clone', action='store_true', default=False) + parser.add_argument('--setup_package', type=str, default=None) + parser.add_argument('--test_pull_name', type=str, default='main') + parser.add_argument('--debug', action='store_true', default=False) + # metadata + parser.add_argument('--agent_func', type=str, default='get_system_triage_agent') + parser.add_argument('--eval_note', type=str, default=None) + parser.add_argument('--eval_output_dir', type=str, default='./evaluation_results') + parser.add_argument('--data_split', type=str, default=None) + # gaia level + parser.add_argument('--level', type=str, default='1') + parser.add_argument('--eval_n_limit', type=int, default=None) + parser.add_argument('--port', type=int, default=12345) + parser.add_argument('--eval_num_workers', type=int, default=1) + args = parser.parse_args() + return args + +def get_config(metadata: EvalMetadata, instance_id: str): + container_name = metadata.container_name+f'_{instance_id}' + + port_info = check_container_ports(container_name) + port = metadata.port + if port_info: + port = port_info[0] + else: + # while not check_port_available(port): + # port += 1 + # 使用文件锁来确保端口分配的原子性 + import filelock + lock_file = os.path.join(os.getcwd(), ".port_lock") + lock = filelock.FileLock(lock_file) + + with lock: + port = metadata.port + while not check_port_available(port): + port += 1 + print(f'{port} is not available, trying {port+1}') + # 立即标记该端口为已使用 + with open(os.path.join(os.getcwd(), f".port_{port}"), 'w') as f: + f.write(container_name) + local_root = os.path.join(os.getcwd(), f"workspace_gaia_whole", f"gaia_eval_{instance_id}") + os.makedirs(local_root, exist_ok=True) + docker_config = DockerConfig( + workplace_name=DOCKER_WORKPLACE_NAME, + container_name=container_name, + communication_port=port, + conda_path='/root/miniconda3', + local_root=local_root, + git_clone=metadata.git_clone, + test_pull_name=metadata.test_pull_name, + ) + return docker_config + +def process_instance( + instance: pd.Series, + metadata: EvalMetadata, + logger: MetaChainLogger, +) -> EvalOutput: + docker_config = get_config(metadata, instance_id=instance['instance_id']) + code_env = None + try: + + + code_env, web_env, file_env = create_environment(docker_config) + local_workplace = code_env.local_workplace + docker_workplace = code_env.docker_workplace + + + # Setup the logger properly, so you can run multi-processing to parallelize the evaluation + logger.info(f'Starting evaluation for instance {instance["instance_id"]}.') + + if instance['file_name'] != '': + assert metadata.data_split is not None + src_file = os.path.join( + DATASET_CACHE_DIR, '2023', metadata.data_split, instance['file_name'] + ) + assert os.path.exists(src_file) + extension_name = instance['file_name'].split('.')[-1] + dest_file = os.path.join(local_workplace, f'file.{extension_name}') + shutil.copy(src_file, dest_file) + file_name = dest_file.split('/')[-1] + else: + dest_file = None + + + # Prepare instruction + instruction = f"{instance['Question']}\n" + logger.info(f'Instruction: {instruction}') + if dest_file: + instruction += f"\n\nThe mentioned file is provided in the workspace at: {osp.join(docker_workplace, file_name)}" + + instruction += 'IMPORTANT: Any agent cannot stop using tools until the task is done. Don\'t tell me how to do bot do it using tools!\n' + instruction += 'IMPORTANT: System Triage Agent must hand off the task to the suitable agents, and finally answer the question util there is no more sub-task to do.\n' + instruction += 'IMPORTANT: When you meet something you are not sure about, you should use the `Web Surfer Agent` to search the web. And when you are required to compute something, you should use the `Programming Agent` to compute. Take Advantage of agents as much as possible.\n' + instruction += 'IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\n' + instruction += 'Please encapsulate your final answer (answer ONLY) within and .\n' + instruction += ( + 'For example: The answer to the question is 42 .\n' + ) + + logger.info(f'Instruction:\n{instruction}') + + system_triage_agent = registry.agents[metadata.agent_func](model=metadata.model) + messages = [ + { + 'role': 'user', + 'content': instruction + } + ] + + context_variables = {"code_env": code_env, "web_env": web_env, "file_env": file_env} + # Here's how you can run the agent (similar to the `main` function) and get the final task state + tool_editor_agent = get_tool_editor_agent(model=metadata.model) + response: Response | None = asyncio.run( + run_in_client( + agent=system_triage_agent, + messages=messages, + context_variables = context_variables, + logger=logger, + meta_agent=tool_editor_agent, + docker_config=docker_config, + code_env=code_env, + ) + ) + # response: Response | None = run_in_client_non_async( + # agent=system_triage_agent, + # messages=messages, + # context_variables = context_variables, + # logger=logger + # ) + messages.extend(response.messages) + # save messages to a file + messages_file = osp.join(metadata.eval_output_dir, f"gaia_{instance['instance_id']}", f'messages_{metadata.agent_func.replace("get_", "")}.json') + os.makedirs(osp.dirname(messages_file), exist_ok=True) + messages = clean_msg(messages) + with open(messages_file, 'w', encoding='utf-8') as f: + json.dump(messages, f, ensure_ascii=False, indent=4) + # ======= Attempt to evaluate the agent's edits ======= + # If you are working on simpler benchmark that only evaluates the final model output (e.g., in a MessageAction) + # You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation. + + if response is None: + raise ValueError('Response should not be None.') + + model_answer_raw = response.messages[-1]['content'] + + # attempt to parse model_answer + model_answer = re.findall(r'(.*?)', model_answer_raw) + if len(model_answer) == 0: + logger.info(f'Failed to parse model answer: {model_answer_raw}', title='WARNING', color='yellow') + model_answer = model_answer_raw + else: + model_answer = model_answer[0] + + logger.info( + f'Final message: {model_answer} | Ground truth: {instance["Final answer"]}', + title='INFO', color='green' + ) + score = question_scorer( + model_answer=model_answer, ground_truth=instance['Final answer'] + ) + test_result = { + 'score': score, + 'model_answer_raw': model_answer_raw, + 'model_answer': model_answer, + 'ground_truth': instance['Final answer'], + } + + + # Save the output + output = EvalOutput( + instance_id=instance['instance_id'], + instance=instance.to_dict(), + instruction=instance['Question'], + metadata=metadata, + messages=messages, + test_result=test_result, + ) + finally: + # 清理资源 + if code_env is not None: + try: + # 停止容器 + code_env.stop_container() + logger.info(f"Container {docker_config.container_name} stopped successfully") + # 可选:删除容器 + # subprocess.run(["docker", "rm", docker_config.container_name], + # capture_output=True, text=True) + # logger.info(f"Container {docker_config.container_name} removed successfully") + + # 可选:删除工作目录 + + except Exception as e: + logger.error(f"Error during cleanup: {str(e)}") + + # 清理端口标记文件 + port_file = os.path.join(os.getcwd(), f".port_{docker_config.communication_port}") + if os.path.exists(port_file): + os.remove(port_file) + logger.info(f"Port {docker_config.communication_port} released") + return output + +def map_instance_to_port(dataset: pd.DataFrame, metadata: EvalMetadata): + port_dict = {} + for idx, row in dataset.iterrows(): + port_dict[row['instance_id']] = metadata.port + idx + + +def create_environment(docker_config: DockerConfig): + """ + 1. create the code environment + 2. create the web environment + 3. create the file environment + """ + code_env = DockerEnv(docker_config) + code_env.init_container() + + web_env = BrowserEnv(browsergym_eval_env = None, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name) + file_env = RequestsMarkdownBrowser(viewport_size=1024 * 5, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name, downloads_folder=os.path.join(docker_config.local_root, docker_config.workplace_name, "downloads")) + + return code_env, web_env, file_env +def main(args): + metadata: EvalMetadata = make_metadata( + model=args.model, + dataset_name="gaia", + agent_func=args.agent_func, + eval_note=args.eval_note, + eval_output_dir=args.eval_output_dir, + data_split=args.data_split, + details={'gaia-level': args.level}, + port=args.port, + container_name=args.container_name, + git_clone=args.git_clone, + test_pull_name=args.test_pull_name, + ) + log_path = osp.join(metadata.eval_output_dir, 'logs', f'agent_{metadata.model}.log') + LoggerManager.set_logger(MetaChainLogger(log_path)) + + dataset = load_dataset('gaia-benchmark/GAIA', args.level) + huggingface_hub.snapshot_download( + 'gaia-benchmark/GAIA', + repo_type='dataset', + local_dir=DATASET_CACHE_DIR, + ) + gaia_tests = dataset[metadata.data_split].to_pandas() + gaia_tests.rename(columns={'task_id': 'instance_id'}, inplace=True) + + output_file = osp.join(metadata.eval_output_dir, 'output.jsonl') + prepared_dataset = prepare_dataset(gaia_tests, output_file, args.eval_n_limit) + + run_evaluation( + dataset=prepared_dataset, + metadata=metadata, + output_file=output_file, + num_workers=args.eval_num_workers, + process_instance_func=process_instance, + ) + + + +if __name__ == "__main__": + args = get_args() + main(args) + # print(check_container_exist('gaia_lite_eval_c61d22de-5f6c-4958-a7f6-5e9707bd3466')) \ No newline at end of file diff --git a/evaluation/gaia/run_infer_old.py b/evaluation/gaia/run_infer_old.py new file mode 100644 index 0000000..ca59444 --- /dev/null +++ b/evaluation/gaia/run_infer_old.py @@ -0,0 +1,235 @@ +from metachain.environment.docker_container import init_container +import argparse +from constant import DOCKER_WORKPLACE_NAME +from datasets import load_dataset +import huggingface_hub +from metachain import MetaChain +from metachain.logger import MetaChainLogger, LoggerManager +from evaluation.utils import make_metadata, prepare_dataset, update_progress, check_port_available, run_evaluation, clean_msg +from evaluation.types import EvalMetadata, EvalOutput +import metachain.agents as agenthub +import os.path as osp +import pandas as pd +import asyncio +import re +import os +import shutil +from metachain.registry import registry +from evaluation.gaia.scorer import question_scorer +import json +# from metachain.util import run_command_in_container +from metachain.environment.docker_env import DockerEnv, DockerConfig, check_container_ports, check_container_exist, check_container_running +from metachain.environment.browser_env import BrowserEnv +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +from metachain.types import Response +from metachain.util import function_to_json +from metachain.main import run_in_client +import subprocess +DATASET_CACHE_DIR = osp.join(osp.dirname(__file__), 'data') +# Note: You should run this script in the root directory of the project metachain +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--container_name', type=str, default='gaia_test') + parser.add_argument('--model', type=str, default='gpt-4o-2024-08-06') + parser.add_argument('--git_clone', action='store_true', default=False) + parser.add_argument('--setup_package', type=str, default=None) + parser.add_argument('--test_pull_name', type=str, default='main') + parser.add_argument('--debug', action='store_true', default=False) + # metadata + parser.add_argument('--agent_func', type=str, default='get_system_triage_agent') + parser.add_argument('--eval_note', type=str, default=None) + parser.add_argument('--eval_output_dir', type=str, default='./evaluation_results') + parser.add_argument('--data_split', type=str, default=None) + # gaia level + parser.add_argument('--level', type=str, default='1') + parser.add_argument('--eval_n_limit', type=int, default=None) + parser.add_argument('--port', type=int, default=12345) + parser.add_argument('--eval_num_workers', type=int, default=1) + args = parser.parse_args() + return args + +def get_config(metadata: EvalMetadata, instance_id: str): + container_name = metadata.container_name+f'_{instance_id}' + + port_info = check_container_ports(container_name) + port = metadata.port + if port_info: + port = port_info[0] + else: + while not check_port_available(port): + port += 1 + local_root = os.path.join(os.getcwd(), f"workspace_gaia_whole", f"gaia_eval_{instance_id}") + os.makedirs(local_root, exist_ok=True) + docker_config = DockerConfig( + workplace_name=DOCKER_WORKPLACE_NAME, + container_name=container_name, + communication_port=port, + conda_path='/root/miniconda3', + local_root=local_root, + ) + return docker_config + +def process_instance( + instance: pd.Series, + metadata: EvalMetadata, + logger: MetaChainLogger, +) -> EvalOutput: + + docker_config = get_config(metadata, instance_id=instance['instance_id']) + + code_env, web_env, file_env = create_environment(docker_config) + local_workplace = code_env.local_workplace + docker_workplace = code_env.docker_workplace + + # Setup the logger properly, so you can run multi-processing to parallelize the evaluation + logger.info(f'Starting evaluation for instance {instance["instance_id"]}.') + + if instance['file_name'] != '': + assert metadata.data_split is not None + src_file = os.path.join( + DATASET_CACHE_DIR, '2023', metadata.data_split, instance['file_name'] + ) + assert os.path.exists(src_file) + extension_name = instance['file_name'].split('.')[-1] + dest_file = os.path.join(local_workplace, f'file.{extension_name}') + shutil.copy(src_file, dest_file) + file_name = dest_file.split('/')[-1] + else: + dest_file = None + + + # Prepare instruction + instruction = f"{instance['Question']}\n" + logger.info(f'Instruction: {instruction}') + if dest_file: + instruction += f"\n\nThe mentioned file is provided in the workspace at: {osp.join(docker_workplace, file_name)}" + + instruction += 'IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\n' + instruction += 'Please encapsulate your final answer (answer ONLY) within and .\n' + instruction += ( + 'For example: The answer to the question is 42 .\n' + ) + + logger.info(f'Instruction:\n{instruction}') + + system_triage_agent = registry.agents[metadata.agent_func](model=metadata.model, file_env=file_env, web_env=web_env, code_env=code_env) + messages = [ + { + 'role': 'user', + 'content': instruction + } + ] + + context_variables = {} + # Here's how you can run the agent (similar to the `main` function) and get the final task state + response: Response | None = asyncio.run( + run_in_client( + agent=system_triage_agent, + messages=messages, + context_variables = context_variables, + logger=logger + ) + ) + messages.extend(response.messages) + # save messages to a file + messages_file = osp.join(metadata.eval_output_dir, f"gaia_{instance['instance_id']}", f'messages_{metadata.agent_func.replace("get_", "")}.json') + os.makedirs(osp.dirname(messages_file), exist_ok=True) + messages = clean_msg(messages) + with open(messages_file, 'w', encoding='utf-8') as f: + json.dump(messages, f, ensure_ascii=False, indent=4) + # ======= Attempt to evaluate the agent's edits ======= + # If you are working on simpler benchmark that only evaluates the final model output (e.g., in a MessageAction) + # You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation. + + if response is None: + raise ValueError('Response should not be None.') + + model_answer_raw = response.messages[-1]['content'] + + # attempt to parse model_answer + model_answer = re.findall(r'(.*?)', model_answer_raw) + if len(model_answer) == 0: + logger.info(f'Failed to parse model answer: {model_answer_raw}', title='WARNING', color='yellow') + model_answer = model_answer_raw + else: + model_answer = model_answer[0] + + logger.info( + f'Final message: {model_answer} | Ground truth: {instance["Final answer"]}', + title='INFO', color='green' + ) + score = question_scorer( + model_answer=model_answer, ground_truth=instance['Final answer'] + ) + test_result = { + 'score': score, + 'model_answer_raw': model_answer_raw, + 'model_answer': model_answer, + 'ground_truth': instance['Final answer'], + } + + + # Save the output + output = EvalOutput( + instance_id=instance['instance_id'], + instance=instance.to_dict(), + instruction=instance['Question'], + metadata=metadata, + messages=messages, + test_result=test_result, + ) + return output + +def create_environment(docker_config: DockerConfig): + """ + 1. create the code environment + 2. create the web environment + 3. create the file environment + """ + code_env = DockerEnv(docker_config) + code_env.init_container() + web_env = BrowserEnv(browsergym_eval_env = None, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name) + file_env = RequestsMarkdownBrowser(viewport_size=1024 * 5, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name, downloads_folder=os.path.join(docker_config.local_root, docker_config.workplace_name, "downloads")) + + return code_env, web_env, file_env +def main(args): + metadata: EvalMetadata = make_metadata( + model=args.model, + dataset_name="gaia", + agent_func=args.agent_func, + eval_note=args.eval_note, + eval_output_dir=args.eval_output_dir, + data_split=args.data_split, + details={'gaia-level': args.level}, + port=args.port, + container_name=args.container_name, + ) + log_path = osp.join(metadata.eval_output_dir, 'logs', f'agent_{metadata.model}.log') + LoggerManager.set_logger(MetaChainLogger(log_path)) + + dataset = load_dataset('gaia-benchmark/GAIA', args.level) + huggingface_hub.snapshot_download( + 'gaia-benchmark/GAIA', + repo_type='dataset', + local_dir=DATASET_CACHE_DIR, + ) + gaia_tests = dataset[metadata.data_split].to_pandas() + gaia_tests.rename(columns={'task_id': 'instance_id'}, inplace=True) + + output_file = osp.join(metadata.eval_output_dir, 'output.jsonl') + prepared_dataset = prepare_dataset(gaia_tests, output_file, args.eval_n_limit) + + run_evaluation( + dataset=prepared_dataset, + metadata=metadata, + output_file=output_file, + num_workers=args.eval_num_workers, + process_instance_func=process_instance, + ) + + + +if __name__ == "__main__": + args = get_args() + main(args) + # print(check_container_exist('gaia_lite_eval_c61d22de-5f6c-4958-a7f6-5e9707bd3466')) \ No newline at end of file diff --git a/evaluation/gaia/scorer.py b/evaluation/gaia/scorer.py new file mode 100644 index 0000000..5a1cd1c --- /dev/null +++ b/evaluation/gaia/scorer.py @@ -0,0 +1,102 @@ +import re +import string +import warnings + + +def normalize_number_str(number_str: str) -> float: + # we replace these common units and commas to allow + # conversion to float + for char in ['$', '%', ',']: + number_str = number_str.replace(char, '') + try: + return float(number_str) + except ValueError: + print(f'String {number_str} cannot be normalized to number str.') + return float('inf') + + +def split_string( + s: str, + char_list: list[str] = None, +) -> list[str]: + if char_list is None: + char_list = [',', ';'] + pattern = f"[{''.join(char_list)}]" + return re.split(pattern, s) + + +def question_scorer( + model_answer: str, + ground_truth: str, +) -> bool: + def is_float(element: any) -> bool: + try: + float(element) + return True + except ValueError: + return False + + # if gt is a number + if is_float(ground_truth): + print(f'Evaluating {model_answer} as a number.') + normalized_answer = normalize_number_str(model_answer) + return normalized_answer == float(ground_truth) + + # if gt is a list + elif any(char in ground_truth for char in [',', ';']): + print(f'Evaluating {model_answer} as a comma separated list.') + # question with the fish: normalization removes punct + + gt_elems = split_string(ground_truth) + ma_elems = split_string(model_answer) + + # check length is the same + if len(gt_elems) != len(ma_elems): + warnings.warn( + 'Answer lists have different lengths, returning False.', + UserWarning, + stacklevel=2, + ) + return False + + # compare each element as float or str + comparisons = [] + for ma_elem, gt_elem in zip(ma_elems, gt_elems): + if is_float(gt_elem): + normalized_ma_elem = normalize_number_str(ma_elem) + comparisons.append(normalized_ma_elem == float(gt_elem)) + else: + # we do not remove punct since comparisons can include punct + comparisons.append( + normalize_str(ma_elem, remove_punct=False) + == normalize_str(gt_elem, remove_punct=False) + ) + return all(comparisons) + + # if gt is a str + else: + print(f'Evaluating {model_answer} as a string.') + return normalize_str(model_answer) == normalize_str(ground_truth) + + +def normalize_str(input_str, remove_punct=True) -> str: + """Normalize a string by: + - Removing all white spaces + - Optionally removing punctuation (if remove_punct is True) + - Converting to lowercase + Parameters: + - input_str: str, the string to normalize + - remove_punct: bool, whether to remove punctuation (default: True) + + Returns: + - str, the normalized string + """ + # Remove all white spaces. Required e.g for seagull vs. sea gull + no_spaces = re.sub(r'\s', '', input_str) + + # Remove punctuation, if specified. + if remove_punct: + translator = str.maketrans('', '', string.punctuation) + return no_spaces.lower().translate(translator) + else: + return no_spaces.lower() diff --git a/evaluation/gaia/scripts/run_infer.sh b/evaluation/gaia/scripts/run_infer.sh new file mode 100644 index 0000000..3b420d3 --- /dev/null +++ b/evaluation/gaia/scripts/run_infer.sh @@ -0,0 +1,12 @@ +current_dir=$(dirname "$(readlink -f "$0")") + +cd $current_dir +cd ../../../ +export DOCKER_WORKPLACE_NAME=workplace +export EVAL_MODE=True +export DEBUG=True +export BASE_IMAGES=tjbtech1/gaia-bookworm:v2 +export COMPLETION_MODEL=claude-3-5-sonnet-20241022 + +python evaluation/gaia/run_infer.py --container_name gaia_lite_eval --model ${COMPLETION_MODEL} --test_pull_name test_pull_1225 --debug --eval_num_workers 1 --port 12345 --data_split validation --level 2023_all --agent_func get_system_triage_agent --git_clone +# python /Users/tangjiabin/Documents/reasoning/metachain/test_gaia_tool.py \ No newline at end of file diff --git a/evaluation/gaia/test_imaes.py b/evaluation/gaia/test_imaes.py new file mode 100644 index 0000000..0ad3d9f --- /dev/null +++ b/evaluation/gaia/test_imaes.py @@ -0,0 +1,17 @@ + + +from metachain.environment.docker_env import DockerEnv, DockerConfig, check_container_ports, check_container_exist, check_container_running +from metachain.tools.files import create_file + +if __name__ == "__main__": + import os + os.environ["BASE_IMAGES"] = "tjbtech1/gaia-bookworm:amd64" + config = DockerConfig(container_name = "gaia_amd64_test", + workplace_name = "workplace_gaia_amd64_test", + communication_port = 12345, + conda_path = "/root/miniconda3" + ) + env = DockerEnv(config) + env.init_container() + res = create_file(path = 'test.py', content = 'print("hello world")', env = env) + print(res) \ No newline at end of file diff --git a/evaluation/math500/get_score.py b/evaluation/math500/get_score.py new file mode 100644 index 0000000..8daf9bc --- /dev/null +++ b/evaluation/math500/get_score.py @@ -0,0 +1,125 @@ +from pathlib import Path +from tqdm import tqdm +import multiprocessing +from copy import deepcopy +import re +from lm_eval.tasks.minerva_math.utils import ( + last_boxed_only_string, + normalize_final_answer, + get_unnormalized_answer, + remove_boxed, + is_equiv, +) + +import yaml +import argparse + +def load_yaml(path: Path): + with open(path, "r") as f: + data = yaml.load(f, Loader=yaml.CLoader) + + return data + + +def save_yaml(path: Path, data, sort_keys=True): + with open(path, "w") as f: + yaml.dump(data, f, sort_keys=sort_keys) + + +ANS_RE_GSM8k = re.compile(r"#### (\-?[\$0-9\.\,]+)") +INVALID_ANS_GSM8k = "[invalid]" +GSM8K_IGNORE_REGEXES = [",", "\\$", "\\.$"] + + +def filter_ignores(st, regexes_to_ignore): + if regexes_to_ignore is not None: + for s in regexes_to_ignore: + st = re.sub(s, "", st) + return st + + +def extract_answer_gsm8k(completion): + match = ANS_RE_GSM8k.search(completion) + if match: + match_str = match.group(1).strip() + match_str = filter_ignores( + match_str, + GSM8K_IGNORE_REGEXES, + ) + return match_str + else: + return INVALID_ANS_GSM8k + + +def is_correct_gsm8k(model_completion, gt_example): + gt_answer = extract_answer_gsm8k(gt_example) + assert gt_answer != INVALID_ANS_GSM8k + model_answer = extract_answer_gsm8k(model_completion) + return model_answer == gt_answer or is_equiv(model_answer, gt_answer) + +def my_get_unnormalized_answer(og_pred): + og_pred = get_unnormalized_answer(og_pred) + print(og_pred) + og_pred = re.sub(r"\\+[\(\[](.+?)\\+[\)\]]", "\\1", og_pred) + return og_pred + + +def is_correct_minerva(og_pred, gt): + pred = normalize_final_answer(my_get_unnormalized_answer(og_pred)) + print(pred) + print(gt) + # gt = normalize_final_answer(remove_boxed(last_boxed_only_string(gt))) + # string equality check needed because of https://github.com/EleutherAI/lm-evaluation-harness/issues/2212 + return pred == gt or is_equiv(pred, gt) + + + + +def is_correct(sample: str, gt_answer: str, dset: str): + if dset == "gsm8k": + return is_correct_gsm8k(sample, gt_answer) + elif dset == "math": + return is_correct_minerva(sample, gt_answer) + else: + raise ValueError(f"Dataset {dset} not supported") + + + + +def get_tasks(config): + sample_paths = Path(config.samples_dir).glob("*.yaml") + + tasks = [] + for sample_path in tqdm(sample_paths, desc="Loading generations"): + save_path = config.save_dir / sample_path.name + + task_config = deepcopy(config) + task_config.sample_path = sample_path + task_config.save_path = save_path + + tasks.append(task_config) + + return tasks + + +def main(args): + + tasks = Path(args.save_dir).glob("*.yaml") + + + corrects = [] + + for task in tqdm(tasks, desc="Evaluating"): + result = load_yaml(task) + + correct = is_correct(result["answer"], result["gt_answer"], "math") + corrects.append(correct) + + print(f"Accuracy: {sum(corrects) / len(corrects)}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--save_dir", type=str, default="evaluation_results/math500/math_solver") + args = parser.parse_args() + main(args) diff --git a/evaluation/math500/prompts.py b/evaluation/math500/prompts.py new file mode 100644 index 0000000..8793128 --- /dev/null +++ b/evaluation/math500/prompts.py @@ -0,0 +1,23 @@ +MATH_COT_PROMPT = """Problem: +Find the domain of the expression $\\frac{\\sqrt{x-2}}{\\sqrt{5-x}}$.} + +Solution: +The expressions inside each square root must be non-negative. Therefore, $x-2 \\ge 0$, so $x\\ge2$, and $5 - x \\ge 0$, so $x \\le 5$. Also, the denominator cannot be equal to zero, so $5-x>0$, which gives $x<5$. Therefore, the domain of the expression is $\\boxed{[2,5)}$.\nFinal Answer: The final answer is $[2,5)$. I hope it is correct. + +Problem: +If $\\det \\mathbf{A} = 2$ and $\\det \\mathbf{B} = 12,$ then find $\\det (\\mathbf{A} \\mathbf{B}).$ + +Solution: +We have that $\\det (\\mathbf{A} \\mathbf{B}) = (\\det \\mathbf{A})(\\det \\mathbf{B}) = (2)(12) = \\boxed{24}.$\nFinal Answer: The final answer is $24$. I hope it is correct. + +Problem: +Terrell usually lifts two 20-pound weights 12 times. If he uses two 15-pound weights instead, how many times must Terrell lift them in order to lift the same total weight? + +Solution: +If Terrell lifts two 20-pound weights 12 times, he lifts a total of $2\\cdot 12\\cdot20=480$ pounds of weight. If he lifts two 15-pound weights instead for $n$ times, he will lift a total of $2\\cdot15\\cdot n=30n$ pounds of weight. Equating this to 480 pounds, we can solve for $n$:\n\\begin{align*}\n30n&=480\\\n\\Rightarrow\\qquad n&=480/30=\\boxed{16}\n\\end{align*}\nFinal Answer: The final answer is $16$. I hope it is correct. + +Problem: +If the system of equations\n\n\\begin{align*}\n6x-4y&=a,\\\n6y-9x &=b.\n\\end{align*}has a solution $(x, y)$ where $x$ and $y$ are both nonzero,\nfind $\\frac{a}{b},$ assuming $b$ is nonzero. + +Solution: +If we multiply the first equation by $-\\frac{3}{2}$, we obtain\n\n$$6y-9x=-\\frac{3}{2}a.$$Since we also know that $6y-9x=b$, we have\n\n$$-\\frac{3}{2}a=b\\Rightarrow\\frac{a}{b}=\\boxed{-\\frac{2}{3}}.$$\nFinal Answer: The final answer is $-\\frac{2}{3}$. I hope it is correct.""" diff --git a/evaluation/math500/run_infer.py b/evaluation/math500/run_infer.py new file mode 100644 index 0000000..507e4d4 --- /dev/null +++ b/evaluation/math500/run_infer.py @@ -0,0 +1,121 @@ +import torch +from datasets import load_dataset +from tqdm import tqdm +import multiprocessing +import random +import requests +from functools import partial +import argparse +from pathlib import Path +import yaml +from metachain.agents.math.math_solver_agent import get_math_solver_agent +from metachain import MetaChain +from metachain.workflows.math_solver_workflow_flow import majority_voting +import importlib +import os +import asyncio +from evaluation.math500.prompts import MATH_COT_PROMPT + + +def save_yaml(path: Path, data, sort_keys=True): + with open(path, "w") as f: + yaml.dump(data, f, sort_keys=sort_keys) + +async def run_inference(item, save_dir, workflow): + + outpath = save_dir / f"{item['id']}.yaml" + if outpath.exists(): + return + + prompt = MATH_COT_PROMPT + f"\n\nProblem:\n{item['problem']}\n\nYour task is to solve this problem." + prompt += "Please given your final answer (answer ONLY) within the format of `Final Answer: The final answer is . I hope it is correct.` after your reasoning \n" + prompt += "For example: According to ...\nFinal Answer: The final answer is $24$. I hope it is correct.\n" + + if workflow == "majority_voting": + answer = await majority_voting(prompt) + elif workflow == None: + agent = get_math_solver_agent(model="deepseek/deepseek-chat") + client = MetaChain() + messages = [ + {"role": "user", "content": prompt}, + ] + context_variables = { + } + + response = await client.run_async(agent, messages, context_variables) + answer = response.messages[-1]['content'] + else: raise ValueError(f"Unknown workflow: {workflow}") + + out = { + "prompt": prompt, + "question": item["problem"], + "answer": answer, + "gt_answer": item["answer"], + } + + save_yaml(outpath, out) + + +async def main(args): + + test_dataset = list( + load_dataset( + "HuggingFaceH4/MATH-500", "default", split="test", trust_remote_code=True + ) + ) + + print(f"Number of test items: {len(test_dataset)}") + + random.seed(12345) + + + for i, data in enumerate(test_dataset): + data["id"] = i + + random.shuffle(test_dataset) + + if args.limit is not None: + limit = args.limit + else: + limit = len(test_dataset) + + if args.stride is not None: + stride = args.stride + else: + stride = 1 + + if args.offset is not None: + offset = args.offset + else: + offset = 0 + + test_dataset = test_dataset[offset:limit:stride] + + print(f"Total number of items to process: {len(test_dataset)}") + + if args.workflow == None: + save_dir = os.path.join(args.save_dir, "math_solver") + save_dir = Path(save_dir) + save_dir.mkdir(parents=True, exist_ok=True) + + else: + save_dir = os.path.join(args.save_dir, args.workflow) + save_dir = Path(save_dir) + save_dir.mkdir(parents=True, exist_ok=True) + + + + predictions = [] + for item in tqdm(test_dataset): + predictions.append(await run_inference(item, save_dir, args.workflow)) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--num_few_shot", type=int, default=2) + parser.add_argument("--limit", type=int, default=3) + parser.add_argument("--stride", type=int, default=1) + parser.add_argument("--offset", type=int, default=0) + parser.add_argument("--save_dir", type=str, default="evaluation_results/math500") + parser.add_argument("--workflow", type=str, default=None) + args = parser.parse_args() + asyncio.run(main(args)) \ No newline at end of file diff --git a/evaluation/types.py b/evaluation/types.py new file mode 100644 index 0000000..e4ab22f --- /dev/null +++ b/evaluation/types.py @@ -0,0 +1,65 @@ +from pydantic import BaseModel +from typing import Any, List +import json +import logging + +logger = logging.getLogger(__name__) + +class EvalMetadata(BaseModel): + agent_func: str + model: str + eval_output_dir: str + start_time: str + dataset: str | None = None + data_split: str | None = None + details: dict[str, Any] | None = None + container_name: str | None = None + port: int | None = None + git_clone: bool | None = None + test_pull_name: str | None = None + + def model_dump(self, *args, **kwargs): + dumped_dict = super().model_dump(*args, **kwargs) + # avoid leaking sensitive information + return dumped_dict + + def model_dump_json(self, *args, **kwargs): + dumped = super().model_dump_json(*args, **kwargs) + dumped_dict = json.loads(dumped) + logger.debug(f'Dumped metadata: {dumped_dict}') + return json.dumps(dumped_dict) + +class EvalOutput(BaseModel): + # NOTE: User-specified + instance_id: str + # output of the evaluation + # store anything that is needed for the score calculation + test_result: dict[str, Any] + + instruction: str | None = None + + # Interaction info + metadata: EvalMetadata | None = None + # list[tuple[dict[str, Any], dict[str, Any]]] - for compatibility with the old format + messages: List | None = None + error: str | None = None + + # Optionally save the input test instance + instance: dict[str, Any] | None = None + + def model_dump(self, *args, **kwargs): + dumped_dict = super().model_dump(*args, **kwargs) + # Remove None values + dumped_dict = {k: v for k, v in dumped_dict.items() if v is not None} + # Apply custom serialization for metadata (to avoid leaking sensitive information) + if self.metadata is not None: + dumped_dict['metadata'] = self.metadata.model_dump() + return dumped_dict + + def model_dump_json(self, *args, **kwargs): + dumped = super().model_dump_json(*args, **kwargs) + dumped_dict = json.loads(dumped) + # Apply custom serialization for metadata (to avoid leaking sensitive information) + if 'metadata' in dumped_dict: + dumped_dict['metadata'] = json.loads(self.metadata.model_dump_json()) + return json.dumps(dumped_dict) \ No newline at end of file diff --git a/evaluation/utils.py b/evaluation/utils.py new file mode 100644 index 0000000..019306a --- /dev/null +++ b/evaluation/utils.py @@ -0,0 +1,379 @@ +import os +import pathlib +import subprocess +import time +from typing import Any, TextIO, List, Dict +from .types import EvalMetadata, EvalOutput +import pandas as pd +import json +from typing import Callable, Awaitable +from tqdm import tqdm +from metachain.logger import MetaChainLogger, LoggerManager +import multiprocessing as mp +import psutil +import traceback +import socket +import queue # 添加这行导入 + +def make_metadata( + model: str, + dataset_name: str, + agent_func: str, + eval_note: str | None, + eval_output_dir: str, + data_split: str | None = None, + details: dict[str, Any] | None = None, + port: int | None = None, + container_name: str | None = None, + git_clone: bool = False, + test_pull_name: str | None = None, +) -> EvalMetadata: + eval_note = f'_N_{eval_note}' if eval_note else '' + + eval_output_path = os.path.join( + eval_output_dir, + dataset_name, + agent_func.replace('get_', ''), + f'{model}_maxiter{eval_note}', + ) + + pathlib.Path(eval_output_path).mkdir(parents=True, exist_ok=True) + pathlib.Path(os.path.join(eval_output_path, 'logs')).mkdir( + parents=True, exist_ok=True + ) + + metadata = EvalMetadata( + agent_func=agent_func, + model=model, + eval_output_dir=eval_output_path, + start_time=time.strftime('%Y-%m-%d %H:%M:%S'), + dataset=dataset_name, + data_split=data_split, + details=details, + port=port, + container_name=container_name, + git_clone=git_clone, + test_pull_name=test_pull_name, + ) + metadata_json = metadata.model_dump_json() + with open(os.path.join(eval_output_path, 'metadata.json'), 'w') as f: + f.write(metadata_json) + + return metadata + +def prepare_dataset( + dataset: pd.DataFrame, + output_file: str, + eval_n_limit: int, + eval_ids: list[str] | None = None, + skip_num: int | None = None, +): + assert ( + 'instance_id' in dataset.columns + ), "Expected 'instance_id' column in the dataset. You should define your own unique identifier for each instance and use it as the 'instance_id' column." + logger = LoggerManager.get_logger() + id_column = 'instance_id' + logger.info(f'Writing evaluation output to {output_file}') + finished_ids: set[str] = set() + if os.path.exists(output_file): + with open(output_file, 'r') as f: + for line in f: + data = json.loads(line) + finished_ids.add(str(data[id_column])) + logger.info( + f'\nOutput file {output_file} already exists. Loaded {len(finished_ids)} finished instances.', title='Warning', color='red' + ) + + if eval_ids: + eval_ids_converted = [dataset[id_column].dtype.type(id) for id in eval_ids] + dataset = dataset[dataset[id_column].isin(eval_ids_converted)] + logger.info(f'Limiting evaluation to {len(eval_ids)} specific instances.') + elif skip_num and skip_num >= 0: + skip_num = min(skip_num, len(dataset)) + dataset = dataset.iloc[skip_num:] + logger.info( + f'Starting evaluation with skipping first {skip_num} instances ({len(dataset)} instances to run).' + ) + if eval_n_limit and eval_n_limit > 0: + dataset = dataset.head(eval_n_limit) + logger.info(f'Limiting evaluation to {eval_n_limit} instances.') + elif eval_n_limit and eval_n_limit > 0: + dataset = dataset.head(eval_n_limit) + logger.info(f'Limiting evaluation to first {eval_n_limit} instances.') + + new_dataset = [ + instance + for _, instance in dataset.iterrows() + if str(instance[id_column]) not in finished_ids + ] + logger.info( + f'Finished instances: {len(finished_ids)}, Remaining instances: {len(new_dataset)}' + ) + + return pd.DataFrame(new_dataset) +def _process_and_queue(process_instance_func, instance, metadata, use_mp, max_retries, queue): + """包装函数,将结果放入队列""" + try: + result = _process_instance_wrapper( + process_instance_func, instance, metadata, use_mp, max_retries + ) + queue.put(result) + except Exception as e: + print(f"Error processing instance {instance.get('instance_id', 'unknown')}: {str(e)}") + traceback.print_exc() + # 在发生错误时也要把错误结果放入队列,避免主进程等待 + queue.put(None) # 或者放入一个表示错误的特殊值 + # finally: + # # 确保子进程中的资源被释放 + # queue.close() + +def run_evaluation( + dataset: pd.DataFrame, + metadata: EvalMetadata | None, + output_file: str, + num_workers: int, + process_instance_func: Callable[ + [pd.Series, EvalMetadata, bool], Awaitable[EvalOutput] + ], + max_retries: int = 3, # number of retries for each instance +): + logger = LoggerManager.get_logger() + use_multiprocessing = num_workers > 1 + + if metadata is not None: + logger.info( + f'Evaluation started with Agent {metadata.agent_func}\n' + ) + else: + logger.info('Running evaluation without metadata.', title='Warning', color='red') + logger.info(f'Evaluation started with {num_workers} workers.') + + total_instances = len(dataset) + pbar = tqdm(total=total_instances, desc='Instances processed') + output_fp = open(output_file, 'a') + + try: + if use_multiprocessing: + # 使用队列来收集结果 + results_queue = mp.Queue() + active_processes = [] + instances_iter = dataset.iterrows() + instances_completed = 0 + + while instances_completed < total_instances: + # 启动新进程,直到达到worker数量限制 + while len(active_processes) < num_workers and instances_completed < total_instances: + try: + _, instance = next(instances_iter) + # 创建非守护进程 + p = mp.Process( + target=_process_and_queue, + args=(process_instance_func, instance, metadata, True, max_retries, results_queue), + daemon=False # 关键:设置为非守护进程 + ) + p.start() + time.sleep(3) + active_processes.append((p, time.time())) # 记录进程启动时间 + except StopIteration: + break + + # 检查完成的进程 + for p, start_time in active_processes[:]: + if not p.is_alive(): + try: + # 给进程1分钟时间来清理资源 + p.join(timeout=60) + if p.is_alive(): + logger.warning(f"Process {p.pid} cleanup timeout, force terminating...") + p.terminate() + p.join(timeout=5) + if p.is_alive(): + p.kill() + except Exception as e: + logger.warning(f"Error cleaning up process {p.pid}: {str(e)}") + p.kill() + finally: + active_processes.remove((p, start_time)) + + # 处理队列中的结果 + try: + while not results_queue.empty(): + result = results_queue.get_nowait() + update_progress(result, pbar, output_fp) + instances_completed += 1 + except Exception as e: + logger.error(f"Error processing results: {str(e)}") + + time.sleep(0.1) # 避免过度占用CPU + + # 清理剩余进程 + logger.info("Cleaning up remaining processes...") + for p, _ in active_processes: + try: + # 给进程一个较短的超时时间 + p.join(timeout=5) + if p.is_alive(): + p.terminate() + p.join(timeout=1) + if p.is_alive(): + p.kill() + except Exception as e: + logger.info(f"Error cleaning up process {p.pid}: {str(e)}", title='warning', color='red') + try: + p.kill() + except: + pass + + # 快速清空队列 + try: + while True: + try: + result = results_queue.get_nowait() + update_progress(result, pbar, output_fp) + instances_completed += 1 + except queue.Empty: + break + except Exception as e: + logger.info(f"Error processing final results: {str(e)}", title='Warning', color='red') + else: + for _, instance in dataset.iterrows(): + result = _process_instance_wrapper( + process_instance_func=process_instance_func, + instance=instance, + metadata=metadata, + use_mp=False, + max_retries=max_retries, + ) + update_progress(result, pbar, output_fp) + + except KeyboardInterrupt: + print('\nKeyboardInterrupt received. Cleaning up...\n') + if use_multiprocessing: + for p, _ in active_processes: + try: + p.terminate() + p.join(timeout=1) + except Exception: + p.kill() + cleanup() + finally: + # 确保资源被释放 + output_fp.close() + if use_multiprocessing: + results_queue.close() + results_queue.join_thread() + + output_fp.close() + logger.info('\nEvaluation finished.\n') +def _process_instance_wrapper_mp(args): + """Wrapper for multiprocessing, especially for imap_unordered.""" + return _process_instance_wrapper(*args) + +def _process_instance_wrapper( + process_instance_func: Callable[[pd.Series, EvalMetadata, bool], EvalOutput], + instance: pd.Series, + metadata: EvalMetadata, + use_mp: bool, + max_retries: int = 5, +) -> EvalOutput: + """Wrap the process_instance_func to handle retries and errors. + + Retry an instance up to max_retries times if it fails (e.g., due to transient network/runtime issues). + """ + if use_mp: + log_path = os.path.join(metadata.eval_output_dir, 'logs', f'agent_{metadata.model}_did_{instance["instance_id"]}.log') + logger = MetaChainLogger(log_path) + else: + logger = LoggerManager.get_logger() + for attempt in range(max_retries + 1): + try: + result = process_instance_func(instance, metadata, logger) + return result + except Exception as e: + error = str(e) + stacktrace = traceback.format_exc() + if attempt == max_retries: + logger.info(error, title='Error', color='red') + msg = ( + '-' * 10 + + '\n' + + f'Error in instance [{instance.instance_id}]: {error}. Stacktrace:\n{stacktrace}' + + '\n' + + f'[Encountered after {max_retries} retries. Please check the logs and report the issue.]' + + '-' * 10 + ) + # Raise an error after all retries & stop the evaluation + logger.info(error, title='Error', color='red') + raise RuntimeError( + f'Maximum error retries reached for instance {instance.instance_id}' + ) from e + msg = ( + '-' * 10 + + '\n' + + f'Error in instance [{instance.instance_id}]: {error}. Stacktrace:\n{stacktrace}' + + '\n' + + '-' * 10 + + f'[The above error occurred. Retrying... (attempt {attempt + 1} of {max_retries})]' + + '-' * 10 + + '\n' + ) + logger.info(msg, title='Error', color='red') + if use_mp: + print(msg) # use print to directly print to console + time.sleep(5) + +def update_progress( + result: EvalOutput, + pbar: tqdm, + output_fp: TextIO, +): + """Update the progress bar and write the result to the output file.""" + logger = LoggerManager.get_logger() + pbar.update(1) + pbar.set_description(f'Instance {result.instance_id}') + pbar.set_postfix_str(f'Test Result: {str(result.test_result)[:300]}...') + logger.info( + f'Finished evaluation for instance {result.instance_id}: {str(result.test_result)[:300]}...\n' + ) + output_fp.write(json.dumps(result.model_dump()) + '\n') + output_fp.flush() + +def cleanup(): + print('Cleaning up child processes...') + for process in mp.active_children(): + print(f'Terminating child process: {process.name}') + process.terminate() + process.join() + + +def check_port_available(port): + """check if the port is available""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + # set the port reuse option + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # try to bind the port + s.bind(('0.0.0.0', port)) + # immediately close the connection + s.close() + return True # the port is available + except socket.error: + return False # the port is not available + +def clean_msg(msg: List[Dict[str, Any]]): + new_msg = [] + for m in msg: + msg_content = m['content'] + if isinstance(msg_content, str): + m['content'] = msg_content + new_msg.append(m.copy()) + elif isinstance(msg_content, List): + new_content = [] + for c in msg_content: + if c['type'] == 'text': + new_content.append(c.copy()) + elif c['type'] == 'image_url': + new_content.append({'type': 'image_url', 'image_url': 'placeholder'}) + m['content'] = new_content + new_msg.append(m.copy()) + return new_msg diff --git a/loop_utils/font_page.py b/loop_utils/font_page.py new file mode 100644 index 0000000..9f29075 --- /dev/null +++ b/loop_utils/font_page.py @@ -0,0 +1,44 @@ +from rich.table import Table +from rich.console import Console +from rich.text import Text +from rich.panel import Panel +from rich.style import Style +from rich.console import Console +from rich.box import DOUBLE +from rich.markdown import Markdown +MC_LOGO = """\ +███╗ ███╗███████╗████████╗ █████╗ ██████╗██╗ ██╗ █████╗ ██╗███╗ ██╗ +████╗ ████║██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██║██╔══██╗██║████╗ ██║ +██╔████╔██║█████╗ ██║ ███████║██║ ███████║███████║██║██╔██╗ ██║ +██║╚██╔╝██║██╔══╝ ██║ ██╔══██║██║ ██╔══██║██╔══██║██║██║╚██╗██║ +██║ ╚═╝ ██║███████╗ ██║ ██║ ██║╚██████╗██║ ██║██║ ██║██║██║ ╚████║ +╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ +╔═══ 𝒞𝓇𝑒𝒶𝓉𝑒 𝒜𝑔𝑒𝓃𝓉𝒾𝒸 𝒜ℐ 𝓊𝓈𝒾𝓃𝑔 ℒ𝒶𝓃𝑔𝓊𝒶𝑔𝑒 ═══╗ +""".strip() + +version_table = Table(show_header=False, box=DOUBLE, expand=True) +version_table.add_column("Key", style="cyan") +version_table.add_column("Value", style="green") + +version_table.add_row("Version", "0.1.0") +version_table.add_row("Author", "MetaChain Team@HKU") +version_table.add_row("License", "MIT") + +NOTES = """\ +* Choose `user mode` if you just want to let a general yet powerful AI Assistant to help you +* Choose `agent editor` to create your own AI Agent with language. +* Choose `workflow editor` to create your own AI Workflow with language. +* Choose `exit` to exit the program +""" +NOTES = Markdown(NOTES) + +GOODBYE_LOGO = """\ +███████╗███████╗███████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ +██╔════╝██╔════╝██╔════╝ ╚██╗ ██╔╝██╔═══██╗██║ ██║ +███████╗█████╗ █████╗ ╚████╔╝ ██║ ██║██║ ██║ +╚════██║██╔══╝ ██╔══╝ ╚██╔╝ ██║ ██║██║ ██║ +███████║███████╗███████╗ ██║ ╚██████╔╝╚██████╔╝ +╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ +· 𝓜𝓮𝓽𝓪𝓒𝓱𝓪𝓲𝓷-𝓐𝓘 · +""".strip() + diff --git a/metachain/__init__.py b/metachain/__init__.py new file mode 100644 index 0000000..2aa4d1c --- /dev/null +++ b/metachain/__init__.py @@ -0,0 +1,9 @@ +from .core import MetaChain +from .types import Agent, Response +# from .workflow import Graph, meta_workflow, FlowEngine +from .flow import default_drive + +import metachain.workflows +import metachain.tools +import metachain.agents +__all__ = ["MetaChain", "Agent", "Response", "default_drive", ] diff --git a/metachain/agents/__init__.py b/metachain/agents/__init__.py new file mode 100644 index 0000000..61aa4ea --- /dev/null +++ b/metachain/agents/__init__.py @@ -0,0 +1,64 @@ +# from metachain.agents.programming_agent import get_programming_agent +# from metachain.agents.tool_retriver_agent import get_tool_retriver_agent +# from metachain.agents.agent_check_agent import get_agent_check_agent +# from metachain.agents.tool_check_agent import get_tool_check_agent +# from metachain.agents.github_agent import get_github_agent +# from metachain.agents.programming_triage_agent import get_programming_triage_agent +# from metachain.agents.plan_agent import get_plan_agent + +# import os +# import importlib +# from metachain.registry import registry + +# # 获取当前目录下的所有 .py 文件 +# current_dir = os.path.dirname(__file__) +# for file in os.listdir(current_dir): +# if file.endswith('.py') and not file.startswith('__'): +# module_name = file[:-3] +# importlib.import_module(f'metachain.agents.{module_name}') + +# # 导出所有注册的 agent 创建函数 +# globals().update(registry.agents) + +# __all__ = list(registry.agents.keys()) + +import os +import importlib +from metachain.registry import registry + +def import_agents_recursively(base_dir: str, base_package: str): + """Recursively import all agents in .py files + + Args: + base_dir: the root directory to start searching + base_package: the base name of the Python package + """ + for root, dirs, files in os.walk(base_dir): + # get the relative path to the base directory + rel_path = os.path.relpath(root, base_dir) + + for file in files: + if file.endswith('.py') and not file.startswith('__'): + # build the module path + if rel_path == '.': + # in the root directory + module_path = f"{base_package}.{file[:-3]}" + else: + # in the subdirectory + package_path = rel_path.replace(os.path.sep, '.') + module_path = f"{base_package}.{package_path}.{file[:-3]}" + + try: + importlib.import_module(module_path) + except Exception as e: + print(f"Warning: Failed to import {module_path}: {e}") + +# get the current directory and import all agents +current_dir = os.path.dirname(__file__) +import_agents_recursively(current_dir, 'metachain.agents') + +# export all agent creation functions +globals().update(registry.agents) +globals().update(registry.plugin_agents) + +__all__ = list(registry.agents.keys()) \ No newline at end of file diff --git a/metachain/agents/agent_check_agent.py b/metachain/agents/agent_check_agent.py new file mode 100644 index 0000000..597d404 --- /dev/null +++ b/metachain/agents/agent_check_agent.py @@ -0,0 +1,25 @@ +from metachain.types import Agent +from metachain.tools import ( + get_api_plugin_tools_doc, check_agent +) +from metachain.registry import register_agent + +@register_agent(name = "Agent Check Agent", func_name="get_agent_check_agent") +def get_agent_check_agent(model: str): + def instructions(context_variables): + return \ + f"""You are a developer working on a project named 'metachain'. + You are given a user request and required to use existing project code to solve the task. + Your goal is to enrich the functionality of of existing list of agents in the `agents` folder as much as possible, so that once the similar task occurs again, the agent can solve it directly without developing new agents. + whether you should develop a new agent to solve the task. + If you have already have an pre-built agent in the `agents` folder and suitable actions in the `actions` folder you could use with it, you should not develop a new agent. + Note that the key of agent is the apprioriate `instructions` and `functions` using existing tools. + Answer 'Needed' or 'Not needed' first and then give your reason. + """ + return Agent( + name="Agent Check Agent", + model=model, + instructions=instructions, + functions=[check_agent], + parallel_tool_calls = False + ) diff --git a/metachain/agents/browsing_agent.py b/metachain/agents/browsing_agent.py new file mode 100644 index 0000000..cfc9412 --- /dev/null +++ b/metachain/agents/browsing_agent.py @@ -0,0 +1,147 @@ +from metachain.types import Agent +from metachain.registry import register_agent +from browsergym.core.action.highlevel import HighLevelActionSet +from metachain.util import function_to_json +import gymnasium as gym +import browsergym.miniwob # register miniwob tasks as gym environments +import importlib +import json +from functools import wraps +from typing import Callable, Union +from metachain.environment.browser_env import BrowserEnv +import inspect +from metachain.types import Result +from browsergym.utils.obs import flatten_axtree_to_str +def get_error_prefix(last_browser_action: str) -> str: + return f'IMPORTANT! Last action is incorrect:\n{last_browser_action}\nThink again with the current observation of the page.\n' +def wrap_browser_action(action_func: Callable, env: BrowserEnv) -> Callable: + """ + 包装浏览器动作函数,使其能与环境交互 + + Args: + action_func: 原始的浏览器动作函数 + + Returns: + 包装后的函数,可以与环境交互 + """ + @wraps(action_func) + def wrapper(*args, **kwargs) -> Union[Result, str]: + error_prefix = "" + try: + # 执行动作 + # action = action_func(*args, **kwargs) + action_str = f"{action_func.__name__}({', '.join([f'{repr(v)}' for k, v in kwargs.items()])})" + + # 与环境交互 + obs = env.step(action_str) + + # 返回观察结果 + obs_dict = dict( + content=obs['text_content'], # text content of the page + url=obs.get('url', ''), # URL of the page + screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png + open_pages_urls=obs.get('open_pages_urls', []), # list of open pages + active_page_index=obs.get( + 'active_page_index', -1 + ), # index of the active page + dom_object=obs.get('dom_object', {}), # DOM object + axtree_object=obs.get('axtree_object', {}), # accessibility tree object + extra_element_properties=obs.get('extra_element_properties', {}), + focused_element_bid=obs.get( + 'focused_element_bid', None + ), # focused element bid + last_browser_action=obs.get( + 'last_action', '' + ), # last browser env action performed + last_browser_action_error=obs.get('last_action_error', ''), + error=True if obs.get('last_action_error', '') else False, # error flag + ) + except Exception as e: + obs_dict = dict( + content=str(e), + screenshot='', + error=True, + last_browser_action_error=str(e), + ) + + if obs_dict['error']: + # add error recovery prompt prefix + error_prefix = get_error_prefix(obs_dict['last_browser_action']) + # self.error_accumulator += 1 + # if self.error_accumulator > 5: + # return MessageAction('Too many errors encountered. Task failed.') + + cur_url = obs_dict['url'] + + try: + cur_axtree_txt = flatten_axtree_to_str( + obs_dict['axtree_object'], + extra_properties=obs_dict['extra_element_properties'], + with_clickable=True, + filter_visible_only=True, + ) + except Exception as e: + print( + 'Error when trying to process the accessibility tree: %s', e + ) + return 'Error encountered when browsing.' + ret_value = f"""\ +{error_prefix} + +# Current Page URL: +{cur_url} + +# Current Accessibility Tree: +{cur_axtree_txt} + +Here is an example with chain of thought of a valid action when clicking on a button: +" +In order to accomplish my goal I need to click on the button with bid 12 +```click("12")``` +" +""".strip() + return Result( + value=ret_value, + image=obs_dict['screenshot'], + ) + + # 保留原函数的签名和文档 + wrapper.__signature__ = inspect.signature(action_func) + wrapper.__doc__ = action_func.__doc__ + + return wrapper +@register_agent(name = "Browsing Agent", func_name="get_browsing_agent") +def get_browsing_agent(model: str): + env = BrowserEnv() + demo_mode = "off" + action_set = HighLevelActionSet( + subsets=["chat", "nav", "bid"], # define a subset of the action space + # subsets=["chat", "bid", "coord", "infeas"] # allow the agent to also use x,y coordinates + strict=False, # less strict on the parsing of the actions + multiaction=False, # does not enable the agent to take multiple actions at once + demo_mode=demo_mode, # add visual effects + ) + func_list = [act for act in action_set.action_set.keys()] + func_module = importlib.import_module("browsergym.core.action.functions") + func_list = [getattr(func_module, func) for func in func_list] + wrap_func_list = [wrap_browser_action(func, env) for func in func_list] + def instructions(context_variables): + goal = context_variables.get("goal", "") + action_space = action_set.describe(with_long_description=False, with_examples=True) + return \ +f"""Review the current state of the page and all other information to find the best +possible next action to accomplish your goal. Your answer will be interpreted +and executed by a program, make sure to follow the formatting instructions. + +# Goal: +{goal} + +# Action Space +{action_space} +""" + return Agent( + name="Browsing Agent", + model=model, + instructions=instructions, + functions=wrap_func_list + ) diff --git a/metachain/agents/dummy_agent.py b/metachain/agents/dummy_agent.py new file mode 100644 index 0000000..e767af3 --- /dev/null +++ b/metachain/agents/dummy_agent.py @@ -0,0 +1,36 @@ +from metachain.types import Agent +from metachain.tools import tool_dummy +from typing import Union +from metachain.registry import register_plugin_agent # import the register_agent function from the registry + +@register_plugin_agent(name = "Dummy Agent", func_name="get_dummy_agent") # You must register the agent in the registry, otherwise the agent will not be loaded. The name of register_agent is get_xxx_agent. +def get_dummy_agent(model: str): + """ + This is a dummy agent, it's used for demonstrating the usage of the metachain. + Args: + model: The model to be used for the agent. + Returns: + An agent instance. + """ + def dummy_instructions(context_variables: dict): + """ + The function should take the context_variables as an argument, and return a string. The context_variables is a dictionary, and it's track the important variables of the agent in the whole conversation. + The instructions should be concise and clear, and it's very important for the agent to follow the instructions. + """ + tmp_variables = context_variables.get("tmp_variables", {}) + return f"""...""" + return Agent( + name="Dummy Agent", # The name of the agent, you can change it in different scenes. + model=model, # The default model is gpt-4o-2024-08-06, you can change it to other models if user specified. + instructions="..." or dummy_instructions, # the instructions of the agent, the instructions can be a string or a function that returns a string. If it is a function, the function should take the context_variables as an argument, and return a string. The instructions should be concise and clear, and it's very important for the agent to follow the instructions. + functions=[tool_dummy], # The tools of the agent, you can add different tools in different scenes. + ) + +""" +Form to create an agent: + +agent_name = "Dummy Agent" +agent_description = "This is a dummy agent, it's used for demonstrating the usage of the metachain." +agent_instructions = "..." | "...{global_variables}..." +agent_tools = [tool_dummy] +""" \ No newline at end of file diff --git a/metachain/agents/github_agent.py b/metachain/agents/github_agent.py new file mode 100644 index 0000000..d35db5a --- /dev/null +++ b/metachain/agents/github_agent.py @@ -0,0 +1,25 @@ +from metachain.types import Agent +from metachain.tools import ( + push_changes, submit_pull_request +) +from metachain.registry import register_agent + + +@register_agent(name = "Github Agent", func_name="get_github_agent") +def get_github_agent(model: str): + def instructions(context_variables): + return \ +f"""You are an agent that helps user to manage the GitHub repository named 'metachain'. +The user will give you the suggestion of the changes to be pushed to the repository. +Follow the following routine with the user: +1. First, use `push_changes` to push the changes to the repository. (If the user want to push all the changes, use `push_changes` with `file_paths=None` as the argument.) +2. Then, ask the user whether to submit a pull request to a target branch. (If yes, give the `target_branch`) +3. If the user wants to submit a pull request, use `submit_pull_request` to submit the pull request, if not, just ignore this step. +""" + return Agent( + name="Github Agent", + model=model, + instructions=instructions, + functions=[push_changes, submit_pull_request], + parallel_tool_calls = False + ) diff --git a/metachain/agents/math/condition_extraction_agent.py.back b/metachain/agents/math/condition_extraction_agent.py.back new file mode 100644 index 0000000..72a697c --- /dev/null +++ b/metachain/agents/math/condition_extraction_agent.py.back @@ -0,0 +1,12 @@ +from metachain import Agent +from metachain.registry import register_plugin_agent +@register_plugin_agent(name="Condition Extraction Agent", func_name = "get_condition_extraction_agent") +def get_condition_extraction_agent(model): + instruction = """ + This agent is specialized in identifying and extracting all relevant conditions, given values, and constraints from the math problem. + """ + return Agent( + name="Condition Extraction Agent", + description=instruction, + model=model, + ) \ No newline at end of file diff --git a/metachain/agents/math/math_solver_agent.py b/metachain/agents/math/math_solver_agent.py new file mode 100644 index 0000000..457480b --- /dev/null +++ b/metachain/agents/math/math_solver_agent.py @@ -0,0 +1,17 @@ +from metachain.types import Agent + +from metachain.registry import register_plugin_agent + +@register_plugin_agent(name="Math Solver Agent", func_name="get_math_solver_agent") +def get_math_solver_agent(model: str): + ''' + This agent solves mathematical problems using analytical and systematic approaches. + ''' + instructions = 'You are responsible for solving mathematical problems using a systematic approach. You should:\n1. Use the provided conditions and objective to formulate a solution strategy\n2. Break down complex problems into smaller steps\n3. Apply appropriate mathematical concepts and formulas\n4. Show clear step-by-step work and explanations\n5. Verify the solution matches the problem requirements' + return Agent( + name="Math Solver Agent", + model=model, + instructions=instructions, + functions=[] + ) + diff --git a/metachain/agents/math/math_solver_agent.py.back b/metachain/agents/math/math_solver_agent.py.back new file mode 100644 index 0000000..ed1d139 --- /dev/null +++ b/metachain/agents/math/math_solver_agent.py.back @@ -0,0 +1,12 @@ +from metachain import Agent +from metachain.registry import register_plugin_agent +@register_plugin_agent(name="Math Solver Agent", func_name = "get_math_solver_agent") +def get_math_solver_agent(model): + instruction = """ + This agent is specialized in evaluating whether conditions are sufficient and solving math problems using appropriate mathematical methods. + """ + return Agent( + name="Math Solver Agent", + description=instruction, + model=model, + ) \ No newline at end of file diff --git a/metachain/agents/math/objective_extraction_agent.py.back b/metachain/agents/math/objective_extraction_agent.py.back new file mode 100644 index 0000000..c3aadf7 --- /dev/null +++ b/metachain/agents/math/objective_extraction_agent.py.back @@ -0,0 +1,12 @@ +from metachain import Agent +from metachain.registry import register_plugin_agent +@register_plugin_agent(name="Objective Extraction Agent", func_name = "get_objective_extraction_agent") +def get_objective_extraction_agent(model): + instruction = """ + This agent is specialized in analyzing math problems and extracting the main objective or question being asked. + """ + return Agent( + name="Objective Extraction Agent", + description=instruction, + model=model, + ) \ No newline at end of file diff --git a/metachain/agents/math/result_aggregator_agent.py.back b/metachain/agents/math/result_aggregator_agent.py.back new file mode 100644 index 0000000..2d34826 --- /dev/null +++ b/metachain/agents/math/result_aggregator_agent.py.back @@ -0,0 +1,12 @@ +from metachain import Agent +from metachain.registry import register_plugin_agent +@register_plugin_agent(name="Result Aggregator Agent", func_name = "get_result_aggregator_agent") +def get_result_aggregator_agent(model): + instruction = """ + This agent is specialized in aggregating results from different models and determining the final answer through majority voting. + """ + return Agent( + name="Math Solver Agent", + description=instruction, + model=model, + ) \ No newline at end of file diff --git a/metachain/agents/math/vote_aggregator_agent.py b/metachain/agents/math/vote_aggregator_agent.py new file mode 100644 index 0000000..c8aa8b5 --- /dev/null +++ b/metachain/agents/math/vote_aggregator_agent.py @@ -0,0 +1,17 @@ +from metachain.types import Agent + +from metachain.registry import register_plugin_agent + +@register_plugin_agent(name="Vote Aggregator Agent", func_name="get_vote_aggregator_agent") +def get_vote_aggregator_agent(model: str): + ''' + This agent aggregates solutions from different solvers and determines the final answer through majority voting. + ''' + instructions = 'You are a solution aggregator specializing in combining and analyzing multiple solutions to determine the most accurate answer. Your responsibilities include:\n\n1. Carefully review all provided solutions\n2. Compare the reasoning and calculations in each solution\n3. Identify commonalities and differences between solutions\n4. Implement majority voting when solutions differ\n5. Evaluate the confidence level of each solution\n6. Provide justification for the final selected answer\n\nWhen aggregating solutions:\n1. List all solutions received\n2. Compare the approach and methodology used in each\n3. Identify the final answer from each solution\n4. Apply majority voting to determine the consensus\n5. If no clear majority, analyze the reasoning quality to break ties\n6. Present the final selected answer with explanation of the selection process' + return Agent( + name="Vote Aggregator Agent", + model=model, + instructions=instructions, + functions=[] + ) + diff --git a/metachain/agents/meta_agent/agent_creator.py b/metachain/agents/meta_agent/agent_creator.py new file mode 100644 index 0000000..a96f20e --- /dev/null +++ b/metachain/agents/meta_agent/agent_creator.py @@ -0,0 +1,78 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_agents import list_agents, create_agent, delete_agent, run_agent, read_agent, create_orchestrator_agent +from metachain.tools.meta.edit_tools import list_tools, create_tool, delete_tool, run_tool +from metachain.tools.terminal_tools import execute_command, terminal_page_down, terminal_page_up, terminal_page_to +from metachain.types import Agent +from metachain.io_utils import read_file + + +@register_agent(name = "Agent Creator Agent", func_name="get_agent_creator_agent") +def get_agent_creator_agent(model: str) -> str: + """ + The agent creator is an agent that can be used to create the agents. + """ + def instructions(context_variables): + return f"""\ +You are an Agent Creator specialized in the MetaChain framework. Your primary responsibility is to create, manage, and orchestrate agents based on XML-formatted agent forms. + +CORE RESPONSIBILITIES: +1. Parse and implement agent forms +2. Create and manage individual agents +3. Orchestrate multi-agent systems +4. Handle dependencies and system requirements + +AVAILABLE FUNCTIONS: +1. Agent Management: + - `create_agent`: Create new agents or update existing ones strictly following the given agent form. + - `read_agent`: Retrieve existing agent definitions. Note that if you want to use `create_agent` to update an existing agent, you MUST use the `read_agent` function to get the definition of the agent first. + - `delete_agent`: Remove unnecessary agents. + - `list_agents`: Display all available agents and their information. + - `create_orchestrator_agent`: Create orchestrator for multi-agent systems. If the request is to create MORE THAN ONE agent, after you create ALL required agents, you MUST use the `create_orchestrator_agent` function to create an orchestrator agent that can orchestrate the workflow of the agents. And then use the `run_agent` function to run the orchestrator agent to complete the user task. + +2. Execution: + - run_agent: Execute agent to complete the user task. The agent could be a single agent (single agent form) or an orchestrator agent (multi-agent form). + - execute_command: Handle system dependencies and requirements + - terminal_page_down: Move the terminal page down when the terminal output is too long. + - terminal_page_up: Move the terminal page up when the terminal output is too long. + - terminal_page_to: Move the terminal page to the specific page when the terminal output is too long, and you want to move to the specific page with the meaningful content. + +WORKFLOW GUIDELINES: + +1. Single Agent Implementation: + - Carefully read the agent form and understand the requirements. + - Create/update agent using create_agent + - Execute task using run_agent + - Monitor and handle any errors + +2. Multi-Agent Implementation: + - Create all required agents individually using `create_agent` + - MUST create an orchestrator agent using `create_orchestrator_agent` + - Execute task through the `run_agent` function to execute the created orchestrator agent + - Monitor system performance + +3. Error Handling: + - Check for missing dependencies using `execute_command` + - Install required packages using execute_command + - Validate agent creation and execution + - Report any issues clearly + +BEST PRACTICES: +1. Always verify existing agents using `read_agent` before updates +2. Create orchestrator agents for ANY multi-agent scenario using `create_orchestrator_agent` +3. Handle dependencies proactively using `execute_command` +4. Maintain clear documentation of created agents +5. Follow the exact specifications from the agent form XML + +Remember: Your success is measured by both the accurate creation of agents and their effective execution of the given tasks. +""" + tool_list = [list_agents, create_agent, delete_agent, run_agent, execute_command, read_agent, create_orchestrator_agent, terminal_page_down, terminal_page_up, terminal_page_to] + return Agent( + name="Agent Creator Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) + + diff --git a/metachain/agents/meta_agent/agent_editor.py b/metachain/agents/meta_agent/agent_editor.py new file mode 100644 index 0000000..738be60 --- /dev/null +++ b/metachain/agents/meta_agent/agent_editor.py @@ -0,0 +1,38 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_agents import list_agents, create_agent, delete_agent, run_agent +from metachain.tools.terminal_tools import execute_command +from metachain.types import Agent +from metachain.io_utils import read_file + +@register_agent(name = "Agent Editor Agent", func_name="get_agent_editor_agent") +def get_agent_editor_agent(model: str) -> str: + """ + The agent editor is an agent that can be used to edit the agents. + """ + def instructions(context_variables): + return f"""\ +You are an agent editor agent that can be used to edit the agents. You are working on a Agent framework named MetaChain, and your responsibility is to edit the agents in the MetaChain, so that the agents can be used to help the user with their request. + +The existing agents are shown below: +{list_agents(context_variables)} + +If you want to create a new agent, you should: +1. follow the format of the `get_dummy_agent` below: +```python +{read_file('metachain/agents/dummy_agent.py')} +``` +2. you successfully create the agent only after you have successfully run the agent with the `run_agent` function to satisfy the user's request. + +3. If you encounter any error while creating and running the agent, like dependency missing, you should use the `execute_command` function to install the dependency. + +[IMPORTANT] The `register_plugin_agent` registry function is strictly required for a agent implementation to be recognized by the MetaChain framework. +""" + tool_list = [list_agents, create_agent, delete_agent, run_agent, execute_command] + return Agent( + name="Agent Editor Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) diff --git a/metachain/agents/meta_agent/agent_form/customer_service.xml b/metachain/agents/meta_agent/agent_form/customer_service.xml new file mode 100644 index 0000000..c555797 --- /dev/null +++ b/metachain/agents/meta_agent/agent_form/customer_service.xml @@ -0,0 +1,68 @@ + + + The user request from the specific user about the product or service, mainly categorized into 2 types: + - Purchase a product or service + - Refund a product or service + + + response + The response of the agent to the user's request. + + + + user_name + The name of the user. + John Doe + + + + Personal Sales Agent + The personal sales agent is an agent that serves as a personal sales agent for a specific user. + You are a personal sales agent that can be used to help the user {user_name} with their request. + + + recommend_product + Recommend a product to the user. + + + recommend_service + Recommend a service to the user. + + + conduct_sales + Conduct sales with the user. + + + + user_request + Request from the specific user for purchasing a product or service. + + + response + The response of the agent to the user's request. + + + + Personal Refunds Agent + The personal refunds agent is an agent that serves as a personal refunds agent for a specific user. + Help the user {user_name} with a refund. If the reason is that it was too expensive, offer the user a discount. If they insist, then process the refund. + + + process_refund + Refund an item. Refund an item. Make sure you have the item_id of the form item_... Ask for user confirmation before processing the refund. + + + apply_discount + Apply a discount to the user's cart. + + + + user_request + Request from the specific user for refunding a product or service. + + + response + The response of the agent to the user's request. + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/agent_form/financial_agent.xml b/metachain/agents/meta_agent/agent_form/financial_agent.xml new file mode 100644 index 0000000..c4e24d4 --- /dev/null +++ b/metachain/agents/meta_agent/agent_form/financial_agent.xml @@ -0,0 +1,85 @@ + + + Two types of financial requests: + 1. Managing private financial documents stored in the 'financial_docs' folder + 2. Searching online financial information for specific company tickers + + + financial_response + Comprehensive response containing either document analysis results or requested financial information. + + + Financial Agent + A specialized agent that handles both private financial document management and online financial information retrieval. + You are a financial assistant with two primary responsibilities: + +1. For private financial documents: + - Process and analyze documents in the 'financial_docs' folder + - Store document content for efficient retrieval + - Answer questions about stored financial documents + - Maintain document confidentiality and security + +2. For online financial information: + - Retrieve accurate financial data for specified company tickers + - Format and present financial statements clearly + - Ensure data accuracy and proper citation + - Provide context for financial metrics when needed + + + save_raw_docs_to_vector_db + Process and store private financial documents into the vector database for efficient retrieval. + + + query_db + Search through stored financial documents to find relevant information. + + + modify_query + Refine search queries for better document retrieval results. + + + answer_query + Provide answers based on information found in stored documents. + + + can_answer + Verify if sufficient information exists in stored documents to answer a query. + + + get_historical_stock_price + Retrieve historical stock price data for specified tickers. + + + visualizer + Visualize financial data and documents when needed. + + + + + get_balance_sheet + Retrieve balance sheet data for a specific company ticker over a given period. + + + get_cash_flow + Retrieve cash flow statement data for a specific company ticker over a given period. + + + get_income_statement + Retrieve income statement data for a specific company ticker over a given period. + + + + financial_request + User request for either document management or financial information retrieval, including: +- Document analysis requests for private financial documents +- Requests for specific financial statements with company ticker and time period + + + financial_response + Comprehensive response containing either: +- Analysis results from private financial documents +- Requested financial statements and data +- Relevant visualizations or summaries as needed + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/agent_form/financial_agent_2.json b/metachain/agents/meta_agent/agent_form/financial_agent_2.json new file mode 100644 index 0000000..6a48287 --- /dev/null +++ b/metachain/agents/meta_agent/agent_form/financial_agent_2.json @@ -0,0 +1,87 @@ +{ + "system_input": "Two types of financial requests:\n 1. Managing and analyzing private financial documents stored in the `financial_docs` folder\n 2. Retrieving and analyzing public financial information for specific company tickers", + "system_output": { + "key": "financial_analysis", + "description": "The comprehensive financial analysis or response based on either private documents or public financial data." + }, + "global_variables": {}, + "agents": [ + { + "name": "Private Financial Document Manager", + "description": "An agent specialized in managing and analyzing private financial documents stored locally.", + "instructions": "You are a financial document manager responsible for:\n1. Processing and organizing financial documents from the local `financial_docs` folder\n2. Storing document content in a searchable format using vector database\n3. Retrieving relevant financial information from stored documents\n4. Providing detailed analysis based on the stored financial documents\n\nAlways verify document processing success and maintain data confidentiality.", + "tools": { + "existing": [ + { + "name": "save_raw_docs_to_vector_db", + "description": "Save the financial documents to the vector database for efficient retrieval and analysis." + }, + { + "name": "query_db", + "description": "Search for specific financial information within stored documents." + }, + { + "name": "modify_query", + "description": "Refine search queries to get more accurate financial information." + }, + { + "name": "answer_query", + "description": "Provide detailed answers based on the financial documents." + }, + { + "name": "can_answer", + "description": "Verify if sufficient information exists in stored documents to answer a query." + } + ], + "new": [] + }, + "agent_input": { + "key": "document_request", + "description": "User's request related to private financial documents, including document processing, searching, or analysis needs." + }, + "agent_output": { + "key": "financial_analysis", + "description": "Analysis, insights, or information retrieved from private financial documents." + } + }, + { + "name": "Public Financial Data Analyst", + "description": "An agent specialized in retrieving and analyzing public financial information for specific company tickers.", + "instructions": "You are a financial data analyst responsible for:\n1. Retrieving public financial data including balance sheets, cash flow statements, and income statements\n2. Analyzing financial metrics and trends\n3. Providing detailed financial analysis based on public data\n4. Ensuring accuracy in financial data retrieval and calculations\n\nAlways verify data accuracy and provide clear sources for financial information.", + "tools": { + "existing": [ + { + "name": "get_historical_stock_price", + "description": "Retrieve historical stock price data for analysis." + } + ], + "new": [ + { + "name": "get_balance_sheet", + "description": "Retrieve balance sheet data for a specific ticker over a given period." + }, + { + "name": "get_cash_flow_statement", + "description": "Retrieve cash flow statement data for a specific ticker over a given period." + }, + { + "name": "get_income_statement", + "description": "Retrieve income statement data for a specific ticker over a given period." + }, + { + "name": "calculate_financial_metrics", + "description": "Calculate key financial metrics and ratios from the retrieved financial statements." + } + ] + }, + "agent_input": { + "key": "market_request", + "description": "User's request for public financial data, including specific ticker symbols and time periods for analysis." + }, + "agent_output": { + "key": "financial_analysis", + "description": "Analysis and insights based on public financial data, including financial statements and calculated metrics." + } + } + ] +} \ No newline at end of file diff --git a/metachain/agents/meta_agent/agent_form/financial_agent_2.xml b/metachain/agents/meta_agent/agent_form/financial_agent_2.xml new file mode 100644 index 0000000..16a768a --- /dev/null +++ b/metachain/agents/meta_agent/agent_form/financial_agent_2.xml @@ -0,0 +1,98 @@ + + + Two types of financial requests: + 1. Managing and analyzing private financial documents stored in the `financial_docs` folder + 2. Retrieving and analyzing public financial information for specific company tickers + + + + financial_analysis + The comprehensive financial analysis or response based on either private documents or public financial data. + + + + Private Financial Document Manager + An agent specialized in managing and analyzing private financial documents stored locally. + You are a financial document manager responsible for: +1. Processing and organizing financial documents from the local `financial_docs` folder +2. Storing document content in a searchable format using vector database +3. Retrieving relevant financial information from stored documents +4. Providing detailed analysis based on the stored financial documents + +Always verify document processing success and maintain data confidentiality. + + + save_raw_docs_to_vector_db + Save the financial documents to the vector database for efficient retrieval and analysis. + + + query_db + Search for specific financial information within stored documents. + + + modify_query + Refine search queries to get more accurate financial information. + + + answer_query + Provide detailed answers based on the financial documents. + + + can_answer + Verify if sufficient information exists in stored documents to answer a query. + + + + document_request + User's request related to private financial documents, including document processing, searching, or analysis needs. + + + financial_analysis + Analysis, insights, or information retrieved from private financial documents. + + + + + Public Financial Data Analyst + An agent specialized in retrieving and analyzing public financial information for specific company tickers. + You are a financial data analyst responsible for: +1. Retrieving public financial data including balance sheets, cash flow statements, and income statements +2. Analyzing financial metrics and trends +3. Providing detailed financial analysis based on public data +4. Ensuring accuracy in financial data retrieval and calculations + +Always verify data accuracy and provide clear sources for financial information. + + + get_historical_stock_price + Retrieve historical stock price data for analysis. + + + + + get_balance_sheet + Retrieve balance sheet data for a specific ticker over a given period. + + + get_cash_flow_statement + Retrieve cash flow statement data for a specific ticker over a given period. + + + get_income_statement + Retrieve income statement data for a specific ticker over a given period. + + + calculate_financial_metrics + Calculate key financial metrics and ratios from the retrieved financial statements. + + + + market_request + User's request for public financial data, including specific ticker symbols and time periods for analysis. + + + financial_analysis + Analysis and insights based on public financial data, including financial statements and calculated metrics. + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/agent_form/helper_bot.xml b/metachain/agents/meta_agent/agent_form/helper_bot.xml new file mode 100644 index 0000000..9c3cf68 --- /dev/null +++ b/metachain/agents/meta_agent/agent_form/helper_bot.xml @@ -0,0 +1,54 @@ + + + Questions from the user about the OpenAI products. The document of the OpenAI products is available at `/workspace/docs/openai_products/`. + + + answer + The answer to the user's question. + + + Helper Center Agent + The helper center agent is an agent that serves as a helper center agent for a specific user to answer the user's question about the OpenAI products. + You are a helper center agent that can be used to help the user with their request. + + + save_raw_docs_to_vector_db + Save the raw documents to the vector database. The documents could be: + - ANY text document with the extension of pdf, docx, txt, etcs. + - A zip file containing multiple text documents + - a directory containing multiple text documents + All documents will be converted to raw text format and saved to the vector database in the chunks of 4096 tokens. + + + query_db + Query the vector database to find the answer to the user's question. + + + modify_query + Modify the user's question to a more specific question. + + + answer_query + Answer the user's question based on the answer from the vector database. + + + can_answer + Check if the user's question can be answered by the vector database. + + + + + send_email + Send an email to the user. + + + + user_question + The question from the user about the OpenAI products. + + + answer + The answer to the user's question. + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/agent_former.py b/metachain/agents/meta_agent/agent_former.py new file mode 100644 index 0000000..3756f84 --- /dev/null +++ b/metachain/agents/meta_agent/agent_former.py @@ -0,0 +1,257 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_agents import list_agents, create_agent, delete_agent, run_agent, read_agent +from metachain.tools.meta.edit_tools import list_tools, create_tool, delete_tool, run_tool +from metachain.tools.terminal_tools import execute_command +from metachain.types import Agent +from metachain.io_utils import read_file +from pydantic import BaseModel, Field +from typing import List + + +@register_agent(name = "Agent Former Agent", func_name="get_agent_former_agent") +def get_agent_former_agent(model: str) -> str: + """ + This agent is used to complete a form that can be used to create an agent. + """ + def instructions(context_variables): + return r"""\ +You are an agent specialized in creating agent forms for the MetaChain framework. + +Your task is to analyze user requests and generate structured creation forms for either single or multi-agent systems. + +KEY COMPONENTS OF THE FORM: +1. - Root element containing all agent definitions + +2. - Defines what the system receives + - Must describe the overall input that the system accepts + - For single agent: Same as agent_input + - For multi-agent: Should encompass all possible inputs that will be routed to different agents + +3. - Specifies system response format + - Must contain exactly ONE key-description pair + - : Single identifier for the system's output + - : Explanation of the output + - For single agent: Same as agent_output + - For multi-agent: Should represent the unified output format from all agents + +4. - Individual agent definition + - name: Agent's identifier + - description: Agent's purpose and capabilities + - instructions: Agent's behavioral guidelines + * To reference global variables, use format syntax: {variable_key} + * Example: "Help the user {user_name} with his/her request" + * All referenced keys must exist in global_variables + - tools: Available tools (existing/new) + - agent_input: + * Must contain exactly ONE key-description pair + * : Identifier for the input this agent accepts + * : Detailed explanation of the input format + - agent_output: + * Must contain exactly ONE key-description pair + * : Identifier for what this agent produces + * : Detailed explanation of the output format + +5. - Shared variables across agents (optional) + - Used for constants or shared values accessible by all agents + - Variables defined here can be referenced in instructions using {key} + - Example: + ```xml + + + user_name + The name of the user + John Doe + + + ``` + - Usage in instructions: "You are a personal assistant for {user_name}." + +IMPORTANT RULES: +- For single agent systems: + * system_input/output must match agent_input/output exactly +- For multi-agent systems: + * system_input should describe the complete input space + * Each agent_input should specify which subset of the system_input it handles + * system_output should represent the unified response format +""" + \ +f""" +Existing tools you can use is: +{list_tools(context_variables)} + +Existing agents you can use is: +{list_agents(context_variables)} +""" + \ +r""" +EXAMPLE 1 - SINGLE AGENT: + +User: I want to build an agent that can answer the user's question about the OpenAI products. The document of the OpenAI products is available at `/workspace/docs/openai_products/`. +The agent should be able to: +1. query and answer the user's question about the OpenAI products based on the document. +2. send email to the user if the sending email is required in the user's request. + +The form should be: + + + Questions from the user about the OpenAI products. The document of the OpenAI products is available at `/workspace/docs/openai_products/`. + + + answer + The answer to the user's question. + + + Helper Center Agent + The helper center agent is an agent that serves as a helper center agent for a specific user to answer the user's question about the OpenAI products. + You are a helper center agent that can be used to help the user with their request. + + + save_raw_docs_to_vector_db + Save the raw documents to the vector database. The documents could be: + - ANY text document with the extension of pdf, docx, txt, etcs. + - A zip file containing multiple text documents + - a directory containing multiple text documents + All documents will be converted to raw text format and saved to the vector database in the chunks of 4096 tokens. + + + query_db + Query the vector database to find the answer to the user's question. + + + modify_query + Modify the user's question to a more specific question. + + + answer_query + Answer the user's question based on the answer from the vector database. + + + can_answer + Check if the user's question can be answered by the vector database. + + + + + send_email + Send an email to the user. + + + + user_question + The question from the user about the OpenAI products. + + + answer + The answer to the user's question. + + + + +EXAMPLE 2 - MULTI-AGENT: + +User: I want to build a multi-agent system that can handle two types of requests for the specific user: +1. Purchase a product or service +2. Refund a product or service +The specific user worked for is named John Doe. + +The form should be: + + + The user request from the specific user about the product or service, mainly categorized into 2 types: + - Purchase a product or service + - Refund a product or service + + + response + The response of the agent to the user's request. + + + + user_name + The name of the user. + John Doe + + + + Personal Sales Agent + The personal sales agent is an agent that serves as a personal sales agent for a specific user. + You are a personal sales agent that can be used to help the user {user_name} with their request. + + + recommend_product + Recommend a product to the user. + + + recommend_service + Recommend a service to the user. + + + conduct_sales + Conduct sales with the user. + + + + user_request + Request from the specific user for purchasing a product or service. + + + response + The response of the agent to the user's request. + + + + Personal Refunds Agent + The personal refunds agent is an agent that serves as a personal refunds agent for a specific user. + Help the user {user_name} with a refund. If the reason is that it was too expensive, offer the user a discount. If they insist, then process the refund. + + + process_refund + Refund an item. Refund an item. Make sure you have the item_id of the form item_... Ask for user confirmation before processing the refund. + + + apply_discount + Apply a discount to the user's cart. + + + + user_request + Request from the specific user for refunding a product or service. + + + response + The response of the agent to the user's request. + + + + +GUIDELINES: +1. Each agent must have clear, focused responsibilities +2. Tool selections should be minimal but sufficient +3. Instructions should be specific and actionable +4. Input/Output definitions must be precise +5. Use global_variables for shared context across agents + +Follow these examples and guidelines to create appropriate agent forms based on user requirements. +""" + return Agent( + name = "Agent Former Agent", + model = model, + instructions = instructions, + ) + +if __name__ == "__main__": + from metachain import MetaChain + agent = get_agent_former_agent("claude-3-5-sonnet-20241022") + client = MetaChain() + task_yaml = """\ +I want to create two agents that can help me to do two kinds of tasks: +1. Manage the private financial docs. I have a folder called `financial_docs` in my local machine, and I want to help me to manage the financial docs. +2. Search the financial information online. You may help me to: +- get balance sheets for a given ticker over a given period. +- get cash flow statements for a given ticker over a given period. +- get income statements for a given ticker over a given period. +""" + task_yaml = task_yaml + """\ +Directly output the form in the XML format. +""" + messages = [{"role": "user", "content": task_yaml}] + response = client.run(agent, messages) + print(response.messages[-1]["content"]) \ No newline at end of file diff --git a/metachain/agents/meta_agent/form_complie.py b/metachain/agents/meta_agent/form_complie.py new file mode 100644 index 0000000..f34a7cb --- /dev/null +++ b/metachain/agents/meta_agent/form_complie.py @@ -0,0 +1,162 @@ +from pydantic import BaseModel, Field, validator, field_validator, ValidationInfo +from typing import List, Dict, Optional, Literal +import xml.etree.ElementTree as ET + +class KeyDescription(BaseModel): + key: str + description: str + +class Tool(BaseModel): + name: str + description: str + +class ToolSet(BaseModel): + existing: List[Tool] = Field(default_factory=list) + new: List[Tool] = Field(default_factory=list) + +class GlobalVariable(BaseModel): + key: str + description: str + value: str + +class Agent(BaseModel): + name: str + description: str + instructions: str + tools: ToolSet + agent_input: KeyDescription + agent_output: KeyDescription + +class AgentForm(BaseModel): + system_input: str + system_output: KeyDescription + global_variables: Dict[str, GlobalVariable] = Field(default_factory=dict) + agents: List[Agent] + + @field_validator('agents') + def validate_single_agent_io(cls, v, info: ValidationInfo): + """验证单agent系统的输入输出是否匹配""" + if len(v) == 1: + agent = v[0] + system_output = info.data.get('system_output') + if system_output and agent.agent_output.key != system_output.key: + raise ValueError("Single agent system must have matching system and agent output keys") + return v + # def validate_global_ctx_instructions(cls, v, info: ValidationInfo): + # """验证全局变量和系统输入是否匹配""" + +class XMLParser: + @staticmethod + def parse_key_description(elem: ET.Element, tag_name: str) -> KeyDescription: + node = elem.find(tag_name) + if node is None: + raise ValueError(f"Missing {tag_name}") + return KeyDescription( + key=node.find('key').text.strip(), + description=node.find('description').text.strip() + ) + + @staticmethod + def parse_tools(agent_elem: ET.Element) -> ToolSet: + tools = ToolSet() + for tools_elem in agent_elem.findall('tools'): + category = tools_elem.get('category') + if category not in ('existing', 'new'): + continue + + tool_list = [] + for tool_elem in tools_elem.findall('tool'): + tool = Tool( + name=tool_elem.find('name').text.strip(), + description=tool_elem.find('description').text.strip() + ) + tool_list.append(tool) + + if category == 'existing': + tools.existing = tool_list + else: + tools.new = tool_list + + return tools + + @staticmethod + def parse_global_variables(root: ET.Element) -> Dict[str, GlobalVariable]: + variables = {} + global_vars = root.find('global_variables') + if global_vars is not None: + for var in global_vars.findall('variable'): + key = var.find('key').text.strip() + variables[key] = GlobalVariable( + key=key, + description=var.find('description').text.strip(), + value=var.find('value').text.strip() + ) + return variables + + @classmethod + def parse_agent(cls, agent_elem: ET.Element) -> Agent: + return Agent( + name=agent_elem.find('name').text.strip(), + description=agent_elem.find('description').text.strip(), + instructions=agent_elem.find('instructions').text.strip(), + tools=cls.parse_tools(agent_elem), + agent_input=cls.parse_key_description(agent_elem, 'agent_input'), + agent_output=cls.parse_key_description(agent_elem, 'agent_output') + ) + + @classmethod + def parse_xml(cls, xml_content: str) -> AgentForm: + root = ET.fromstring(xml_content) + + return AgentForm( + system_input=root.find('system_input').text.strip(), + system_output=cls.parse_key_description(root, 'system_output'), + global_variables=cls.parse_global_variables(root), + agents=[cls.parse_agent(agent_elem) for agent_elem in root.findall('agent')] + ) + +def parse_agent_form(xml_content: str) -> Optional[AgentForm]: + """ + 读取并解析agent form XML文件 + + Args: + xml_content: XML文件内容 + + Returns: + 解析后的AgentForm对象,如果解析失败返回None + """ + try: + # with open(xml_path, 'r', encoding='utf-8') as f: + # xml_content = f.read() + + return XMLParser.parse_xml(xml_content) + + except ET.ParseError as e: + print(f"Error parsing XML: {e}") + return None + except Exception as e: + print(f"Unexpected error: {e}") + return None + +# 使用示例 +if __name__ == "__main__": + # 读取和解析XML文件 + import json + with open("/Users/tangjiabin/Documents/reasoning/metachain/metachain/agents/meta_agent/agent_form/financial_agent_2.xml", 'r', encoding='utf-8') as f: + xml_content = f.read() + form = parse_agent_form(xml_content) + with open("/Users/tangjiabin/Documents/reasoning/metachain/metachain/agents/meta_agent/agent_form/financial_agent_2.json", 'w', encoding='utf-8') as f: + json.dump(form.model_dump(), f, indent=4) + if form: + # 访问数据(现在有类型提示和验证) + print(f"System input: {form.system_input}") + print(f"Number of agents: {len(form.agents)}") + + # 访问第一个agent的信息 + first_agent = form.agents[0] + print(f"First agent name: {first_agent.name}") + print(f"First agent tools: {first_agent.tools}") + + # 转换为字典 + form_dict = form.model_dump() + print(json.dumps(form_dict, indent=4)) \ No newline at end of file diff --git a/metachain/agents/meta_agent/form_complie_ori.py b/metachain/agents/meta_agent/form_complie_ori.py new file mode 100644 index 0000000..7932af5 --- /dev/null +++ b/metachain/agents/meta_agent/form_complie_ori.py @@ -0,0 +1,137 @@ + + +import xml.etree.ElementTree as ET +from typing import Dict, List, Optional + +class AgentForm: + def __init__(self, xml_string: str): + # Parse XML string + root = ET.fromstring(xml_string) + + # Parse system input/output + self.system_input = root.find('system_input').text.strip() + + system_output = root.find('system_output') + self.system_output = { + 'key': system_output.find('key').text.strip(), + 'description': system_output.find('description').text.strip() + } + + # Parse global variables (optional) + global_vars = root.find('global_variables') + self.global_variables = {} + if global_vars is not None: + for var in global_vars.findall('variable'): + self.global_variables[var.find('key').text.strip()] = { + 'description': var.find('description').text.strip(), + 'value': var.find('value').text.strip() + } + + # Parse agents + self.agents = [] + for agent_elem in root.findall('agent'): + agent = { + 'name': agent_elem.find('name').text.strip(), + 'description': agent_elem.find('description').text.strip(), + 'instructions': agent_elem.find('instructions').text.strip(), + + # Parse tools + 'tools': { + 'existing': [], + 'new': [] + }, + + # Parse agent input/output + 'input': { + 'key': agent_elem.find('agent_input/key').text.strip(), + 'description': agent_elem.find('agent_input/description').text.strip() + }, + 'output': { + 'key': agent_elem.find('agent_output/key').text.strip(), + 'description': agent_elem.find('agent_output/description').text.strip() + } + } + + # Parse tools for both existing and new categories + for tools_category in agent_elem.findall('tools'): + category = tools_category.get('category') + for tool in tools_category.findall('tool'): + tool_info = { + 'name': tool.find('name').text.strip(), + 'description': tool.find('description').text.strip() + } + agent['tools'][category].append(tool_info) + + self.agents.append(agent) + + def validate(self) -> bool: + """ + 验证表单是否符合规则: + 1. system_output必须只有一个key-description对 + 2. 每个agent的input/output必须只有一个key-description对 + 3. 对于单agent系统,system in/output必须与agent in/output相同 + """ + try: + # 检查是否为单agent系统 + if len(self.agents) == 1: + agent = self.agents[0] + # 检查system和agent的input/output是否匹配 + if agent['output']['key'] != self.system_output['key']: + return False + + # 检查每个agent的input/output格式 + for agent in self.agents: + if not agent['input'].get('key') or not agent['input'].get('description'): + return False + if not agent['output'].get('key') or not agent['output'].get('description'): + return False + + return True + except Exception: + return False + + def to_dict(self) -> Dict: + """将表单转换为字典格式""" + return { + 'system_input': self.system_input, + 'system_output': self.system_output, + 'global_variables': self.global_variables, + 'agents': self.agents + } + +# 使用示例 +def parse_agent_form(xml_path: str) -> Optional[Dict]: + """ + 读取并解析agent form XML文件 + + Args: + xml_path: XML文件路径 + + Returns: + 解析后的字典格式数据,如果解析失败返回None + """ + try: + with open(xml_path, 'r', encoding='utf-8') as f: + xml_content = f.read() + + form = AgentForm(xml_content) + if not form.validate(): + print("Error: Invalid agent form format") + return None + + return form.to_dict() + + except ET.ParseError as e: + print(f"Error parsing XML: {e}") + return None + except Exception as e: + print(f"Unexpected error: {e}") + return None + +# 使用示例 +if __name__ == "__main__": + import json + result = parse_agent_form("/Users/tangjiabin/Documents/reasoning/metachain/metachain/agents/meta_agent/agent_form/customer_service.xml") + if result: + print("Successfully parsed agent form:") + print(json.dumps(result, indent=4)) \ No newline at end of file diff --git a/metachain/agents/meta_agent/meta_agent.py b/metachain/agents/meta_agent/meta_agent.py new file mode 100644 index 0000000..a519bdd --- /dev/null +++ b/metachain/agents/meta_agent/meta_agent.py @@ -0,0 +1,118 @@ +from metachain.registry import register_agent +from metachain.types import Agent, Result +from metachain.environment import DockerEnv, LocalEnv +from metachain.tools.meta.edit_tools import list_tools +from metachain.tools.meta.edit_agents import list_agents +from metachain.agents.meta_agent.agent_editor import get_agent_editor_agent +from metachain.agents.meta_agent.tool_editor import get_tool_editor_agent +from typing import Union +from metachain.tools.inner import case_resolved, case_not_resolved +from pydantic import BaseModel +from metachain.util import function_to_json +from metachain.agents.meta_agent.meta_plan_agent import get_meta_plan_agent +class AgentDescription(BaseModel): + tools: list[str] + existing: bool + +class ToolDescription(BaseModel): + tool_functionalities: str + existing: bool + tool_docs: str + +class ToolPlan(BaseModel): + tool_name: str + tool_description: ToolDescription + +class AgentPlan(BaseModel): + agent_name: str + agent_description: AgentDescription + + + +@register_agent(name = "Meta Agent", func_name="get_meta_agent") +def get_meta_agent(model: str) -> str: + """ + The meta agent is an agent that can be used to create and run other agents. + """ + def instructions(context_variables): + code_env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + instructions = f"""\ +You are a helpful assistant that can help the user with their request by creating and running agents in the Metachain agent framework. Your responsibility is to determine which agent is best suited to handle the user's request under the current context, and transfer the conversation to that agent. And you should not stop to try to solve the user's request by transferring to another agent only until the task is completed. + +Existing tools you already have: +{list_tools(context_variables)} + +Existing agents you already have: +{list_agents(context_variables)} + +You should first transfer the conversation to the `Meta Plan Agent` to plan how to use MetaChain to solve the user's request, and the plan should follow the following constraints: + +1. If exising agents are enough for your task, you can directly use them to solve the user's request. + +2. If exising agents are not enough for your task but there are enough existing tools, you can transfer the conversation to the `Agent Editor Agent` to develop new agents by using the existing tools. + +3. If exising agents and existing tools are not enough for your task, you should first transfer the conversation to the `Tool Editor Agent` to develop new tools, then transfer the conversation to the `Agent Editor Agent` to develop new agents by using the new tools. +""" + return instructions + + tool_editor_agent: Agent = get_tool_editor_agent(model) + agent_editor_agent: Agent = get_agent_editor_agent(model) + meta_plan_agent: Agent = get_meta_plan_agent(model) + def transfer_to_tool_editor_agent(sub_task: str): + """ + Args: + sub_task: The detailed description of the sub-task that the `Meta Agent` will ask the `Tool Editor Agent` to do. + """ + return tool_editor_agent + def transfer_to_agent_editor_agent(sub_task: str): + """ + Args: + sub_task: The detailed description of the sub-task that the `Meta Agent` will ask the `Agent Editor Agent` to do. + """ + return agent_editor_agent + def transfer_to_meta_plan_agent(sub_task: str): + """ + Use this function when you want to plan how to use MetaChain to solve the user's request. + Args: + sub_task: The detailed description of the sub-task that the `Meta Agent` will ask the `Meta Plan Agent` to do. + """ + return meta_plan_agent + meta_agent = Agent( + name="Meta Agent", + model=model, + instructions=instructions, + functions=[transfer_to_meta_plan_agent, transfer_to_tool_editor_agent, transfer_to_agent_editor_agent, case_resolved, case_not_resolved], + tool_choice = "required", + parallel_tool_calls = False + ) + + def transfer_back_to_meta_agent(task_status: str): + """ + Args: + task_status: The status of the task that the `Meta Agent` will ask the `Meta Agent` to do. + """ + return meta_agent + def transfer_back_to_meta_agent_with_plans(tool_development_steps: list[ToolPlan]) -> str: + """ + This function is used to plan how to use MetaChain to solve the user's request. You can use this function only after you have fully understood the user's request and have try your best to search information from exsiting resources. + + Args: + tool_development_steps: The steps of tool development. It is a list of dictionaries, each dictionary contains the tools name you should use in the exsiting MetaChain or the tools name you should develop. If the tool is not existing, dictionaries should contain the tool documentation. + """ + tool_str = "\n".join([f"{tool['tool_name']}: {tool['tool_description']['tool_functionalities']} [{tool['tool_description']['existing']}]" for tool in tool_development_steps]) + ret_val = f"""\ + Receiving user's request, I have the following plans to use MetaChain to solve the user's request: + As for using existing tools, I have the following plans: + {tool_str} + """ + return Result( + value=ret_val, + agent=meta_agent + ) + + tool_editor_agent.functions.append(transfer_back_to_meta_agent) + agent_editor_agent.functions.append(transfer_back_to_meta_agent) + meta_plan_agent.functions.append(transfer_back_to_meta_agent_with_plans) + + return meta_agent + diff --git a/metachain/agents/meta_agent/meta_plan_agent.py b/metachain/agents/meta_agent/meta_plan_agent.py new file mode 100644 index 0000000..77bb334 --- /dev/null +++ b/metachain/agents/meta_agent/meta_plan_agent.py @@ -0,0 +1,38 @@ +from metachain.types import Agent +from pydantic import BaseModel +from metachain.tools.meta.tool_retriever import get_api_plugin_tools_doc +from metachain.tools.meta.search_tools import search_trending_models_on_huggingface, get_hf_model_tools_doc +from metachain.tools.meta.edit_tools import list_tools +from typing import Union +from metachain.environment import DockerEnv, LocalEnv + + +def get_meta_plan_agent(model: str) -> Agent: + def instructions(context_variables): + code_env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + instructions = f"""\ +You are a helpful planner that can help `Tool Editor Agent` how to use MetaChain to solve the user's request. + +Existing tools you already have: +{list_tools(context_variables)} + +You should first fully understand the user's request, then analyze the existing tools and determine which tools are needed to solve the user's request, finally, you should transfer the conversation to the `Meta Agent` with the plan of using the tools. + +If existing tools are not enough for your task, you should develop new tools. + +1. [IMPORTANT] If you want to use third-party api, especially for some tasks related to Finance, Entertainment, eCommerce, Food, Travel, Sports, you MUST use the `get_api_plugin_tools_doc` tool to search information from existing api documents, it contains how to implement the api and API keys. + + +2. [IMPORTANT] If you want to use Hugging Face models, especially for some tasks related to vision, audio, video, you should use the `search_trending_models_on_huggingface` tool to search trending models related to the specific task on Hugging Face, and then use the `get_hf_model_tools_doc` tool to get the detailed information about the specific model. + +3. [IMPORTANT] You can not use `transfer_back_to_meta_agent_with_plans` util you have fully understood the user's request and have try your best to search information from exsiting resources if you want to create a new tool. +""" + return instructions + return Agent( + name="Meta Plan Agent", + model=model, + instructions=instructions, + functions=[get_api_plugin_tools_doc, search_trending_models_on_huggingface, get_hf_model_tools_doc], + tool_choice = "required", + parallel_tool_calls = False + ) diff --git a/metachain/agents/meta_agent/tool_editor.py b/metachain/agents/meta_agent/tool_editor.py new file mode 100644 index 0000000..8d57be9 --- /dev/null +++ b/metachain/agents/meta_agent/tool_editor.py @@ -0,0 +1,257 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_tools import list_tools, create_tool, delete_tool, run_tool, get_metachain_path +from metachain.tools.meta.tool_retriever import get_api_plugin_tools_doc +from metachain.tools.meta.search_tools import search_trending_models_on_huggingface, get_hf_model_tools_doc + +from metachain.types import Agent +from metachain.io_utils import read_file +from metachain.tools.terminal_tools import execute_command, terminal_page_down, terminal_page_up, terminal_page_to +@register_agent(name = "Tool Editor Agent", func_name="get_tool_editor_agent") +def get_tool_editor_agent(model: str) -> Agent: + """ + The tool editor is an agent that can be used to edit the tools. + """ + def instructions(context_variables): + return f"""\ +You are a tool editor agent responsible for managing plugin tools in the MetaChain framework. Your core responsibility is to edit, create, and manage plugin tools that can be used by other agents. + +[PLUGIN TOOLS SYSTEM] +- Plugin tools are the building blocks of MetaChain +- All available plugin tools are as follows: +{list_tools(context_variables)} +- Plugin tools can ONLY be executed using `run_tool(tool_name, run_code)`. You should import `run_tool` by `from metachain.tools import run_tool`. +- NEVER try to import and run plugin tools directly - always use `run_tool` + +[TOOL CREATION WORKFLOW] +1. ALWAYS start with `list_tools()` to check existing tools + +2. For NEW plugin tool creation, FOLLOW THIS ORDER: + a. For third-party API integration (e.g., RapidAPI, external services): + - MUST FIRST use `get_api_plugin_tools_doc` to get API documentation and keys + - API keys should be embedded IN the function body, NOT as parameters. + - The API keys are always in the retrieved information from `get_api_plugin_tools_doc`, DO NOT guess the API keys by yourself. + - Follow the API implementation details from the documentation + + b. For modal transformation tasks (image/video/audio generation/processing): + - FIRST use `search_trending_models_on_huggingface` to find suitable models, only support the following tags: ['audio-text-to-text', 'text-to-image', 'image-to-image', 'image-to-video', 'text-to-video', 'text-to-speech', 'text-to-audio', 'automatic-speech-recognition', 'audio-to-audio']. + - Then use `get_hf_model_tools_doc` for detailed model information + - Only use internal knowledge if no suitable models are found + + c. For visual analysis tasks (images/videos): + - MUST use the existing `visual_question_answering` plugin tool by `run_tool("visual_question_answering", "from metachain.tools import visual_question_answering; ...")`. DO NOT use it directly without `run_tool`. + - NO direct implementation of visual processing + - Chain with other tools as needed + +3. Plugin Tool Implementation Requirements: + - Use @register_plugin_tool decorator (REQUIRED). You should import `register_plugin_tool` by `from metachain.registry import register_plugin_tool`. + - Follow this template: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + - Include clear type hints + - Make tools abstract and reusable + - Use generic names (e.g., 'process_media' not 'process_youtube_video') + - Handle dependencies with `execute_command` + +[AVAILABLE TOOLS] +1. get_api_plugin_tools_doc: + - PRIMARY tool for third-party API integration + - MUST be used FIRST for Finance, Entertainment, eCommerce, etc. + - Provides API documentation AND authentication keys + - API keys should be embedded in tool implementation + +2. search_trending_models_on_huggingface: + - Use for finding models for media transformation tasks + - Supported tags: ['text-to-image', 'image-to-image', 'text-to-video', etc.] + - Use AFTER checking no suitable API exists via `get_api_plugin_tools_doc` + +3. get_hf_model_tools_doc: + - Get the detailed information of a model on Hugging Face, such as the detailed usage of the model containing the model's README.md. + - You should use this tool after you have used `search_trending_models_on_huggingface` to find the model you want to use. + +4. Other management tools: + - list_tools(): Check existing tools + - create_tool(tool_name, tool_code): Create new tools + - run_tool(tool_name, run_code): REQUIRED method to execute any plugin tool + - delete_tool(tool_name): Remove tools + - execute_command: Install dependencies. Handles system-level operations + - terminal_page_* tools: Navigate long outputs + +5. case_resolved & case_not_resolved: + - case_resolved: after you have created all the tools and tested them using `run_tool` successfully (with the expected output rather than just run it), you should use the `case_resolved` tool to brief the result. + - case_not_resolved: after you have tried your best to create the tools but failed, you should use the `case_not_resolved` tool to tell the failure reason. + +[CRITICAL RULES] +1. Tool Creation Priority: + - FIRST: Check existing tools via list_tools() + - SECOND: Use `get_api_plugin_tools_doc` for API-based tools + - THIRD: Use `search_trending_models_on_huggingface` for media tasks + - LAST: Use internal knowledge if no other options available + +2. API Implementation: + - NEVER expose API keys as parameters + - ALWAYS embed API keys in function body + - Get keys from `get_api_plugin_tools_doc` + +3. Tool Design: + - Tools MUST be abstract, modular, and reusable: + - Use generic function names (e.g., `download_media` instead of `download_youtube_video`) + - Break complex tasks into smaller, reusable components + - Avoid task-specific implementations + - Use parameters instead of hardcoded values + - Include proper error handling + +[TESTING] +Test new tools using `run_tool`: +`run_tool(tool_name="your_tool", run_code="from metachain.tools import your_tool; print(your_tool(param1='value1'))")` +""" + tool_list = [list_tools, create_tool, run_tool, delete_tool, get_api_plugin_tools_doc, execute_command, terminal_page_down, terminal_page_up, terminal_page_to, search_trending_models_on_huggingface, get_hf_model_tools_doc] + return Agent( + name="Tool Editor Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) + + +""" +5. [IMPORTANT] If you want to use Hugging Face models, especially for some tasks related to vision, audio, video, you should use the `search_trending_models_on_huggingface` tool to search trending models related to the specific task on Hugging Face, and then use the `get_hf_model_tools_doc` tool to get the detailed information about the specific model. + +6. [IMPORTANT] As for the tags ['image-text-to-text', 'visual-question-answering', 'video-text-to-text'] and ANY visual tasks, you should use `visual_question_answering` tool instead of Hugging Face models. +""" + +"""\ +You are a tool editor agent that can be used to edit the tools. You are working on a Agent framework named MetaChain, and your responsibility is to edit the tools in the MetaChain, so that the tools can be used by the agents to help the user with their request. + +The existing tools are shown below: +{list_tools(context_variables)} + +If you want to create a new tool, you should: +1. follow the format of the `tool_dummy` below. Note that if the tool should be used with third-part api key, you should write the api key inside the definition of the tool: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + +2. you successfully create the tool only after you have successfully run the tool with the `run_tool` function, and an example of testing the tool is shown below.: +```python +from metachain.tools import tool_dummy + +if __name__ == "__main__": + ... # some pre-operations + print(run_tool(tool_name="tool_dummy", run_code="from metachain.tools import tool_dummy; print(tool_dummy(args1=args1, args2=args1, ...))")) +``` + +3. If you encounter any error while creating and running the tool, like dependency missing, you should use the `execute_command` function to install the dependency. + +4. [IMPORTANT] If you want to use third-party api, especially for some tasks related to Finance, Entertainment, eCommerce, Food, Travel, Sports, you MUST use the `get_api_plugin_tools_doc` tool to search information from existing api documents, it contains how to implement the api and API keys. + +[IMPORTANT] The `register_plugin_tool` registry function is strictly required for a tool implementation to be recognized by the MetaChain framework. + +[IMPORTANT] The tool you create should be abstract, modular, and reusable. Specifically, the function name must be generic (e.g., +`count_objects` instead of `count_apples`). The function must use parameters instead of hard-coded values. The +function body must be self-contained. + +[IMPORTANT] Explicitly declare input and output data types using type hints. + +[IMPORTANT] For ANY visual tasks related to image and video, you should use `visual_question_answering` tool. +""" + + +"""\ +You are a tool editor agent responsible for managing plugin tools in the MetaChain framework. Your core responsibility is to edit, create, and manage plugin tools that can be used by other agents. + +[PLUGIN TOOLS SYSTEM] +- Plugin tools are the building blocks of MetaChain +- All available plugin tools are as follows: +{list_tools(context_variables)} +- Plugin tools can ONLY be executed using `run_tool(tool_name, run_code)` +- NEVER try to import and run tools directly - always use `run_tool` + +[AVAILABLE MANAGEMENT TOOLS] +1. list_tools(): + - Lists all existing plugin tools + - Returns: tool name, arguments, docstring, implementation details + - Use this FIRST to check existing tools + +2. create_tool(tool_name: str, tool_code: str): + - Creates new plugin tools + - Requires proper registration using @register_plugin_tool, and you MUST import `register_plugin_tool` by `from metachain.registry import register_plugin_tool` + +3. run_tool(tool_name: str, run_code: str,): + - REQUIRED method to execute any plugin tool + - Format: run_tool("tool_name", "from metachain.tools import tool_name; print(tool_name(args))") + +4. delete_tool(tool_name: str,): + - Removes existing plugin tools + - Use with caution + +5. get_api_plugin_tools_doc: + - Required for third-party API integrations, e.g. RapidAPI. + - MUST be used for Finance, Entertainment, etc. + +6. execute_command: + - Handles system-level operations + - Use for dependency installation + +7. terminal_page_down: + - Move the terminal page down when the terminal output is too long. + +8. terminal_page_up: + - Move the terminal page up when the terminal output is too long. + +9. terminal_page_to: + - Move the terminal page to the specific page when the terminal output is too long, and you want to move to the specific page with the meaningful content. + +10. search_trending_models_on_huggingface: + - Search trending models on Hugging Face. + - Use this tool when you want to use Hugging Face models to generate images, videos, audios, etc. + - Do NOT use this tool for text-to-text or image-to-text tasks. + +11. get_hf_model_tools_doc: + - Get the detailed information about the specific model on Hugging Face. + - Use this tool when you want to use Hugging Face models to generate images, videos, audios, etc. + +[CRITICAL PRINCIPLES FOR PLUGIN TOOLS] +1. Tools MUST be abstract, modular, and reusable: + - Use generic function names (e.g., `download_media` instead of `download_youtube_video`) + - Break complex tasks into smaller, reusable components + - Avoid task-specific implementations + - Use parameters instead of hardcoded values + +2. For ALL visual tasks (images, videos, visual analysis): + - MUST use the existing `visual_question_answering` plugin tool + - NO direct implementation of visual processing + - Chain `visual_question_answering` with other tools as needed + +[WORKFLOW FOR PLUGIN TOOL MANAGEMENT] +1. Always start with `list_tools()` to check existing tools +2. For new plugin tools: + a. Design generic, reusable interface + b. Follow the template format: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + c. Create using `create_tool` + d. Test using `run_tool` + e. Handle dependencies with `execute_command` + +[IMPORTANT RULES] +- ALL tools must be registered with @register_plugin_tool +- ALL tools must have type hints +- Each tool does ONE thing well +- Create modular tools that can be combined +- ALWAYS use `run_tool` to execute plugin tools +- NEVER modify the `visual_question_answering` tool + +[TOOL TESTING EXAMPLE] +Correct way to test a plugin tool: +```python +result = run_tool( + tool_name="your_tool", + run_code="from metachain.tools import your_tool; print(your_tool(param1='value1'))", + context_variables=context_variables +) +``` +""" \ No newline at end of file diff --git a/metachain/agents/meta_agent/tool_editor.py.back b/metachain/agents/meta_agent/tool_editor.py.back new file mode 100644 index 0000000..f261889 --- /dev/null +++ b/metachain/agents/meta_agent/tool_editor.py.back @@ -0,0 +1,121 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_tools import list_tools, create_tool, delete_tool, run_tool, get_metachain_path +from metachain.tools.meta.tool_retriever import get_api_plugin_tools_doc +from metachain.tools.meta.search_tools import search_trending_models_on_huggingface, get_hf_model_tools_doc +from metachain.types import Agent +from metachain.io_utils import read_file +from metachain.tools.terminal_tools import execute_command, terminal_page_down, terminal_page_up, terminal_page_to + +@register_agent(name = "Tool Editor Agent", func_name="get_tool_editor_agent") +def get_tool_editor_agent(model: str) -> str: + """ + The tool editor is an agent that can be used to edit the tools. + """ + def instructions(context_variables): + return f"""\ +You are a Tool Editor specialized in the MetaChain framework. Your role is to create, modify, and maintain tools that agents can use effectively. + +CURRENT TOOLS: +{list_tools(context_variables)} + +TOOL CREATION GUIDELINES: + +1. STRUCTURE AND FORMATTING + - Follow the template structure below: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + - MUST use @register_plugin_tool decorator + - Include clear docstrings with args and returns + - Handle errors gracefully + - If the tool should be used with third-part api key, you should write the api key inside the definition of the tool + - DO NOT direct return too long output (e.g., the raw content of the download file for `download_file_from_url`), instead, save the output to a file in the `workplace/outputs` directory + +2. IMPLEMENTATION PRINCIPLES: + - Keep tools GENERIC and REUSABLE + - Avoid over-specific implementations + - Focus on single responsibility + - Ensure proper error handling + - Include input validation + - Return clear, structured outputs +""" + \ +r""" + +3. TESTING REQUIREMENTS: + - All tools MUST be tested before deployment + - Use this testing template: +```python +from metachain.tools import your_tool_name + +if __name__ == "__main__": + # Setup test environment + test_args = { + "arg1": value1, + "arg2": value2 + } + + # Execute test + result = your_tool_name(**test_args) + print(f"Test result: {result}") +``` + - if the output of the tool is too long, you should use the `terminal_page_down` or `terminal_page_up` or `terminal_page_to` function to move the terminal page to the specific page with the meaningful content. + +4. DEPENDENCY MANAGEMENT: + - Use execute_command for installing dependencies + - Document all required packages + - Verify compatibility with MetaChain + +CRITICAL RULES: +1. ALWAYS use @register_plugin_tool decorator +2. NEVER create overly specific tools +3. ALWAYS test before finalizing +4. ALWAYS handle errors gracefully +5. ALWAYS document clearly + +BEST PRACTICES: +1. Keep tools modular and focused +2. Provide clear error messages +3. Include usage examples in docstrings +4. Follow Python PEP 8 style guide +5. Use type hints for better clarity + +Remember: A good tool is reusable, reliable, and well-documented. Focus on creating tools that can be effectively used across different agents and scenarios. +""" + tool_list = [list_tools, create_tool, delete_tool, run_tool, get_api_plugin_tools_doc, search_trending_models_on_huggingface, get_hf_model_tools_doc, execute_command, terminal_page_down, terminal_page_up, terminal_page_to] + return Agent( + name="Tool Editor Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) + + +"""\ +You are a tool editor agent that can be used to edit the tools. You are working on a Agent framework named MetaChain, and your responsibility is to edit the tools in the MetaChain, so that the tools can be used by the agents to help the user with their request. + +The existing tools are shown below: +{list_tools(context_variables)} + +If you want to create a new tool, you should: +1. follow the format of the `tool_dummy` below. Note that if the tool should be used with third-part api key, you should write the api key inside the definition of the tool: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + +2. you successfully create the tool only after you have successfully tested the tool with the `test_tool` function, and an example of testing the tool is shown below.: +```python +from metachain.tools import tool_dummy + +if __name__ == "__main__": + ... # some pre-operations + print(tool_dummy(args1=args1, args2=args1, ...)) +``` + +3. If you encounter any error while creating and running the tool, like dependency missing, you should use the `execute_command` function to install the dependency. + +[IMPORTANT] The `register_plugin_tool` registry function is strictly required for a tool implementation to be recognized by the MetaChain framework. + +[IMPORTANT] Tools you create should be as general as possible, and you should not create too specific tools, so that the tools can be reused by other agents or other related tasks. +""" \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow.xml b/metachain/agents/meta_agent/workflow.xml new file mode 100644 index 0000000..b7d5950 --- /dev/null +++ b/metachain/agents/meta_agent/workflow.xml @@ -0,0 +1,53 @@ + + + Personal Sales Agent + The personal sales agent is an agent that serves as a personal sales agent for a specific user. + You are a personal sales agent that can be used to help the user {user_name} with their request. + + + recommend_product + Recommend a product to the user. + + + recommend_service + Recommend a service to the user. + + + transfer_customer + Transfer a customer to another sales agent. + + + search_information + Search for information for the user. + + + + + create_agent + Create a new agent. + + + + + user_name + The name of the user. + John Doe + + + user_email + The email of the user. + john.doe@example.com + + + + + + + personal_sales_agent + agent_former_agent + + user_request + create_agent + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_creator.py b/metachain/agents/meta_agent/workflow_creator.py new file mode 100644 index 0000000..cdde498 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_creator.py @@ -0,0 +1,81 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_agents import list_agents, create_agent, delete_agent, run_agent, read_agent, create_orchestrator_agent +from metachain.tools.meta.edit_workflow import list_workflows, create_workflow, run_workflow +from metachain.tools.terminal_tools import execute_command, terminal_page_down, terminal_page_up, terminal_page_to +from metachain.types import Agent +from metachain.io_utils import read_file + + +@register_agent(name = "Workflow Creator Agent", func_name="get_workflow_creator_agent") +def get_workflow_creator_agent(model: str) -> str: + """ + The workflow creator is an agent that can be used to create the workflow. + """ + def instructions(context_variables): + return f"""\ +You are a Workflow Creator specialized in the MetaChain framework. Your primary responsibility is to create and manage workflows based on XML-formatted workflow forms. + +CORE RESPONSIBILITIES: +1. Parse and implement workflow forms +2. Create necessary agents if specified in the workflow +3. Create and manage workflows +4. Execute workflows as needed + +AVAILABLE FUNCTIONS: +1. Workflow Management: + - `create_workflow`: Create new workflows based on the workflow form + - `run_workflow`: Execute the created workflow + - `list_workflows`: Display all available workflows + +2. Agent Management (when needed): + - `create_agent`: Create new agents if specified in the workflow form. If no tools are explicitly specified, use empty tool list ([]) + - `read_agent`: Retrieve existing agent definitions before updates + - `list_agents`: Display all available agents + +3. System Tools: + - `execute_command`: Handle system dependencies + - `terminal_page_down`, `terminal_page_up`, `terminal_page_to`: Navigate terminal output + +WORKFLOW CREATION PROCESS: + +1. Parse Workflow Form: + - Analyze the workflow form carefully + - Identify any new agents that need to be created + - Understand the workflow structure and requirements + +2. Create Required Agents: + - For each new agent in the workflow form: + * Use `create_agent` with appropriate parameters + * If no tools specified, use empty tool list ([]) + * Verify agent creation success + +3. Create Workflow: + - Use `create_workflow` to generate the workflow + - Ensure all required agents exist + - Validate workflow structure + +4. Execute Workflow: + - Use `run_workflow` to execute the created workflow + - Monitor execution progress + - Handle any errors appropriately + +BEST PRACTICES: +1. Always check if required agents exist before creating new ones +2. Use empty tool list ([]) when no specific tools are mentioned +3. Validate workflow creation before execution +4. Follow the exact specifications from the workflow form XML +5. Handle errors and dependencies appropriately + +Remember: Your primary goal is to create and execute workflows according to the provided workflow forms, creating any necessary agents along the way. +""" + tool_list = [list_agents, create_agent, execute_command, read_agent, terminal_page_down, terminal_page_up, terminal_page_to, list_workflows, create_workflow, run_workflow] + return Agent( + name="Workflow Creator Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) + + diff --git a/metachain/agents/meta_agent/workflow_form/condition_mining.json b/metachain/agents/meta_agent/workflow_form/condition_mining.json new file mode 100644 index 0000000..8c8db4b --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/condition_mining.json @@ -0,0 +1,184 @@ +{ + "name": "math_solver_chain_workflow", + "system_input": { + "key": "math_problem", + "description": "The math problem that needs to be solved." + }, + "system_output": { + "key": "solution", + "description": "The complete solution to the math problem." + }, + "global_variables": {}, + "agents": [ + { + "name": "Objective Extraction Agent", + "description": "This agent analyzes the math problem and extracts its main objective or goal.", + "category": "existing", + "tools": null + }, + { + "name": "Condition Extraction Agent", + "description": "This agent identifies and extracts all relevant conditions and given information from the math problem.", + "category": "existing", + "tools": null + }, + { + "name": "Condition Evaluator Agent", + "description": "This agent evaluates whether the extracted conditions are sufficient to solve the math problem.", + "category": "existing", + "tools": null + }, + { + "name": "Math Solver Agent", + "description": "This agent solves mathematical problems using analytical and systematic approaches.", + "category": "existing", + "tools": null + } + ], + "events": [ + { + "name": "on_start", + "inputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved." + } + ], + "task": null, + "outputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": null, + "agent": null + }, + { + "name": "extract_objective", + "inputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved." + } + ], + "task": "Extract and clearly state the main objective of the math problem.", + "outputs": [ + { + "key": "objective", + "description": "The main objective or question that needs to be answered.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Objective Extraction Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "extract_conditions", + "inputs": [ + { + "key": "objective", + "description": "The main objective or question that needs to be answered." + } + ], + "task": "Extract all relevant conditions and given information from the math problem.", + "outputs": [ + { + "key": "conditions", + "description": "The complete set of conditions and information extracted from the problem.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "extract_objective" + ], + "agent": { + "name": "Condition Extraction Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "evaluate_conditions", + "inputs": [ + { + "key": "conditions", + "description": "The complete set of conditions and information extracted from the problem." + } + ], + "task": "Evaluate if the extracted conditions are sufficient to solve the problem.", + "outputs": [ + { + "key": "merged_conditions", + "description": "The merged and organized conditions ready for problem-solving.", + "condition": "When conditions are sufficient to solve the problem.", + "action": { + "type": "RESULT", + "value": null + } + }, + { + "key": "insufficient_feedback", + "description": "Feedback on what additional information is needed.", + "condition": "When conditions are insufficient to solve the problem.", + "action": { + "type": "GOTO", + "value": "extract_conditions" + } + } + ], + "listen": [ + "extract_conditions" + ], + "agent": { + "name": "Condition Evaluator Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "solve_problem", + "inputs": [ + { + "key": "merged_conditions", + "description": "The merged and organized conditions ready for problem-solving." + } + ], + "task": "Solve the math problem using the complete set of conditions.", + "outputs": [ + { + "key": "solution", + "description": "The complete solution to the math problem.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "evaluate_conditions" + ], + "agent": { + "name": "Math Solver Agent", + "model": "claude-3-5-sonnet-20241022" + } + } + ] +} \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/condition_mining.xml b/metachain/agents/meta_agent/workflow_form/condition_mining.xml new file mode 100644 index 0000000..97ffb89 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/condition_mining.xml @@ -0,0 +1,169 @@ + + math_solver_chain_workflow + + math_problem + The math problem that needs to be solved. + + + solution + The complete solution to the math problem. + + + + + Objective Extraction Agent + This agent analyzes the math problem and extracts its main objective or goal. + + + Condition Extraction Agent + This agent identifies and extracts all relevant conditions and given information from the math problem. + + + Condition Evaluator Agent + This agent evaluates whether the extracted conditions are sufficient to solve the math problem. + + + Math Solver Agent + This agent solves mathematical problems using analytical and systematic approaches. + + + + + + on_start + + + math_problem + The math problem that needs to be solved. + + + + + math_problem + The math problem that needs to be solved. + + RESULT + + + + + + + extract_objective + + + math_problem + The math problem that needs to be solved. + + + Extract and clearly state the main objective of the math problem. + + + objective + The main objective or question that needs to be answered. + + RESULT + + + + + on_start + + + Objective Extraction Agent + claude-3-5-sonnet-20241022 + + + + + extract_conditions + + + objective + The main objective or question that needs to be answered. + + + Extract all relevant conditions and given information from the math problem. + + + conditions + The complete set of conditions and information extracted from the problem. + + RESULT + + + + + extract_objective + + + Condition Extraction Agent + claude-3-5-sonnet-20241022 + + + + + evaluate_conditions + + + conditions + The complete set of conditions and information extracted from the problem. + + + Evaluate if the extracted conditions are sufficient to solve the problem. + + + merged_conditions + The merged and organized conditions ready for problem-solving. + When conditions are sufficient to solve the problem. + + RESULT + + + + insufficient_feedback + Feedback on what additional information is needed. + When conditions are insufficient to solve the problem. + + GOTO + extract_conditions + + + + + extract_conditions + + + Condition Evaluator Agent + claude-3-5-sonnet-20241022 + + + + + solve_problem + + + merged_conditions + The merged and organized conditions ready for problem-solving. + + + Solve the math problem using the complete set of conditions. + + + solution + The complete solution to the math problem. + + RESULT + + + + + evaluate_conditions + + + Math Solver Agent + claude-3-5-sonnet-20241022 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_condition_mining.json b/metachain/agents/meta_agent/workflow_form/math_condition_mining.json new file mode 100644 index 0000000..6277557 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_condition_mining.json @@ -0,0 +1,158 @@ +{ + "name": "math_condition_mining_workflow", + "system_input": { + "key": "math_problem", + "description": "The mathematical problem that needs to be solved." + }, + "system_output": { + "key": "solution", + "description": "The detailed solution to the mathematical problem." + }, + "global_variables": {}, + "agents": [ + { + "name": "Objective Extraction Agent", + "description": "This agent is specialized in analyzing math problems and extracting the main objective or question being asked.", + "category": "existing", + "tools": null + }, + { + "name": "Condition Extraction Agent", + "description": "This agent is specialized in identifying and extracting all relevant conditions, given values, and constraints from the math problem.", + "category": "existing", + "tools": null + }, + { + "name": "Math Solver Agent", + "description": "This agent is specialized in evaluating whether conditions are sufficient and solving math problems using appropriate mathematical methods.", + "category": "existing", + "tools": null + } + ], + "events": [ + { + "name": "on_start", + "inputs": [ + { + "key": "math_problem", + "description": "The mathematical problem that needs to be solved." + } + ], + "task": null, + "outputs": [ + { + "key": "math_problem", + "description": "The mathematical problem that needs to be solved.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": null, + "agent": null + }, + { + "name": "extract_objective", + "inputs": [ + { + "key": "math_problem", + "description": "The mathematical problem that needs to be solved." + } + ], + "task": "Analyze the math problem and extract the main objective or question being asked.", + "outputs": [ + { + "key": "objective", + "description": "The main objective or question extracted from the math problem.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Objective Extraction Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "extract_conditions", + "inputs": [ + { + "key": "math_problem", + "description": "The mathematical problem that needs to be solved." + }, + { + "key": "objective", + "description": "The main objective or question extracted from the math problem." + } + ], + "task": "Extract all relevant conditions, given values, and constraints from the math problem.", + "outputs": [ + { + "key": "conditions", + "description": "The extracted conditions, values, and constraints from the math problem.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "extract_objective" + ], + "agent": { + "name": "Condition Extraction Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "solve_problem", + "inputs": [ + { + "key": "objective", + "description": "The main objective or question extracted from the math problem." + }, + { + "key": "conditions", + "description": "The extracted conditions, values, and constraints from the math problem." + } + ], + "task": "Evaluate whether conditions are sufficient and solve the math problem if possible.", + "outputs": [ + { + "key": "solution", + "description": "The complete solution to the math problem.", + "condition": "When conditions are sufficient to solve the problem.", + "action": { + "type": "RESULT", + "value": null + } + }, + { + "key": "insufficient_conditions", + "description": "Feedback about missing or unclear conditions.", + "condition": "When conditions are insufficient to solve the problem.", + "action": { + "type": "GOTO", + "value": "extract_conditions" + } + } + ], + "listen": [ + "extract_conditions" + ], + "agent": { + "name": "Math Solver Agent", + "model": "claude-3-5-sonnet-20241022" + } + } + ] +} \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_condition_mining.xml b/metachain/agents/meta_agent/workflow_form/math_condition_mining.xml new file mode 100644 index 0000000..0af0e38 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_condition_mining.xml @@ -0,0 +1,148 @@ + + math_problem_solver_workflow + + + math_problem + The mathematical problem that needs to be solved. + + + + solution + The detailed solution to the mathematical problem. + + + + + Objective Extraction Agent + This agent is specialized in analyzing math problems and extracting the main objective or question being asked. + + + Condition Extraction Agent + This agent is specialized in identifying and extracting all relevant conditions, given values, and constraints from the math problem. + + + Math Solver Agent + This agent is specialized in evaluating whether conditions are sufficient and solving math problems using appropriate mathematical methods. + + + + + + on_start + + + math_problem + The mathematical problem that needs to be solved. + + + + + math_problem + The mathematical problem that needs to be solved. + + RESULT + + + + + + + extract_objective + + + math_problem + The mathematical problem that needs to be solved. + + + Analyze the math problem and extract the main objective or question being asked. + + + objective + The main objective or question extracted from the math problem. + + RESULT + + + + + on_start + + + Objective Extraction Agent + gpt-4o-2024-08-06 + + + + + extract_conditions + + + math_problem + The mathematical problem that needs to be solved. + + + objective + The main objective or question extracted from the math problem. + + + Extract all relevant conditions, given values, and constraints from the math problem. + + + conditions + The extracted conditions, values, and constraints from the math problem. + + RESULT + + + + + extract_objective + + + Condition Extraction Agent + gpt-4o-2024-08-06 + + + + + solve_problem + + + objective + The main objective or question extracted from the math problem. + + + conditions + The extracted conditions, values, and constraints from the math problem. + + + Evaluate whether conditions are sufficient and solve the math problem if possible. + + + solution + The complete solution to the math problem. + When conditions are sufficient to solve the problem. + + RESULT + + + + insufficient_conditions + Feedback about missing or unclear conditions. + When conditions are insufficient to solve the problem. + + GOTO + extract_conditions + + + + + extract_conditions + + + Math Solver Agent + gpt-4o-2024-08-06 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_majority_voting.json b/metachain/agents/meta_agent/workflow_form/math_majority_voting.json new file mode 100644 index 0000000..cbba29a --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_majority_voting.json @@ -0,0 +1,173 @@ +{ + "name": "math_problem_solver_workflow", + "system_input": { + "key": "math_problem", + "description": "The math problem that needs to be solved." + }, + "system_output": { + "key": "final_solution", + "description": "The final solution to the math problem determined by majority voting." + }, + "global_variables": {}, + "agents": [ + { + "name": "Math Solver Agent", + "description": "This agent is specialized in solving math problems using appropriate mathematical methods.", + "category": "existing", + "tools": null + }, + { + "name": "Result Aggregator Agent", + "description": "This agent aggregates results from different models and determines the final answer through majority voting.", + "category": "existing", + "tools": null + } + ], + "events": [ + { + "name": "on_start", + "inputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved." + } + ], + "task": null, + "outputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": null, + "agent": null + }, + { + "name": "solve_with_gpt4", + "inputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved." + } + ], + "task": "Solve the math problem using GPT-4 model.", + "outputs": [ + { + "key": "gpt4_solution", + "description": "The solution provided by GPT-4 model.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Math Solver Agent", + "model": "gpt-4o-2024-08-06" + } + }, + { + "name": "solve_with_claude", + "inputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved." + } + ], + "task": "Solve the math problem using Claude model.", + "outputs": [ + { + "key": "claude_solution", + "description": "The solution provided by Claude model.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Math Solver Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "solve_with_deepseek", + "inputs": [ + { + "key": "math_problem", + "description": "The math problem that needs to be solved." + } + ], + "task": "Solve the math problem using Deepseek model.", + "outputs": [ + { + "key": "deepseek_solution", + "description": "The solution provided by Deepseek model.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Math Solver Agent", + "model": "deepseek/deepseek-chat" + } + }, + { + "name": "aggregate_results", + "inputs": [ + { + "key": "gpt4_solution", + "description": "The solution provided by GPT-4 model." + }, + { + "key": "claude_solution", + "description": "The solution provided by Claude model." + }, + { + "key": "deepseek_solution", + "description": "The solution provided by Deepseek model." + } + ], + "task": "Aggregate the solutions from different models and determine the final answer through majority voting.", + "outputs": [ + { + "key": "final_solution", + "description": "The final solution determined by majority voting.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "solve_with_gpt4", + "solve_with_claude", + "solve_with_deepseek" + ], + "agent": { + "name": "Result Aggregator Agent", + "model": "gpt-4o-2024-08-06" + } + } + ] +} \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_majority_voting.xml b/metachain/agents/meta_agent/workflow_form/math_majority_voting.xml new file mode 100644 index 0000000..496af76 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_majority_voting.xml @@ -0,0 +1,161 @@ + + math_problem_solver_workflow + + math_problem + The math problem that needs to be solved. + + + final_solution + The final solution to the math problem determined by majority voting. + + + + + Math Solver Agent + This agent is specialized in solving math problems using appropriate mathematical methods. + + + Result Aggregator Agent + This agent aggregates results from different models and determines the final answer through majority voting. + + + + + + on_start + + + math_problem + The math problem that needs to be solved. + + + + + math_problem + The math problem that needs to be solved. + + RESULT + + + + + + + solve_with_gpt4 + + + math_problem + The math problem that needs to be solved. + + + Solve the math problem using GPT-4 model. + + + gpt4_solution + The solution provided by GPT-4 model. + + RESULT + + + + + on_start + + + Math Solver Agent + gpt-4o-2024-08-06 + + + + + solve_with_claude + + + math_problem + The math problem that needs to be solved. + + + Solve the math problem using Claude model. + + + claude_solution + The solution provided by Claude model. + + RESULT + + + + + on_start + + + Math Solver Agent + claude-3-5-sonnet-20241022 + + + + + solve_with_deepseek + + + math_problem + The math problem that needs to be solved. + + + Solve the math problem using Deepseek model. + + + deepseek_solution + The solution provided by Deepseek model. + + RESULT + + + + + on_start + + + Math Solver Agent + deepseek/deepseek-chat + + + + + aggregate_results + + + gpt4_solution + The solution provided by GPT-4 model. + + + claude_solution + The solution provided by Claude model. + + + deepseek_solution + The solution provided by Deepseek model. + + + Aggregate the solutions from different models and determine the final answer through majority voting. + + + final_solution + The final solution determined by majority voting. + + RESULT + + + + + solve_with_gpt4 + solve_with_claude + solve_with_deepseek + + + Result Aggregator Agent + gpt-4o-2024-08-06 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_reasoning.xml b/metachain/agents/meta_agent/workflow_form/math_reasoning.xml new file mode 100644 index 0000000..3800c88 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_reasoning.xml @@ -0,0 +1,159 @@ + + + problem_path + The URL of the dataset containing math problems to solve: https://huggingface.co/datasets/openai/gsm8k + + + solution_results + The aggregated solution results with majority voting from multiple models + + + + + Data Fetcher Agent + This agent is responsible for downloading and processing the dataset from HuggingFace + + + Math Solver Agent + This agent is specialized in solving mathematical problems step by step + + + Result Aggregator Agent + This agent aggregates solutions from different models and performs majority voting + + + + + + dataset_url + The URL of the GSM8K dataset + https://huggingface.co/datasets/openai/gsm8k + + + + + + fetch_dataset + + problem_path + The URL of the dataset to download + + Download the GSM8K dataset and save it locally + + + dataset_content + The downloaded math problems from the dataset + + RESULT + + + + + Data Fetcher Agent + gpt-4o-2024-08-06 + + + + + solve_with_gpt4 + + dataset_content + The math problems to solve + + Solve the math problems using GPT-4 model + + + gpt4_solutions + Solutions generated by GPT-4 + + RESULT + + + + + fetch_dataset + + + Math Solver Agent + gpt-4-1106-preview + + + + + solve_with_claude + + dataset_content + The math problems to solve + + Solve the math problems using Claude model + + + claude_solutions + Solutions generated by Claude + + RESULT + + + + + fetch_dataset + + + Math Solver Agent + claude-3-opus-20240229 + + + + + solve_with_mixtral + + dataset_content + The math problems to solve + + Solve the math problems using Mixtral model + + + mixtral_solutions + Solutions generated by Mixtral + + RESULT + + + + + fetch_dataset + + + Math Solver Agent + mixtral-8x7b-instruct + + + + + aggregate_results + + model_solutions + Solutions from different models to be aggregated + + Aggregate solutions from all models using majority voting + + + solution_results + Final aggregated solutions with majority voting + + RESULT + + + + + solve_with_gpt4 + solve_with_claude + solve_with_mixtral + + + Result Aggregator Agent + gpt-4o-2024-08-06 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_reasoning2.xml b/metachain/agents/meta_agent/workflow_form/math_reasoning2.xml new file mode 100644 index 0000000..a6897cf --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_reasoning2.xml @@ -0,0 +1,167 @@ + + + dataset_url + The URL of the math problem dataset on huggingface. + + + + final_result + The final solution of the math problem after majority voting. + + + + + Data Loader Agent + This agent is responsible for downloading and processing the dataset. + + download_file + analyze_data + load_one_instance + + + + + Math Solver Agent + This agent is responsible for solving math problems using different language models. + + + + Result Aggregator Agent + This agent aggregates results from different solvers and performs majority voting. + + + + + + dataset_path + Local path where the dataset will be stored + /workspace/data/math_dataset.json + + + + + + download_data + + dataset_url + The URL of the math problem dataset + + Download the dataset from huggingface and analyze its structure + + + problem_instance + A single math problem instance from the dataset + + RESULT + + + + + Data Loader Agent + gpt-4o-2024-08-06 + + + + + solve_with_gpt4 + + problem_instance + Math problem to solve + + Solve the math problem using GPT-4 + + + gpt4_solution + Solution from GPT-4 model + + RESULT + + + + + download_data + + + Math Solver Agent + gpt-4o-2024-08-06 + + + + + solve_with_claude + + problem_instance + Math problem to solve + + Solve the math problem using Claude + + + claude_solution + Solution from Claude model + + RESULT + + + + + download_data + + + Math Solver Agent + claude-3-5-sonnet-20241022 + + + + + solve_with_palm + + problem_instance + Math problem to solve + + Solve the math problem using PaLM + + + palm_solution + Solution from PaLM model + + RESULT + + + + + download_data + + + Math Solver Agent + palm-2-4-chat-20240125 + + + + + aggregate_results + + solutions + Solutions from all models + + Aggregate solutions using majority voting + + + final_result + Final solution after majority voting + + RESULT + + + + + solve_with_gpt4 + solve_with_claude + solve_with_palm + + + Result Aggregator Agent + gpt-4o-2024-08-06 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_reasoning3.json b/metachain/agents/meta_agent/workflow_form/math_reasoning3.json new file mode 100644 index 0000000..0df491a --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_reasoning3.json @@ -0,0 +1,158 @@ +{ + "name": "math_problem_solver_workflow", + "system_input": { + "key": "math_problem", + "description": "The math problem that needs to be solved." + }, + "system_output": { + "key": "final_solution", + "description": "The final solution to the math problem determined through majority voting." + }, + "global_variables": {}, + "agents": [ + { + "name": "Math Solver Agent", + "description": "This agent is specialized in solving math problems using appropriate mathematical methods.", + "category": "existing", + "tools": null + }, + { + "name": "Result Aggregator Agent", + "description": "This agent is specialized in aggregating results from different models and determining the final answer through majority voting.", + "category": "existing", + "tools": null + } + ], + "events": [ + { + "name": "on_start", + "input": { + "key": "math_problem", + "description": "The math problem that needs to be solved." + }, + "task": "Distribute the math problem to parallel solvers.", + "outputs": [ + { + "key": "problem_ready", + "description": "Math problem ready for parallel processing.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": null, + "agent": { + "name": "Math Solver Agent", + "model": "gpt-4o-2024-08-06" + } + }, + { + "name": "solve_with_gpt4", + "input": { + "key": "problem_ready", + "description": "Math problem to be solved." + }, + "task": "Solve the math problem using GPT-4 model.", + "outputs": [ + { + "key": "gpt4_solution", + "description": "Solution from GPT-4 model.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Math Solver Agent", + "model": "gpt-4o-2024-08-06" + } + }, + { + "name": "solve_with_claude", + "input": { + "key": "problem_ready", + "description": "Math problem to be solved." + }, + "task": "Solve the math problem using Claude model.", + "outputs": [ + { + "key": "claude_solution", + "description": "Solution from Claude model.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Math Solver Agent", + "model": "claude-3-5-sonnet-20241022" + } + }, + { + "name": "solve_with_deepseek", + "input": { + "key": "problem_ready", + "description": "Math problem to be solved." + }, + "task": "Solve the math problem using Deepseek model.", + "outputs": [ + { + "key": "deepseek_solution", + "description": "Solution from Deepseek model.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Math Solver Agent", + "model": "deepseek/deepseek-chat" + } + }, + { + "name": "aggregate_results", + "input": { + "key": "multiple_solutions", + "description": "Solutions from all three models." + }, + "task": "Aggregate solutions from different models and determine final answer through majority voting.", + "outputs": [ + { + "key": "final_solution", + "description": "Final solution determined through majority voting.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "solve_with_gpt4", + "solve_with_claude", + "solve_with_deepseek" + ], + "agent": { + "name": "Result Aggregator Agent", + "model": "gpt-4o-2024-08-06" + } + } + ] +} \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_reasoning3.xml b/metachain/agents/meta_agent/workflow_form/math_reasoning3.xml new file mode 100644 index 0000000..1f6beab --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_reasoning3.xml @@ -0,0 +1,148 @@ + + math_problem_solver_workflow + + math_problem + The math problem that needs to be solved. + + + final_solution + The final solution to the math problem determined through majority voting. + + + + + Math Solver Agent + This agent is specialized in solving math problems using appropriate mathematical methods. + + + Result Aggregator Agent + This agent is specialized in aggregating results from different models and determining the final answer through majority voting. + + + + + + on_start + + math_problem + The math problem that needs to be solved. + + Distribute the math problem to parallel solvers. + + + problem_ready + Math problem ready for parallel processing. + + RESULT + + + + + Math Solver Agent + gpt-4o-2024-08-06 + + + + + solve_with_gpt4 + + problem_ready + Math problem to be solved. + + Solve the math problem using GPT-4 model. + + + gpt4_solution + Solution from GPT-4 model. + + RESULT + + + + + on_start + + + Math Solver Agent + gpt-4o-2024-08-06 + + + + + solve_with_claude + + problem_ready + Math problem to be solved. + + Solve the math problem using Claude model. + + + claude_solution + Solution from Claude model. + + RESULT + + + + + on_start + + + Math Solver Agent + claude-3-5-sonnet-20241022 + + + + + solve_with_deepseek + + problem_ready + Math problem to be solved. + + Solve the math problem using Deepseek model. + + + deepseek_solution + Solution from Deepseek model. + + RESULT + + + + + on_start + + + Math Solver Agent + deepseek/deepseek-chat + + + + + aggregate_results + + multiple_solutions + Solutions from all three models. + + Aggregate solutions from different models and determine final answer through majority voting. + + + final_solution + Final solution determined through majority voting. + + RESULT + + + + + solve_with_gpt4 + solve_with_claude + solve_with_deepseek + + + Result Aggregator Agent + gpt-4o-2024-08-06 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_reasoning4.json b/metachain/agents/meta_agent/workflow_form/math_reasoning4.json new file mode 100644 index 0000000..1cd428f --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_reasoning4.json @@ -0,0 +1,119 @@ +{ + "name": "mining_conditions_workflow", + "system_input": { + "key": "math_problem", + "description": "The math problem that user wants to solve." + }, + "system_output": { + "key": "solution", + "description": "The detailed solution of the math problem, including steps and final answer." + }, + "global_variables": {}, + "agents": [ + { + "name": "Objective Extraction Agent", + "description": "This agent is specialized in analyzing math problems and extracting the main objective or question being asked.", + "category": "new", + "tools": null + }, + { + "name": "Condition Extraction Agent", + "description": "This agent is specialized in identifying and extracting all relevant conditions, given values, and constraints from the math problem.", + "category": "new", + "tools": null + }, + { + "name": "Math Solver Agent", + "description": "This agent is specialized in evaluating whether conditions are sufficient and solving math problems using appropriate mathematical methods.", + "category": "new", + "tools": null + } + ], + "events": [ + { + "name": "on_start", + "input": { + "key": "math_problem", + "description": "The original math problem text." + }, + "task": "Extract the main objective or question from the math problem.", + "outputs": [ + { + "key": "objective", + "description": "The clearly defined objective or question that needs to be solved.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": null, + "agent": { + "name": "Objective Extraction Agent", + "model": "gpt-4o-2024-08-06" + } + }, + { + "name": "extract_conditions", + "input": { + "key": "objective", + "description": "The objective of the math problem." + }, + "task": "Extract all relevant conditions, given values, and constraints from the math problem.", + "outputs": [ + { + "key": "conditions", + "description": "List of all identified conditions and constraints.", + "condition": null, + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "on_start" + ], + "agent": { + "name": "Condition Extraction Agent", + "model": "gpt-4o-2024-08-06" + } + }, + { + "name": "evaluate_and_solve", + "input": { + "key": "conditions", + "description": "The extracted conditions and constraints." + }, + "task": "Evaluate if conditions are sufficient and solve the math problem if possible.", + "outputs": [ + { + "key": "insufficient_conditions", + "description": "Conditions are not sufficient to solve the problem.", + "condition": "If the current conditions are not enough to solve the problem.", + "action": { + "type": "GOTO", + "value": "extract_conditions" + } + }, + { + "key": "solution", + "description": "Complete solution with steps and final answer.", + "condition": "If conditions are sufficient to solve the problem.", + "action": { + "type": "RESULT", + "value": null + } + } + ], + "listen": [ + "extract_conditions" + ], + "agent": { + "name": "Math Solver Agent", + "model": "gpt-4o-2024-08-06" + } + } + ] +} \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/math_reasoning4.xml b/metachain/agents/meta_agent/workflow_form/math_reasoning4.xml new file mode 100644 index 0000000..7868aa7 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/math_reasoning4.xml @@ -0,0 +1,113 @@ + + mining_conditions_workflow + + math_problem + The math problem that user wants to solve. + + + + solution + The detailed solution of the math problem, including steps and final answer. + + + + + Objective Extraction Agent + This agent is specialized in analyzing math problems and extracting the main objective or question being asked. + + + + Condition Extraction Agent + This agent is specialized in identifying and extracting all relevant conditions, given values, and constraints from the math problem. + + + + Math Solver Agent + This agent is specialized in evaluating whether conditions are sufficient and solving math problems using appropriate mathematical methods. + + + + + + on_start + + math_problem + The original math problem text. + + Extract the main objective or question from the math problem. + + + objective + The clearly defined objective or question that needs to be solved. + + RESULT + + + + + Objective Extraction Agent + gpt-4o-2024-08-06 + + + + + extract_conditions + + objective + The objective of the math problem. + + Extract all relevant conditions, given values, and constraints from the math problem. + + + conditions + List of all identified conditions and constraints. + + RESULT + + + + + on_start + + + Condition Extraction Agent + gpt-4o-2024-08-06 + + + + + evaluate_and_solve + + conditions + The extracted conditions and constraints. + + Evaluate if conditions are sufficient and solve the math problem if possible. + + + insufficient_conditions + Conditions are not sufficient to solve the problem. + If the current conditions are not enough to solve the problem. + + GOTO + extract_conditions + + + + solution + Complete solution with steps and final answer. + If conditions are sufficient to solve the problem. + + RESULT + + + + + extract_conditions + + + Math Solver Agent + gpt-4o-2024-08-06 + + + + \ No newline at end of file diff --git a/metachain/agents/meta_agent/workflow_form/parallelization.xml b/metachain/agents/meta_agent/workflow_form/parallelization.xml new file mode 100644 index 0000000..e69de29 diff --git a/metachain/agents/meta_agent/workflow_form/prompt_chaining.xml b/metachain/agents/meta_agent/workflow_form/prompt_chaining.xml new file mode 100644 index 0000000..b043cbd --- /dev/null +++ b/metachain/agents/meta_agent/workflow_form/prompt_chaining.xml @@ -0,0 +1,144 @@ + + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + + article + The article that satisfies the user's request. + + + + Web Surfer Agent + This agent is used to search the web for the user's topic. + + + Outline Agent + This agent is used to write an outline for the user's topic. + + + Evaluator Agent + This agent is used to evaluate the outline of the user's topic. + + + Article Writer Agent + This agent is used to write the article for the user's topic. + + + + + on_start + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + + search the information about the topic and return the result. + + + + search_result + The search result of the user's topic. + + RESULT + + + + + Web Surfer Agent + gpt-4o-2024-08-06 + + + + on_outline + + search_result + The search result of the user's topic. + + + write an outline for the user's topic. + + + + outline + The outline of the user's topic. + + RESULT + + + + + on_start + + + Outline Agent + gpt-4o-2024-08-06 + + + + on_evaluate + + outline + The outline of the user's topic. + + + evaluate the outline of the user's topic. + + + + positive_feedback + The positive feedback of the outline of the user's topic. + + If the outline is good enough, give positive feedback. + + + RESULT + + + + negative_feedback + The negative feedback of the outline of the user's topic. + + If the outline is not good enough, give negative feedback. + + + ABORT + + + + + on_outline + + + Evaluator Agent + gpt-4o-2024-08-06 + + + + on_write + + outline + The outline of user's topic. + + + write the article for the user's topic. + + + + article + The article of the user's topic. + + RESULT + + + + + on_evaluate + + + Article Writer Agent + gpt-4o-2024-08-06 + + + + diff --git a/metachain/agents/meta_agent/workflow_former.py b/metachain/agents/meta_agent/workflow_former.py new file mode 100644 index 0000000..dae20a0 --- /dev/null +++ b/metachain/agents/meta_agent/workflow_former.py @@ -0,0 +1,578 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_agents import list_agents, create_agent, delete_agent, run_agent, read_agent +from metachain.tools.meta.edit_tools import list_tools, create_tool, delete_tool, run_tool +from metachain.tools.meta.edit_workflow import list_workflows +from metachain.tools.terminal_tools import execute_command +from metachain.types import Agent +from metachain.io_utils import read_file +from pydantic import BaseModel, Field +from typing import List +import json + + +@register_agent(name = "Workflow Former Agent", func_name="get_workflow_former_agent") +def get_workflow_former_agent(model: str) -> str: + """ + This agent is used to complete a form that can be used to create a workflow consisting of multiple agents. + """ + def instructions(context_variables): + workflow_list = list_workflows(context_variables) + workflow_list = json.loads(workflow_list) + workflow_list = [workflow_name for workflow_name in workflow_list.keys()] + workflow_list_str = ", ".join(workflow_list) + return r"""\ +You are an agent specialized in creating workflow forms for the MetaChain framework. + +Your task is to analyze user requests and generate structured creation forms for workflows consisting of multiple agents. + +KEY COMPONENTS OF THE FORM: +1. - Root element containing the entire workflow definition + +2. - The name of the workflow. It should be a single word with '_' as the separator, and as unique as possible to describe the speciality of the workflow. + +3. - Defines what the system receives + - Must describe the overall input that the system accepts + - : Single identifier for the input, could be a single word with '_' as the separator. + - : Detailed explanation of input format + +4. - Specifies system response format + - Must contain exactly ONE key-description pair + - : Single identifier for the system's output, could be a single word with '_' as the separator. + - : Explanation of the output format + + +5. - Contains all agent definitions + - Each can be existing or new (specified by category attribute) + - name: Agent's identifier + - description: Agent's purpose and capabilities + - tools: (optional): Only required for new agents when specific tools are requested + * Only include when user explicitly requests certain tools + +6. - Shared variables across agents in the workflow (optional) + - Used for constants or shared values accessible by all agents in EVERY event in the workflow + - Example: + ```xml + + + user_name + The name of the user + John Doe + + + ``` + +7. - Defines the workflow execution flow + Each contains: + - name: Event identifier + - inputs: What this event receives, should exactly match with the output keys of the events it's listening to + * Each input has: + - key: Input identifier (should match an output key from listened events) + - description: Input explanation + - task: What this event should accomplish + - outputs: Possible outcomes of this event + * Each output has: + - action: What happens after. Every action has a type and a optional value. Action is categorized into 3 types: + - RESULT: The event is successful, and the workflow will continue to the next event which is listening to this event. Value is the output of this event. + - ABORT: The event is not successful, and the workflow will abort. Value could be empty. + - GOTO: The event is not successful, and the workflow will wait for the next event. Value is the name of the event to go to. The event go to should NOT listen to this event. + - key: Output identifier (be a single word with '_' as the separator) + - description: Output explanation + - condition: when the output occurs, the action will be executed + * Can have single or multiple outputs: + - For single output (simple flow): + ```xml + + + result_key + Description of the result + + RESULT + + + + ``` + - For multiple outputs (conditional flow): + ```xml + + + success_result + Output when condition A is met + When condition A is true + + RESULT + + + + should_repeat + Output when condition B is met + When condition B is true + + GOTO + target_event + + + + failure_result + Output when condition C is met + When condition C is true + + ABORT + + + + ``` + - listen: Which events trigger this one. + - agent: Which agent handles this event. Every agent has the name of the agent, and the exact model of the agent (like `claude-3-5-sonnet-20241022` or others) + + +IMPORTANT RULES: +0. The `on_start` event is a special event that: + - Must be the first event in the workflow + - Has inputs that match the system_input + - Has outputs that match the system_input (just pass through) + - Does not have an agent + - Does not have a task + - Does not have listen elements + Example: + ```xml + + on_start + + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + + + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + RESULT + + + + + ``` + +1. For simple sequential flows: + - Use single output with RESULT type + - No condition is needed + - Next event in chain listening to this event will be triggered automatically + +2. For conditional flows: + - Multiple outputs must each have a condition + - Conditions should be mutually exclusive + - Each output should specify appropriate action type + - `GOTO` action should have a value which is the name of the event to go to + +3. Only include tools section when: + - Agent is new (category="new") AND + - User explicitly requests specific tools for the agent + +4. Omit tools section when: + - Using existing agents (category="existing") OR + - Creating new agents without specific tool requirements +""" + \ +f""" +Existing tools you can use is: +{list_tools(context_variables)} + +Existing agents you can use is: +{list_agents(context_variables)} + +The name of existing workflows: [{workflow_list_str}]. The name of the new workflow you are creating should be DIFFERENT from these names according to the speciality of the workflow. +""" + \ +r""" +COMMON WORKFLOW PATTERNS: + +1. If-Else Pattern (Conditional Branching): +```xml + + analyze_data + Analyze the data and determine next steps + + + positive_case + Handle positive case + If data meets criteria A + + RESULT + + + + negative_case + Handle the negative case + If data does not meet criteria A + + ABORT + + + + +``` + +2. Parallelization Pattern (Concurrent Execution): +```xml + + + initial_analysis + + + analysis_result + Initial analysis result + + RESULT + + + + + + + + technical_analysis + + initial_analysis + + + + technical_result + Technical analysis result + + RESULT + + + + + + + financial_analysis + + initial_analysis + + + + financial_result + Financial analysis result + + RESULT + + + + + + + + combine_results + + + technical_result + The technical analysis result. + + + financial_result + The financial analysis result. + + + + technical_analysis + financial_analysis + + + +``` + +3. Evaluator-Optimizer Pattern (Iterative Refinement): +```xml + + generate_content + + + content + Generated content + + RESULT + + + + + + + evaluate_content + + generate_content + + Evaluate the quality of generated content + + + approved + Content meets quality standards + If quality score >= threshold + + RESULT + + + + needs_improvement + Content needs improvement + If quality score < threshold + + GOTO + generate_content + + + + +``` + +IMPORTANT NOTES ON PATTERNS: +0. The above patterns are incomplete which some mandatory elements are missing due to the limitation of context length. In real-world, you could refer to the logic of the patterns to create a complete and correct workflow. + +1. If-Else Pattern: + - Use mutually exclusive conditions + - You can NOT place MORE THAN ONE OUTPUT with RESULT type + - Outputs determine which branch executes + +2. Parallelization Pattern: + - Multiple events can listen to the same parent event + - Aggregator event must list ALL parallel events in its listen section + - All parallel events must complete before aggregator executes + - Model of agents in every parallel event could be different + +3. Evaluator-Optimizer Pattern: + - Use GOTO action for iteration + - Include clear evaluation criteria in conditions + - Have both success and retry paths + - Consider adding maximum iteration limit in global_variables +""" + \ +r""" +EXAMPLE: + +User: I want to build a workflow that can help me to write a wikipiead-like article about the user's topic. It should: +1. Search the web for the user's topic. +2. Write an outline for the user's topic. +3. Evaluate the outline. If the outline is not good enough, repeat the outline step, otherwise, continue to write the article. +4. Write the article. + +The form should be: + + wiki_article_workflow + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + + article + The article that satisfies the user's request. + + + + Web Surfer Agent + This agent is used to search the web for the user's topic. + + + Outline Agent + This agent is used to write an outline for the user's topic. + + + Evaluator Agent + This agent is used to evaluate the outline of the user's topic. + + + Article Writer Agent + This agent is used to write the article for the user's topic. + + + + + + on_start + + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + + + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + RESULT + + + + + + on_search + + + user_topic + The user's topic that user wants to write a wikipiead-like article about. + + + + search the information about the topic and return the result. + + + + search_result + The search result of the user's topic. + + RESULT + + + + + on_start + + + Web Surfer Agent + claude-3-5-sonnet-20241022 + + + + on_outline + + + search_result + The search result of the user's topic. + + + + write an outline for the user's topic. + + + + outline + The outline of the user's topic. + + RESULT + + + + + on_start + + + Outline Agent + claude-3-5-sonnet-20241022 + + + + on_evaluate + + + outline + The outline of the user's topic. + + + + evaluate the outline of the user's topic. + + + + positive_feedback + The positive feedback of the outline of the user's topic. + + If the outline is good enough, give positive feedback. + + + RESULT + + + + negative_feedback + The negative feedback of the outline of the user's topic. + + If the outline is not good enough, give negative feedback. + + + GOTO + on_outline + + + + + on_outline + + + Evaluator Agent + claude-3-5-sonnet-20241022 + + + + on_write + + + outline + The outline of user's topic. + + + + write the article for the user's topic. + + + + article + The article of the user's topic. + + RESULT + + + + + on_evaluate + + + Article Writer Agent + claude-3-5-sonnet-20241022 + + + + + +GUIDELINES: +1. Each event should have clear inputs and outputs +2. Use conditions to handle different outcomes +3. Properly chain events using the listen element +4. Review steps should be included for quality control +5. Action types should be either RESULT or ABORT + +Follow these examples and guidelines to create appropriate workflow forms based on user requirements. +""" + return Agent( + name = "Workflow Former Agent", + model = model, + instructions = instructions, + ) + +if __name__ == "__main__": + from metachain import MetaChain + agent = get_workflow_former_agent("claude-3-5-sonnet-20241022") + client = MetaChain() +# task_yaml = """\ +# I want to create a workflow that can help me to solving the math problem. + +# The workflow should: +# 2. Parallelize solving the math problem with the same `Math Solver Agent` using different language models (`gpt-4o-2024-08-06`, `claude-3-5-sonnet-20241022`, `deepseek/deepseek-chat`) +# 3. Aggregate the results from the `Math Solver Agent` and return the final result using majority voting. + +# Please create the form of this workflow in the XML format. +# """ + task_yaml = """\ +I want to create a workflow that can help me to solving the math problem. + +The workflow should: +1. The `Objective Extraction Agent` will extract the objective of the math problem. +2. The `Condition Extraction Agent` will extract the conditions of the math problem. +3. The `Math Solver Agent` will evaluate whether the conditions are enough to solve the math problem: if yes, solve the math problem; if no, return to the `Condition Extraction Agent` to extract more conditions. + +Please create the form of this workflow in the XML format. +""" + task_yaml = task_yaml + """\ +Directly output the form in the XML format. +""" + messages = [{"role": "user", "content": task_yaml}] + response = client.run(agent, messages) + print(response.messages[-1]["content"]) \ No newline at end of file diff --git a/metachain/agents/meta_agent/worklow_form_complie.py b/metachain/agents/meta_agent/worklow_form_complie.py new file mode 100644 index 0000000..8a4d1e8 --- /dev/null +++ b/metachain/agents/meta_agent/worklow_form_complie.py @@ -0,0 +1,335 @@ +from pydantic import BaseModel, Field, field_validator, ValidationInfo, model_validator +from typing import List, Dict, Optional, Literal, Union +import xml.etree.ElementTree as ET +import re +# 基础模型 + +class WorkflowFormParseError(Exception): + """Exception raised when WorkflowForm failed to parse. + """ + + def __init__(self, message): + super().__init__(message) + +class WorkflowConstraintError(Exception): + """Exception raised when WorkflowForm failed to parse. Use this Exception to raise when the workflow form does not meet some specific constraints. + """ + + def __init__(self, message): + super().__init__(message) + +class KeyDescription(BaseModel): + key: str + description: str + +class Tool(BaseModel): + name: str + description: str + +class Action(BaseModel): + type: Literal["RESULT", "ABORT", "GOTO"] + value: Optional[str] = None + + @field_validator('value') + def validate_goto_value(cls, v, info: ValidationInfo): + if info.data.get('type') == 'GOTO' and not v: + raise WorkflowConstraintError("GOTO action must have a value") + return v + +class Output(BaseModel): + key: str + description: str + condition: Optional[str] = None + action: Action + + @field_validator('condition') + def validate_condition(cls, v, info: ValidationInfo): + """验证condition的存在性""" + outputs_info = info.data.get('_outputs_info', {}) + if outputs_info.get('multiple_outputs', False) and not v: + raise WorkflowConstraintError("Multiple outputs must each have a condition") + return v + +class Event(BaseModel): + name: str + inputs: Optional[List[KeyDescription]] = None # 修改这里 + task: Optional[str] = None # 修改为可选 + outputs: List[Output] + listen: Optional[List[str]] = None + agent: Optional[Dict[str, str]] = None # 修改为可选 + + @field_validator('task') + def validate_task(cls, v, info: ValidationInfo): + """验证非on_start事件必须有task""" + if info.data.get('name') != 'on_start' and not v: + raise WorkflowConstraintError("Non-start events must have a task") + return v + @field_validator('agent') + def validate_agent(cls, v, info: ValidationInfo): + """验证非on_start事件必须有agent""" + if info.data.get('name') != 'on_start' and not v: + raise WorkflowConstraintError("Non-start events must have an agent") + return v + + @field_validator('listen') + def validate_listen(cls, v, info: ValidationInfo): + """验证on_start事件不能有listen""" + if info.data.get('name') == 'on_start' and v: + raise WorkflowConstraintError("Start event cannot have listen elements") + return v + + @field_validator('name') + def validate_start_event(cls, v, info: ValidationInfo): + """验证起始事件的名称""" + if info.data.get('is_start_event', False) and v != "on_start": + raise WorkflowConstraintError("Start event must be named 'on_start'") + return v + @field_validator('outputs') + def validate_start_event_outputs(cls, v, info: ValidationInfo): + """验证on_start事件的输出必须与输入相同""" + if info.data.get('name') == 'on_start': + inputs = info.data.get('inputs', []) + if len(v) != len(inputs): + raise WorkflowConstraintError("Start event outputs must match inputs") + for output, input in zip(v, inputs): + if output.key != input.key or output.description != input.description: + raise WorkflowConstraintError("Start event output must match input") + return v + + @field_validator('outputs') + def validate_outputs(cls, v): + """验证输出的合法性""" + result_outputs = [out for out in v if out.action.type == "RESULT"] + if len(result_outputs) > 1: + raise WorkflowConstraintError("Cannot have more than one RESULT type output") + return v + + @model_validator(mode='after') + def validate_event_constraints(self) -> 'Event': + """验证事件的所有约束""" + # 如果是 on_start event,跳过输入验证 + if self.name == "on_start": + return self + + # 验证非on_start事件的输入 + if self.inputs is None: + raise WorkflowConstraintError(f"Event '{self.name}': Non-start events must have inputs") + + # 验证listen是否存在 + if self.listen is None: + raise WorkflowConstraintError(f"Event '{self.name}': Non-start events must have listen events") + + # 验证输入数量 + if len(self.inputs) != len(self.listen): + raise WorkflowConstraintError( + f"Event '{self.name}': Number of inputs ({len(self.inputs)}) must match number of listen events ({len(self.listen)})" + ) + + return self + +class Agent(BaseModel): + name: str + description: str + category: Literal["existing", "new"] + tools: Optional[List[Tool]] = None + + @field_validator('tools') + def validate_tools(cls, v, info: ValidationInfo): + """验证tools的存在性""" + if info.data.get('category') == 'existing' and v: + raise WorkflowConstraintError("Existing agents should not have tools defined") + return v + +class WorkflowForm(BaseModel): + name: str + system_input: KeyDescription + system_output: KeyDescription + global_variables: Dict[str, str] = Field(default_factory=dict) + agents: List[Agent] + events: List[Event] + + @field_validator('events') + def validate_events(cls, v): + """验证事件流的合法性""" + # 验证是否有且仅有一个on_start事件 + start_events = [e for e in v if e.name == "on_start"] + if len(start_events) != 1: + raise WorkflowConstraintError("Must have exactly one 'on_start' event") + + # 验证事件监听的合法性 + event_names = {e.name for e in v} + for event in v: + if event.listen: + for listened_event in event.listen: + if listened_event not in event_names: + raise WorkflowConstraintError(f"Event {event.name} listens to non-existent event {listened_event}") + return v + @model_validator(mode='after') + def validate_event_order(self) -> 'WorkflowForm': + """验证事件的监听顺序: + 1. 事件只能监听在它之前定义的事件 + 2. 不能有循环依赖 + """ + # 创建事件名称到索引的映射 + event_indices = {event.name: idx for idx, event in enumerate(self.events)} + + # 验证每个事件的监听关系 + for idx, event in enumerate(self.events): + if event.listen: + for listened_event_name in event.listen: + # 检查被监听的事件是否存在 + if listened_event_name not in event_indices: + raise WorkflowConstraintError( + f"Event '{event.name}': Referenced listen event '{listened_event_name}' not found" + ) + + # 检查是否监听了后面的事件 + listened_idx = event_indices[listened_event_name] + if listened_idx >= idx: + raise WorkflowConstraintError( + f"Event '{event.name}' cannot listen to event '{listened_event_name}' " + f"because it appears later in the workflow or creates a cycle" + ) + + return self + +class XMLParser: + @staticmethod + def parse_key_description(elem: ET.Element) -> KeyDescription: + return KeyDescription( + key=elem.find('key').text.strip(), + description=elem.find('description').text.strip() + ) + + @staticmethod + def parse_action(elem: ET.Element) -> Action: + action_elem = elem.find('action') + return Action( + type=action_elem.find('type').text.strip(), + value=action_elem.find('value').text.strip() if action_elem.find('value') is not None else None + ) + + @staticmethod + def parse_output(elem: ET.Element, multiple_outputs: bool) -> Output: + return Output( + key=elem.find('key').text.strip(), + description=elem.find('description').text.strip(), + condition=elem.find('condition').text.strip() if elem.find('condition') is not None else None, + action=XMLParser.parse_action(elem), + _outputs_info={'multiple_outputs': multiple_outputs} + ) + + @staticmethod + def parse_event(elem: ET.Element, is_start: bool = False) -> Event: + name = elem.find('name').text.strip() + is_start = name == 'on_start' + + outputs_elem = elem.find('outputs') + multiple_outputs = len(outputs_elem.findall('output')) > 1 + + listen_elem = elem.find('listen') + listen = [e.text.strip() for e in listen_elem.findall('event')] if listen_elem is not None and not is_start else None + + agent_elem = elem.find('agent') + agent = { + "name": agent_elem.find('name').text.strip(), + "model": agent_elem.find('model').text.strip() + } if agent_elem is not None and not is_start else None + + inputs_elem = elem.find('inputs') + inputs = [XMLParser.parse_key_description(input_elem) + for input_elem in inputs_elem.findall('input')] if inputs_elem is not None else None + task_elem = elem.find('task') + task = task_elem.text.strip() if task_elem is not None and not is_start else None + + return Event( + name=name, + inputs=inputs, + task=task, + outputs=[XMLParser.parse_output(out, multiple_outputs) + for out in outputs_elem.findall('output')], + listen=listen, + agent=agent, + is_start_event=is_start + ) + + @staticmethod + def parse_agent(elem: ET.Element) -> Agent: + tools_elem = elem.find('tools') + tools = None + if tools_elem is not None: + tools = [Tool( + name=tool.find('name').text.strip(), + description=tool.find('description').text.strip() + ) for tool in tools_elem.findall('tool')] + + return Agent( + name=elem.find('name').text.strip(), + description=elem.find('description').text.strip(), + category=elem.get('category'), + tools=tools + ) + + @classmethod + def parse_xml(cls, xml_content: str) -> WorkflowForm: + root = ET.fromstring(xml_content) + workflow_name = root.get('name') + if not workflow_name: + # If name attribute doesn't exist, try to find name element + name_elem = root.find('name') + workflow_name = name_elem.text.strip() if name_elem is not None else "Unnamed Workflow" + + return WorkflowForm( + name=workflow_name, + system_input=cls.parse_key_description(root.find('system_input')), + system_output=cls.parse_key_description(root.find('system_output')), + global_variables={var.find('key').text.strip(): var.find('value').text.strip() + for var in root.find('global_variables').findall('variable')} + if root.find('global_variables') is not None else {}, + agents=[cls.parse_agent(agent) for agent in root.findall('.//agents/agent')], + events=[cls.parse_event(event, event.find('name').text.strip() == 'on_start') + for event in root.findall('.//events/event')] + ) + +def extract_workflow_content(text): + pattern = r'(.*?)' + # re.DOTALL 让 . 也能匹配换行符 + match = re.search(pattern, text, re.DOTALL) + if match: + return match.group(1) + else: + raise WorkflowFormParseError("The workflow XML form is not correct. The workflow XML form should be wrapped by ... tags.") + +def parse_workflow_form(xml_content: str) -> Optional[WorkflowForm]: + """ + 读取并解析workflow form XML文件 + + Args: + xml_content: XML文件内容 + + Returns: + 解析后的WorkflowForm对象,如果解析失败返回None + """ + try: + workflow_content = extract_workflow_content(xml_content) + return XMLParser.parse_xml(workflow_content) + except WorkflowFormParseError as e: + return f"The Error to extract workflow content: {e}" + except WorkflowConstraintError as e: + return f"The generated workflow form MUST meet all the constraints in the given instructions, but the constraints are not met: {e}" + except ET.ParseError as e: + return f"The Error parsing XML workflow form: {e}" + except Exception as e: + return f"Unexpected error: {e}" + +# 使用示例 +if __name__ == "__main__": + # 读取和解析XML文件 + import json + with open("/Users/tangjiabin/Documents/reasoning/metachain/metachain/agents/meta_agent/workflow_form/condition_mining.xml", 'r', encoding='utf-8') as f: + xml_content = f.read() + form = parse_workflow_form(xml_content) + print(form) + with open("/Users/tangjiabin/Documents/reasoning/metachain/metachain/agents/meta_agent/workflow_form/condition_mining.json", 'w', encoding='utf-8') as f: + json.dump(form.model_dump(), f, indent=4) + workflow_form = form.model_dump() \ No newline at end of file diff --git a/metachain/agents/plan_agent.py b/metachain/agents/plan_agent.py new file mode 100644 index 0000000..ae48d69 --- /dev/null +++ b/metachain/agents/plan_agent.py @@ -0,0 +1,40 @@ + + +from metachain.types import Agent +from metachain.tools import ( + get_api_plugin_tools_doc, check_agent, check_tool +) +from metachain.registry import register_agent + +@register_agent(name = "Plan Agent", func_name="get_plan_agent") +def get_plan_agent(model: str): + def instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + return \ +f"""You are a planner working on an agent project named 'metachain' which can generate a coding plan for a given user request. +I want to use existing project code to solve the task. You should use the tools `check_agent` and `check_tool` to carefully go through the existing code to find out whether you should develop a new agent or new tool. +After you have checked the existing code, you should give a detailed plan for developing agents to solve the task based on the existing code, and ask user to confirm or modify the plan. +Finally, after user confirms the plan, you should generate the final coding plan and output it, and transfer the conversation to the 'Programming Triage Agent' to use the plan to execute the task util you finish the task, otherwise I will lose a lot of money. + +Follow the following rules to develop new tools: + +1. If you want to create new tools, you should first create a new file in the `metachain/metachain/tools` directory, write the function, and then add the function to the `metachain/metachain/tools/__init__.py`. Note that when add new tools into `__init__.py`, you first read the file content and keep the original content, then add the new tools into the file. +2. The tool is python functions. +3. When developing a new tool, you should follow the coding style of the existing tools, which means you should write docstring for the function, and add some useful comments to explain the code. +4. Function should usually return a `str` (values will be attempted to be cast as a `str`). +5. If you need to develop a new tool through external API, you should use `get_api_plugin_tools_doc` tool to get the tool doc, such as websearch, news search, financial tools, etc, otherwise you should develop a new tool by yourself. +6. If you need to develop a new tool related to vector database, you should use the pre-built class `Memory` in `/{working_dir}/metachain/metachain/memory/rag_memory.py` to save and retrieve the data. + +Follow the following instructions to develop new agents: + +1. If you want to create new agents, you should first create a new file in the `metachain/metachain/agents` directory, write the function `get_xxx_agent(model: str)`, and then add the function to the `metachain/metachain/agents/__init__.py`. Note that when add new agents into `__init__.py`, you first read the file content and keep the original content, then add the new agents into the file. + +Note that your plan should fit the given rules. +""" + return Agent( + name="Plan Agent", + model=model, + instructions=instructions, + functions=[check_agent, check_tool], + parallel_tool_calls = False + ) \ No newline at end of file diff --git a/metachain/agents/programming_agent.py b/metachain/agents/programming_agent.py new file mode 100644 index 0000000..75c0c4d --- /dev/null +++ b/metachain/agents/programming_agent.py @@ -0,0 +1,103 @@ +from metachain.types import Agent +from metachain.tools import ( + gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag, case_resolved, get_api_plugin_tools_doc +) +from metachain.util import make_message, make_tool_message +from metachain.registry import register_agent +def examples(context_variables): + working_dir = context_variables.get("working_dir", None) + examples_list = [] + examples_list.extend(make_message('user', "Create a list of numbers from 1 to 10, and display them in a web page at port 5000.")) + examples_list.extend(make_message('assistant', "I should first use create_file to write the python code into a file named 'app.py' for starting a web server")) + examples_list.extend(make_tool_message(create_file, {'path': f"/{working_dir}/metachain/app.py", + 'content': """ +from flask import Flask +app = Flask(__name__) + +@app.route('/') +def index(): + numbers = list(range(1, 11)) + return str(numbers) + +if __name__ == '__main__': + app.run(port=5000)"""}, f"File created at: /{working_dir}/metachain/app.py")) + examples_list.extend(make_message('assistant', 'I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you using `run_python`')) + examples_list.extend(make_tool_message(run_python, {'code_path': f"/{working_dir}/metachain/app.py"}, f""" +Traceback (most recent call last): + File "/{working_dir}/metachain/app.py", line 2, in + from flask import Flask +ModuleNotFoundError: No module named 'flask'""")) + examples_list.extend(make_message('assistant', "It seems that Flask is not installed. Let me install Flask for you using `execute_command` by the command: pip install flask")) + examples_list.extend(make_tool_message(execute_command, {'command': 'pip install flask'}, """Defaulting to user installation because normal site-packages is not writeable +Collecting flask + Using cached flask-3.0.3-py3-none-any.whl (101 kB) +Collecting blinker>=1.6.2 + Using cached blinker-1.7.0-py3-none-any.whl (13 kB) +Collecting Werkzeug>=3.0.0 + Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB) +Collecting click>=8.1.3 + Using cached click-8.1.7-py3-none-any.whl (97 kB) +Collecting itsdangerous>=2.1.2 + Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3) +Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5) +Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask +Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0""")) + examples_list.extend(make_message('assistant', "Now that Flask is installed, let me run the Python file again using `run_python`")) + examples_list.extend(make_tool_message(run_python, {'code_path': f"/{working_dir}/metachain/app.py"}, """* Serving Flask app 'app' + * Debug mode: off +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on http://127.0.0.1:5000 +Press CTRL+C to quit""")) + examples_list.extend(make_message('assistant', """The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. Your task is completed.""")) + return examples_list + +@register_agent(name = "Programming Agent", func_name="get_programming_agent") +def get_programming_agent(model: str): + def instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + ret_instructions = \ + f"""You are a programmer working on an agent project named 'metachain' whose path is /{working_dir}/metachain. + + Your can leverage your capabilities by using the specific functions listed below: + + 1. Creating project structures based on the user requirement using function `create_directory`. + 2. Writing clean, efficient, and well-documented code using function `create_file` and `write_file`. + 3. You must run python scripts using function `run_python` rather than using the `execute_command` function. + 4. Exam the project to re-use the existing code snippets as much as possible, you may need to use + functions like `list_files`, `read_file` and `write_file`. + 5. Writing the code into the file when creating new files, do not create empty files. + 6. If you are required to code base on the specific directory, you can use function `code_rag` to search the relatd codes in the specific directory, and remember you could only search one thing (like a function name, a class name, a variable name, etc.) in the codebase at a time. + 7. Before you write code into the existing files, you should first read the file content using function `read_file` and reserve the original content as much as possible. + 8. Decide whether the task requires execution and debugging before moving to the next or not. + 9. Generate the commands to run and test the current task, and the dependencies list for this task. + 10. You only write Python scripts, don't write Jupiter notebooks which require interactive execution. + 11. Note that every path you read, write, or search should be the absolute path (starting with '/'). + + Your task is using existing project to create agents to complete the user request. + If the existing tools or agents are not enough for your task, you should develop new tools or agents. + Follow the following routine: + 1. If there is enough pre-built tools and agents, create a python script in the `/{working_dir}/metachain` folder to run the agent to complete the user request. + 2. If you need to develop new tools, create a new tool in the `/{working_dir}/metachain/metachain/tools` folder. + 3. If you need to develop new agents, create a new agent in the `/{working_dir}/metachain/metachain/agents` folder. + 4. Create a python script in the `/{working_dir}/metachain` folder to run the new agent to complete the user request. + + Note that if you need OPENAI_API_KEY, my key is: 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 + """ + how_to_guides = context_variables.get("how_to_guides", None) + if how_to_guides: + ret_instructions += \ + f""" + If you want to develop new tools or agents, you should follow the following guides: + {how_to_guides} + """ + return ret_instructions + return Agent( + name="Programming Agent", + model=model, + instructions=instructions, + functions=[gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag, get_api_plugin_tools_doc], + # examples=examples, + tool_choice = "auto", + parallel_tool_calls = False + ) \ No newline at end of file diff --git a/metachain/agents/programming_triage_agent.py b/metachain/agents/programming_triage_agent.py new file mode 100644 index 0000000..e0b336b --- /dev/null +++ b/metachain/agents/programming_triage_agent.py @@ -0,0 +1,415 @@ +from metachain.types import Agent +from metachain.tools import ( + gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag, case_resolved, get_api_plugin_tools_doc +) +from metachain.util import make_message, make_tool_message +from metachain.registry import register_agent + +@register_agent(name = "Tool Creation Agent", func_name="get_tool_creation_agent") +def get_tool_creation_agent(model: str): + def tool_creation_instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + return \ + f"""You are working on an agent project named 'metachain' whose path is /{working_dir}/metachain. + Your task is to develop new tools in the `/{working_dir}/metachain/metachain/tools` folder. + + Follow the following instructions to develop new tools: + + 1. If you want to create new tools, you should first create a new file in the `metachain/metachain/tools` directory, write the function, and then add the function to the `metachain/metachain/tools/__init__.py`. Note that when add new tools into `__init__.py`, you first read the file content and keep the original content, then add the new tools into the file. + 2. The tool is python functions. + 3. When developing a new tool, you should follow the coding style of the existing tools, which means you should write docstring for the function, and add some useful comments to explain the code. + 4. Function should usually return a `str` (values will be attempted to be cast as a `str`). + 5. If there is any error during the development process, you should use tools to debug the error and fix the error, and you should not transfer the conversation back to the 'Programming Triage Agent' util the error is fixed. + 6. If you need to develop a new tool through external API, you should use `get_api_plugin_tools_doc` tool to get the tool doc, such as websearch, news search, financial tools, etc, otherwise you should develop a new tool by yourself. + 7. If you need to develop a new tool related to vector database, you should use the pre-built class `Memory` in `/{working_dir}/metachain/metachain/memory/rag_memory.py` to save and retrieve the data. + 8. You can add `if __name__ == "__main__":` at the end of the function file to make sure the function can be executed, and after testing all functions you should develop, using `transfer_back_to_programming_triage_agent` function to transfer the conversation back to the 'Programming Triage Agent', note that you should not transfer the conversation back to the 'Programming Triage Agent' util you finish the your task that is to develop all the tools and make sure they can be executed. + + Note that if you need OPENAI_API_KEY, my key is: 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 + """ + return Agent( + name="Tool Creation Agent", + model=model, + instructions=tool_creation_instructions, + functions=[gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag, get_api_plugin_tools_doc], + tool_choice = "auto", + parallel_tool_calls = False + ) + +@register_agent(name = "Agent Creation Agent", func_name="get_agent_creation_agent") +def get_agent_creation_agent(model: str): + def agent_creation_instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + return \ + f"""You are working on an agent project named 'metachain' whose path is /{working_dir}/metachain. + Your task is to develop new agents in the `/{working_dir}/metachain/metachain/agents` folder. + + Follow the following instructions to develop new agents: + + 1. If you want to create new agents, you should first create a new file in the `metachain/metachain/agents` directory, write the function `get_xxx_agent(model: str)`, and then add the function to the `metachain/metachain/agents/__init__.py`. Note that when add new agents into `__init__.py`, you first read the file content and keep the original content, then add the new agents into the file. + 2. In this stage, you should not run the agent, you should only develop the agent. + 3. You may need to develop more than one agent, and in this stage you should not concern the relationship between agents. + 4. After developing a new agent, you should use `transfer_back_to_programming_triage_agent` function to transfer the conversation back to the 'Programming Triage Agent', note that you should not transfer the conversation back to the 'Programming Triage Agent' util you finish the your task that is to develop all the agents. + + + And there is a guide for you to follow: + + """+\ + r"""An `Agent` simply encapsulates a set of `instructions` with a set of `functions` (plus some additional settings below), and has the capability to hand off execution to another `Agent`. + + While it's tempting to personify an `Agent` as "someone who does X", it can also be used to represent a very specific workflow or step defined by a set of `instructions` and `functions` (e.g. a set of steps, a complex retrieval, single step of data transformation, etc). This allows `Agent`s to be composed into a network of "agents", "workflows", and "tasks", all represented by the same primitive. + + ### `Agent` Fields + + | Field | Type | Description | Default | + | ---------------- | ------------------------ | ------------------------------------------------------------ | ---------------------------- | + | **name** | `str` | The name of the agent. | `"Agent"` | + | **model** | `str` | The model to be used by the agent. | `"gpt-4o"` | + | **instructions** | `str` or `func() -> str` | Instructions for the agent, can be a string or a callable returning a string. | `"You are a helpful agent."` | + | **functions** | `List` | A list of functions that the agent can call. | `[]` | + | **tool_choice** | `str` | The tool choice for the agent, if any. | `None` | + + #### Instructions + + `Agent` `instructions` are directly converted into the `system` prompt of a conversation (as the first message). Only the `instructions` of the active `Agent` will be present at any given time (e.g. if there is an `Agent` handoff, the `system` prompt will change, but the chat history will not.) + + ```python + agent = Agent( + instructions="You are a helpful agent." + ) + ``` + + The `instructions` can either be a regular `str`, or a function that returns a `str`. The function can optionally receive a `context_variables` parameter, which will be populated by the `context_variables` passed into `client.run()`. + + ```python + def instructions(context_variables): + user_name = context_variables["user_name"] + return f"Help the user, {user_name}, do whatever they want." + + agent = Agent( + instructions=instructions + ) + response = client.run( + agent=agent, + messages=[{"role":"user", "content": "Hi!"}], + context_variables={"user_name":"John"} + ) + print(response.messages[-1]["content"]) + ``` + + ``` + Hi John, how can I assist you today? + ``` + """ + return Agent( + name="Agent Creation Agent", + model=model, + instructions=agent_creation_instructions, + functions=[gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag], + tool_choice = "auto", + parallel_tool_calls = False + ) + +@register_agent(name = "Workflow Run Agent", func_name="get_workflow_run_agent") +def get_workflow_run_agent(model: str): + def workflow_run_instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + return \ + f"""You are working on an agent project named 'metachain' whose path is /{working_dir}/metachain. + Your task is to run workflows to complete the user request. + + Follow the following instructions to run workflows: + + 1. The workflow is a directed graph represented by a dictionary, with the format: + """ +\ + r""" + { + "type": "object", + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_name": {"type": "string"}, + "agent_tools": {"type": "array", "items": {"type": "string"}}, + "input": {"type": "string"}, + "output": {"type": "string"}, + "is_start": {"type": "boolean"}, + "is_end": {"type": "boolean"} + }, + "required": ["agent_name", "agent_tools", "input", "output", "is_start", "is_end"], + "additionalProperties": False + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": {"type": "string"}, + "end": {"type": "string"}, + "description": {"type": "string"} + }, + "required": ["start", "end", "description"], + "additionalProperties": False + } + } + }, + "required": ["nodes", "edges"], + "additionalProperties": False + } + 2. First create a python script named `run_xxx_workflow.py` in the `/{working_dir}/metachain` directory, and the workflow graph should be instantiated by `Graph` class in `metachain/metachain/workflow/flowgraph.py`, using `Graph.from_dict()` method. + 3. After instantiating the workflow graph, you should use `FlowEngine` class in `metachain/metachain/workflow/flowengine.py`, using `FlowEngine(g = g, model=model)` to instantiate the workflow engine. + 4. Then you can use `engine.run_meta(query, context_variables = context_variables, debug = True)` to run the workflow。 + 5. After running the workflow, you should tell the 'Programming Triage Agent' final running results and use `transfer_back_to_programming_triage_agent` function to transfer the conversation back to the 'Programming Triage Agent'. + 6. If there is any error during the running process, you should use tools to debug the error and fix the error, and you should not transfer the conversation back to the 'Programming Triage Agent' util the error is fixed. + + """+\ + r""" + There is an example to run a workflow based on the 'metachain' project: + + ```python + from metachain.workflow import Graph, FlowEngine + from metachain.types import Response + import os + os.environ["OPENAI_API_KEY"] = "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" + model = 'gpt-4o-2024-08-06' + workflow_dict = { + "nodes": [ + { + "agent_name": "user_request", + "agent_tools": [], + "input": "PDF file", + "output": "PDF file", + "is_start": True, + "is_end": False + }, + { + "agent_name": "read_pdf_agent", + "agent_tools": [ + "read_pdf" + ], + "input": "PDF file", + "output": "Extracted text", + "is_start": False, + "is_end": False + }, + { + "agent_name": "chunk_text_agent", + "agent_tools": [ + "chunk_text" + ], + "input": "Extracted text", + "output": "Chunked text", + "is_start": False, + "is_end": False + }, + { + "agent_name": "vectordb_agent", + "agent_tools": [ + "vectordb_save" + ], + "input": "Chunked text", + "output": "Text saved to VectorDB", + "is_start": False, + "is_end": False + }, + { + "agent_name": "retrieve_vectordb_agent", + "agent_tools": [ + "retrieve_vectordb" + ], + "input": "Text saved to VectorDB", + "output": "Method section text", + "is_start": False, + "is_end": False + }, + { + "agent_name": "output", + "agent_tools": [], + "input": "Method section text", + "output": "Description of Method section", + "is_start": False, + "is_end": True + } + ], + "edges": [ + { + "start": "user_request", + "end": "read_pdf_agent", + "description": "Send PDF to be read." + }, + { + "start": "read_pdf_agent", + "end": "chunk_text_agent", + "description": "Send extracted text for chunking." + }, + { + "start": "chunk_text_agent", + "end": "vectordb_agent", + "description": "Save chunked text to VectorDB." + }, + { + "start": "vectordb_agent", + "end": "retrieve_vectordb_agent", + "description": "Retrieve Method section." + }, + { + "start": "retrieve_vectordb_agent", + "end": "output", + "description": "Output of Method section text." + } + ] + } + g = Graph.from_dict(workflow_dict) + engine = FlowEngine(g = g, model=model) + + query = 'I have a paper in the pdf format, and I want to know what the method section is about.' + context_variables = {} + response: Response = engine.run_meta(query, context_variables = context_variables, debug = True) + print(response.messages[-1]['content']) + ``` + """ + return Agent( + name="Workflow Run Agent", + model=model, + instructions=workflow_run_instructions, + functions=[gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag], + tool_choice = "auto", + parallel_tool_calls = False + ) +@register_agent(name = "Agent Run Agent", func_name="get_agent_run_agent") +def get_agent_run_agent(model: str): + def agent_run_instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + return \ + f"""You are working on an agent project named 'metachain' whose path is /{working_dir}/metachain. + Your task is to run agents to complete the user request. + + Follow the following instructions to run agents: + + 1. To complete the user request using 'metachain' project, you need to run the agent by creating a python file named `run_xxx_agent.py` in the 'metachain' directory, and use `run_python` function to run the agent. + 2. If there is any error during the running process, you should use tools to debug the error and fix the error, and you should not transfer the conversation back to the 'Programming Triage Agent' util the error is fixed. + 3. After running the agent, you should tell the 'Programming Triage Agent' final running results and use `transfer_back_to_programming_triage_agent` function to transfer the conversation back to the 'Programming Triage Agent', note that you should not transfer the conversation back to the 'Programming Triage Agent' util you finish the your task that is to run all the agents. + + Note that if you need OPENAI_API_KEY, my key is: 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 + + And there is a guide for you to follow: + + """+\ + r""" + ```python + from metachain import MetaChain + from metachain.agents import get_programming_agent + + client = MetaChain() + programming_agent = get_programming_agent(model) + context_variables = {"key": value} + messages = [{"role": "user", "content": task_instructions}] + response = client.run(agent=programming_agent, messages=messages, context_variables=context_variables, debug=True) + ``` + + ### `client.run()` + + MetaChain's `run()` function is analogous to the `chat.completions.create()` function in the Chat Completions API - it takes `messages` and returns `messages` and saves no state between calls. Importantly, however, it also handles Agent function execution, hand-offs, context variable references, and can take multiple turns before returning to the user. + + At its core, MetaChain's `client.run()` implements the following loop: + + 1. Get a completion from the current Agent + 2. Execute tool calls and append results + 3. Switch Agent if necessary + 4. Update context variables, if necessary + 5. If no new function calls, return + + #### Arguments + + | Argument | Type | Description | Default | + | --------------------- | ------- | ------------------------------------------------------------ | -------------- | + | **agent** | `Agent` | The (initial) agent to be called. | (required) | + | **messages** | `List` | A list of message objects, identical to [Chat Completions `messages`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages) | (required) | + | **context_variables** | `dict` | A dictionary of additional context variables, available to functions and Agent instructions | `{}` | + | **max_turns** | `int` | The maximum number of conversational turns allowed | `float("inf")` | + | **model_override** | `str` | An optional string to override the model being used by an Agent | `None` | + | **execute_tools** | `bool` | If `False`, interrupt execution and immediately returns `tool_calls` message when an Agent tries to call a function | `True` | + | **stream** | `bool` | If `True`, enables streaming responses | `False` | + | **debug** | `bool` | If `True`, enables debug logging | `False` | + + Once `client.run()` is finished (after potentially multiple calls to agents and tools) it will return a `Response` containing all the relevant updated state. Specifically, the new `messages`, the last `Agent` to be called, and the most up-to-date `context_variables`. You can pass these values (plus new user messages) in to your next execution of `client.run()` to continue the interaction where it left off – much like `chat.completions.create()`. (The `run_demo_loop` function implements an example of a full execution loop in `/MetaChain/repl/repl.py`.) + + #### `Response` Fields + + | Field | Type | Description | + | --------------------- | ------- | ------------------------------------------------------------ | + | **messages** | `List` | A list of message objects generated during the conversation. Very similar to [Chat Completions `messages`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages), but with a `sender` field indicating which `Agent` the message originated from. | + | **agent** | `Agent` | The last agent to handle a message. | + | **context_variables** | `dict` | The same as the input variables, plus any changes. | + """ + return Agent( + name="Agent Run Agent", + model=model, + instructions=agent_run_instructions, + functions=[gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, code_rag], + tool_choice = "auto", + parallel_tool_calls = False + ) +@register_agent(name = "Programming Triage Agent", func_name="get_programming_triage_agent") +def get_programming_triage_agent(model: str): + def programming_triage_instructions(context_variables): + working_dir = context_variables.get("working_dir", None) + ret_instructions = \ + f"""You are a programmer working on an agent project named 'metachain' whose path is /{working_dir}/metachain. + + Your overall task is using existing project to create agents or workflows to complete the user request. + + If the existing tools or agents are not enough for your task, you should develop new tools or agents. + And you should determine which agent is best suited to handle the user's request, and transfer the conversation to that agent based on the following routine: + + 1. If you need to develop new tools, transfer the conversation to the 'Tool Creation Agent' to create a new tool in the `/{working_dir}/metachain/metachain/tools` folder using function `transfer_to_tool_creation_agent`. + 2. If you need to develop new agents, transfer the conversation to the 'Agent Creation Agent' to create a new agent in the `/{working_dir}/metachain/metachain/agents` folder using function `transfer_to_agent_creation_agent`. + 3. After there is enough pre-built tools and agents, transfer the conversation to the 'Agent Run Agent' or 'Workflow Run Agent' to create agents or workflows to complete the user request using function `transfer_to_agent_run_agent` or `transfer_to_workflow_run_agent`. + 4. Note that if you should create both new tools and new agents, you should create the new tools first, and then create the new agents. + + Note that if there are not enough pre-built tools, you should develop new tools first, and then develop new agents, and finally run the workflow or agent to complete the user request. + + Once you receive the develop plan, you should not stop util you finish the task. + """ + how_to_guides = context_variables.get("how_to_guides", None) + if how_to_guides: + ret_instructions += \ + f""" + If you want to develop new tools or agents, you should follow the following guides: + {how_to_guides} + """ + return ret_instructions + tool_creation_agent = get_tool_creation_agent(model) + agent_creation_agent = get_agent_creation_agent(model) + workflow_run_agent = get_workflow_run_agent(model) + agent_run_agent = get_agent_run_agent(model) + def transfer_to_tool_creation_agent(input: str): + return tool_creation_agent + def transfer_to_agent_creation_agent(input: str): + return agent_creation_agent + def transfer_to_workflow_run_agent(input: str): + return workflow_run_agent + def transfer_to_agent_run_agent(input: str): + return agent_run_agent + programming_triage_agent = Agent( + name="Programming Triage Agent", + model=model, + instructions=programming_triage_instructions, + functions=[transfer_to_tool_creation_agent, transfer_to_agent_creation_agent, transfer_to_workflow_run_agent, transfer_to_agent_run_agent], + tool_choice = "auto", + parallel_tool_calls = False + ) + def transfer_back_to_programming_triage_agent(): + """Call this function if the existing agent has already finished the sub-task.""" + return programming_triage_agent + tool_creation_agent.functions.append(transfer_back_to_programming_triage_agent) + agent_creation_agent.functions.append(transfer_back_to_programming_triage_agent) + workflow_run_agent.functions.append(transfer_back_to_programming_triage_agent) + agent_run_agent.functions.append(transfer_back_to_programming_triage_agent) + return programming_triage_agent + + +if __name__ == "__main__": + print(agent_creation_instructions({"working_dir": "metachain"})) diff --git a/metachain/agents/system_agent/filesurfer_agent.py b/metachain/agents/system_agent/filesurfer_agent.py new file mode 100644 index 0000000..9252408 --- /dev/null +++ b/metachain/agents/system_agent/filesurfer_agent.py @@ -0,0 +1,43 @@ +from metachain.types import Agent +from metachain.registry import register_agent +from metachain.tools import open_local_file, page_up_markdown, page_down_markdown, find_on_page_ctrl_f, find_next, visual_question_answering +from metachain.tools.file_surfer_tool import with_env +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +import time +from inspect import signature +from constant import LOCAL_ROOT, DOCKER_WORKPLACE_NAME +@register_agent(name = "File Surfer Agent", func_name="get_filesurfer_agent") +def get_filesurfer_agent(model: str = "gpt-4o", **kwargs): + + def handle_mm_func(tool_name, tool_args): + return f"After using tool `{tool_name}({tool_args})`, I have opened the image I want to see and prepared a question according to the image. Please answer the question based on the image." + def instructions(context_variables): + file_env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert file_env is not None, "file_env is required" + return \ +f""" +You are a file surfer agent that can handle local files. + +You can only access the files in the folder `{file_env.docker_workplace}` and when you want to open a file, you should use absolute path from root like `{file_env.docker_workplace}/...`. + +Note that `open_local_file` can read a file as markdown text and ask questions about it. And `open_local_file` can handle the following file extensions: [".html", ".htm", ".xlsx", ".pptx", ".wav", ".mp3", ".flac", ".pdf", ".docx"], and all other types of text files. + +But IT DOES NOT HANDLE IMAGES, you should use `visual_question_answering` to see the image. + +If the converted markdown text has more than 1 page, you can use `page_up`, `page_down`, `find_on_page_ctrl_f`, `find_next` to navigate through the pages. + +When you think you have completed the task the `System Triage Agent` asked you to do, you should use `transfer_back_to_triage_agent` to transfer the conversation back to the `System Triage Agent`. And you should not stop to try to solve the user's request by transferring to `System Triage Agent` only until the task is completed. + +If you are unable to open the file, you can transfer the conversation back to the `System Triage Agent`, and let the `Coding Agent` try to solve the problem by coding. +""" + tool_list = [open_local_file, page_up_markdown, page_down_markdown, find_on_page_ctrl_f, find_next, visual_question_answering] + return Agent( + name="File Surfer Agent", + model=model, + instructions=instructions, + functions=tool_list, + handle_mm_func=handle_mm_func, + tool_choice = "required", + parallel_tool_calls = False + ) + diff --git a/metachain/agents/system_agent/programming_agent.py b/metachain/agents/system_agent/programming_agent.py new file mode 100644 index 0000000..62576dd --- /dev/null +++ b/metachain/agents/system_agent/programming_agent.py @@ -0,0 +1,98 @@ +from metachain.types import Agent +from metachain.tools import ( + gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, terminal_page_up, terminal_page_down, terminal_page_to +) +from metachain.util import make_message, make_tool_message +from metachain.registry import register_agent, register_plugin_agent +from constant import LOCAL_ROOT, DOCKER_WORKPLACE_NAME +from metachain.environment import DockerEnv, BrowserEnv, LocalEnv +from typing import Union +from inspect import signature +def examples(context_variables): + working_dir = context_variables.get("working_dir", None) + examples_list = [] + examples_list.extend(make_message('user', "Create a list of numbers from 1 to 10, and display them in a web page at port 5000.")) + examples_list.extend(make_message('assistant', "I should first use create_file to write the python code into a file named 'app.py' for starting a web server")) + examples_list.extend(make_tool_message(create_file, {'path': f"/{working_dir}/app.py", + 'content': """ +from flask import Flask +app = Flask(__name__) + +@app.route('/') +def index(): + numbers = list(range(1, 11)) + return str(numbers) + +if __name__ == '__main__': + app.run(port=5000)"""}, f"File created at: /{working_dir}/app.py")) + examples_list.extend(make_message('assistant', 'I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you using `run_python`')) + examples_list.extend(make_tool_message(run_python, {'code_path': f"/{working_dir}/app.py"}, f""" +Traceback (most recent call last): + File "/{working_dir}/app.py", line 2, in + from flask import Flask +ModuleNotFoundError: No module named 'flask'""")) + examples_list.extend(make_message('assistant', "It seems that Flask is not installed. Let me install Flask for you using `execute_command` by the command: pip install flask")) + examples_list.extend(make_tool_message(execute_command, {'command': 'pip install flask'}, """Defaulting to user installation because normal site-packages is not writeable +Collecting flask + Using cached flask-3.0.3-py3-none-any.whl (101 kB) +Collecting blinker>=1.6.2 + Using cached blinker-1.7.0-py3-none-any.whl (13 kB) +Collecting Werkzeug>=3.0.0 + Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB) +Collecting click>=8.1.3 + Using cached click-8.1.7-py3-none-any.whl (97 kB) +Collecting itsdangerous>=2.1.2 + Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3) +Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5) +Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask +Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0""")) + examples_list.extend(make_message('assistant', "Now that Flask is installed, let me run the Python file again using `run_python`")) + examples_list.extend(make_tool_message(run_python, {'code_path': f"/{working_dir}/app.py"}, """* Serving Flask app 'app' + * Debug mode: off +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on http://127.0.0.1:5000 +Press CTRL+C to quit""")) + examples_list.extend(make_message('assistant', """The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. Your task is completed.""")) + return examples_list + +@register_agent(name= "Coding Agent", func_name="get_coding_agent") +@register_plugin_agent(name= "Coding Agent", func_name="get_coding_agent") +def get_coding_agent(model: str, **kwargs): + def instructions(context_variables): + code_env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + return f"""You are a helpful programming assistant that can write and execute code. You are working in the folder: `{code_env.docker_workplace}`, and you can only access the files in this folder. + + Your can leverage your capabilities by using the specific functions listed below: + + 1. Creating project structures based on the user requirement using function `create_directory`. + 2. Writing clean, efficient, and well-documented code using function `create_file` and `write_file`. + 3. You must run python scripts using function `run_python` rather than using the `execute_command` function. + 4. Exam the project to re-use the existing code snippets as much as possible, you may need to use + functions like `list_files`, `read_file` and `write_file`. + 5. Writing the code into the file when creating new files, do not create empty files. + 6. Before you write code into the existing files, you should first read the file content using function `read_file` and reserve the original content as much as possible. + 7. Decide whether the task requires execution and debugging before moving to the next or not. + 8. Generate the commands to run and test the current task, and the dependencies list for this task. + 9. You only write Python scripts, don't write Jupiter notebooks which require interactive execution. + 10. Note that every path you read, write, or search should be the absolute path (starting with '/'). + 11. If you should use programming other than Python, you should use the `write_file` function to write the code into a file, and then use the `execute_command` function to run the code. + 12. If the terminal output is too long, you should use `terminal_page_up` to move the viewport up, `terminal_page_down` to move the viewport down, `terminal_page_to` to move the viewport to the specific page of terminal where the meaningful content is. + + Note that you can use this agent to make complex computation, write a api request, and anything else that can be done by writing code. + + When you think you have completed the task the `System Triage Agent` asked you to do, you should use `transfer_back_to_triage_agent` to transfer the conversation back to the `System Triage Agent`. And you should not stop to try to solve the user's request by transferring to `System Triage Agent` only until the task is completed. + + [IMPORTANT] You can only complete the task by coding. Talk is cheap, show me the code with tools. + """ + tool_list = [gen_code_tree_structure, execute_command, read_file, create_file, write_file, list_files, create_directory, run_python, terminal_page_up, terminal_page_down, terminal_page_to] + + return Agent( + name="Coding Agent", + model=model, + instructions=instructions, + functions=tool_list, + examples=examples, + tool_choice = "required", + parallel_tool_calls = False + ) \ No newline at end of file diff --git a/metachain/agents/system_agent/system_triage_agent.py b/metachain/agents/system_agent/system_triage_agent.py new file mode 100644 index 0000000..35c9885 --- /dev/null +++ b/metachain/agents/system_agent/system_triage_agent.py @@ -0,0 +1,66 @@ +from .filesurfer_agent import get_filesurfer_agent +from .programming_agent import get_coding_agent +from .websurfer_agent import get_websurfer_agent +from metachain.registry import register_agent +from metachain.types import Agent, Result +from metachain.tools.inner import case_resolved, case_not_resolved + +@register_agent(name = "System Triage Agent", func_name="get_system_triage_agent") +def get_system_triage_agent(model: str, **kwargs): + """ + This is the `System Triage Agent`, it can help the user to determine which agent is best suited to handle the user's request under the current context, and transfer the conversation to that agent. + + Args: + model: The model to use for the agent. + **kwargs: Additional keyword arguments, `file_env`, `web_env` and `code_env` are required. + """ + filesurfer_agent = get_filesurfer_agent(model) + websurfer_agent = get_websurfer_agent(model) + coding_agent = get_coding_agent(model) + instructions = \ +f"""You are a helpful assistant that can help the user with their request. +Based on the state of solving user's task, your responsibility is to determine which agent is best suited to handle the user's request under the current context, and transfer the conversation to that agent. And you should not stop to try to solve the user's request by transferring to another agent only until the task is completed. + +There are three agents you can transfer to: +1. use `transfer_to_filesurfer_agent` to transfer to {filesurfer_agent.name}, it can help you to open any type of local files and browse the content of them. +2. use `transfer_to_websurfer_agent` to transfer to {websurfer_agent.name}, it can help you to open any website and browse any content on it. +3. use `transfer_to_coding_agent` to transfer to {coding_agent.name}, it can help you to write code to solve the user's request, especially some complex tasks. +""" + tool_choice = "required" + tools = [case_resolved, case_not_resolved] if tool_choice == "required" else [] + system_triage_agent = Agent( + name="System Triage Agent", + model=model, + instructions=instructions, + functions=tools, + tool_choice = tool_choice, + parallel_tool_calls = False, + ) + def transfer_to_filesurfer_agent(sub_task_description: str): + """ + Args: + sub_task_description: The detailed description of the sub-task that the `System Triage Agent` will ask the `File Surfer Agent` to do. + """ + return Result(value=sub_task_description, agent=filesurfer_agent) + def transfer_to_websurfer_agent(sub_task_description: str): + return Result(value=sub_task_description, agent=websurfer_agent) + def transfer_to_coding_agent(sub_task_description: str): + return Result(value=sub_task_description, agent=coding_agent) + def transfer_back_to_triage_agent(task_status: str): + """ + Args: + task_status: The detailed description of the task status after a sub-agent has finished its sub-task. A sub-agent can use this tool to transfer the conversation back to the `System Triage Agent` only when it has finished its sub-task. + """ + return Result(value=task_status, agent=system_triage_agent) + system_triage_agent.agent_teams = { + filesurfer_agent.name: transfer_to_filesurfer_agent, + websurfer_agent.name: transfer_to_websurfer_agent, + coding_agent.name: transfer_to_coding_agent + } + system_triage_agent.functions.extend([transfer_to_filesurfer_agent, transfer_to_websurfer_agent, transfer_to_coding_agent]) + filesurfer_agent.functions.append(transfer_back_to_triage_agent) + websurfer_agent.functions.append(transfer_back_to_triage_agent) + coding_agent.functions.append(transfer_back_to_triage_agent) + return system_triage_agent + + \ No newline at end of file diff --git a/metachain/agents/system_agent/websurfer_agent.py b/metachain/agents/system_agent/websurfer_agent.py new file mode 100644 index 0000000..43e61f2 --- /dev/null +++ b/metachain/agents/system_agent/websurfer_agent.py @@ -0,0 +1,37 @@ +from metachain.types import Agent +from metachain.registry import register_agent +from metachain.tools import click, page_down, page_up, history_back, history_forward, web_search, input_text, sleep, visit_url, get_page_markdown +from metachain.tools.web_tools import with_env +from metachain.environment.browser_env import BrowserEnv +import time +from constant import DOCKER_WORKPLACE_NAME, LOCAL_ROOT +@register_agent(name = "Web Surfer Agent", func_name="get_websurfer_agent") +def get_websurfer_agent(model: str = "gpt-4o", **kwargs): + + def handle_mm_func(tool_name, tool_args): + return f"After take last action `{tool_name}({tool_args})`, the image of current page is shown below. Please take next action based on the image, the current state of the page as well as previous actions and observations." + def instructions(context_variables): + web_env: BrowserEnv = context_variables.get("web_env", None) + assert web_env is not None, "web_env is required" + return \ +f"""Review the current state of the page and all other information to find the best possible next action to accomplish your goal. Your answer will be interpreted and executed by a program, make sure to follow the formatting instructions. + +Note that if you want to analyze the YouTube video, Wikipedia page, or other pages that contain media content, or you just want to analyze the text content of the page in a more detailed way, you should use `get_page_markdown` tool to convert the page information to markdown text. And when browsing the web, if you have downloaded any files, the path of the downloaded files will be `{web_env.docker_workplace}/downloads`, and you CANNOT open the downloaded files directly, you should transfer back to the `System Triage Agent`, and let `System Triage Agent` to transfer to `File Surfer Agent` to open the downloaded files. + +When you think you have completed the task the `System Triage Agent` asked you to do, you should use `transfer_back_to_triage_agent` to transfer the conversation back to the `System Triage Agent`. And you should not stop to try to solve the user's request by transferring to `System Triage Agent` only until the task is completed. +""" + + tool_list = [click, page_down, page_up, history_back, history_forward, web_search, input_text, sleep, visit_url, get_page_markdown] + return Agent( + name="Web Surfer Agent", + model=model, + instructions=instructions, + functions=tool_list, + handle_mm_func=handle_mm_func, + tool_choice = "required", + parallel_tool_calls = False + ) + +""" +Note that when you need to download something, you should first know the url of the file, and then use the `visit_url` tool to download the file. For example, if you want to download paper from 'https://arxiv.org/abs/2310.13023', you should use `visit_url('url'='https://arxiv.org/pdf/2310.13023.pdf')`. +""" \ No newline at end of file diff --git a/metachain/agents/tool_agent/agent_editor.py b/metachain/agents/tool_agent/agent_editor.py new file mode 100644 index 0000000..738be60 --- /dev/null +++ b/metachain/agents/tool_agent/agent_editor.py @@ -0,0 +1,38 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_agents import list_agents, create_agent, delete_agent, run_agent +from metachain.tools.terminal_tools import execute_command +from metachain.types import Agent +from metachain.io_utils import read_file + +@register_agent(name = "Agent Editor Agent", func_name="get_agent_editor_agent") +def get_agent_editor_agent(model: str) -> str: + """ + The agent editor is an agent that can be used to edit the agents. + """ + def instructions(context_variables): + return f"""\ +You are an agent editor agent that can be used to edit the agents. You are working on a Agent framework named MetaChain, and your responsibility is to edit the agents in the MetaChain, so that the agents can be used to help the user with their request. + +The existing agents are shown below: +{list_agents(context_variables)} + +If you want to create a new agent, you should: +1. follow the format of the `get_dummy_agent` below: +```python +{read_file('metachain/agents/dummy_agent.py')} +``` +2. you successfully create the agent only after you have successfully run the agent with the `run_agent` function to satisfy the user's request. + +3. If you encounter any error while creating and running the agent, like dependency missing, you should use the `execute_command` function to install the dependency. + +[IMPORTANT] The `register_plugin_agent` registry function is strictly required for a agent implementation to be recognized by the MetaChain framework. +""" + tool_list = [list_agents, create_agent, delete_agent, run_agent, execute_command] + return Agent( + name="Agent Editor Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) diff --git a/metachain/agents/tool_agent/meta_plan_agent.py b/metachain/agents/tool_agent/meta_plan_agent.py new file mode 100644 index 0000000..03b2906 --- /dev/null +++ b/metachain/agents/tool_agent/meta_plan_agent.py @@ -0,0 +1,40 @@ +from metachain.types import Agent +from pydantic import BaseModel +from metachain.tools.meta.tool_retriever import get_api_plugin_tools_doc +from metachain.tools.meta.search_tools import search_trending_models_on_huggingface, get_hf_model_tools_doc +from metachain.tools.meta.edit_tools import list_tools +from typing import Union +from metachain.environment import DockerEnv, LocalEnv + + +def get_meta_plan_agent(model: str) -> Agent: + def instructions(context_variables): + code_env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + instructions = f"""\ +You are a helpful planner that can help `Tool Editor Agent` how to use MetaChain to solve the user's request. + +Existing tools you already have: +{list_tools(context_variables)} + +You should first fully understand the user's request, then analyze the existing tools and determine which tools are needed to solve the user's request, finally, you should transfer the conversation to the `Meta Agent` with the plan of using the tools. + +If existing tools are not enough for your task, you should develop new tools. + +1. [IMPORTANT] If you want to use third-party api, especially for some tasks related to Finance, Entertainment, eCommerce, Food, Travel, Sports, you MUST use the `get_api_plugin_tools_doc` tool to search information from existing api documents, it contains how to implement the api and API keys. + + +2. [IMPORTANT] If you want to use Hugging Face models, especially for some tasks related to vision, audio, video, you should use the `search_trending_models_on_huggingface` tool to search trending models related to the specific task on Hugging Face, and then use the `get_hf_model_tools_doc` tool to get the detailed information about the specific model. + +3. [IMPORTANT] As for the tags ['image-text-to-text', 'visual-question-answering', 'video-text-to-text'] and ANY visual tasks, you should use `visual_question_answering` tool instead of Hugging Face models. + +4. [IMPORTANT] You can not use `transfer_back_to_meta_agent_with_plans` util you have fully understood the user's request and have try your best to search information from exsiting resources if you want to create a new tool. +""" + return instructions + return Agent( + name="Meta Plan Agent", + model=model, + instructions=instructions, + functions=[get_api_plugin_tools_doc, search_trending_models_on_huggingface, get_hf_model_tools_doc], + tool_choice = "required", + parallel_tool_calls = False + ) diff --git a/metachain/agents/tool_agent/tool_agent.py b/metachain/agents/tool_agent/tool_agent.py new file mode 100644 index 0000000..c0a5961 --- /dev/null +++ b/metachain/agents/tool_agent/tool_agent.py @@ -0,0 +1,100 @@ +from metachain.registry import register_agent +from metachain.types import Agent, Result +from metachain.environment import DockerEnv, LocalEnv +from metachain.tools.meta.edit_tools import list_tools +from metachain.agents.tool_agent.tool_editor import get_tool_editor_agent +from typing import Union +from metachain.tools.inner import case_resolved, case_not_resolved +from pydantic import BaseModel +from metachain.util import function_to_json +from metachain.agents.tool_agent.meta_plan_agent import get_meta_plan_agent + +class ToolDescription(BaseModel): + tool_functionalities: str + existing: bool + tool_docs: str + +class ToolPlan(BaseModel): + tool_name: str + tool_description: ToolDescription + +@register_agent(name = "Tool Agent", func_name="get_tool_agent") +def get_tool_agent(model: str) -> Agent: + """ + The tool agent is an agent that can be used to create and run other tools. + """ + def instructions(context_variables): + code_env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + instructions = f"""\ +You are a helpful assistant that can help the user with their request by creating and running tools in the Metachain agent framework. Your responsibility is to determine which sub-agent is best suited to handle the user's request under the current context, and transfer the conversation to that sub-agent. And you should not stop to try to solve the user's request by transferring to another sub-agent only until the task is completed. + +Your sub-agents are: +1. `Meta Plan Agent`: This agent is used to plan how to use MetaChain to solve the user's request. +2. `Tool Editor Agent`: This agent is used to run and edit tools. + +Existing tools you already have: +{list_tools(context_variables)} + +You should first transfer the conversation to the `Meta Plan Agent` to plan how to use MetaChain to solve the user's request, and the plan should follow the following constraints: + +1. If exising tools are enough for your task, you can directly use them to solve the user's request. + +2. If exising tools are not enough for your task, `Meta Plan Agent` should search information from the resources and plan how to create new tools. + +3. [IMPORTANT] As for the tags ['image-text-to-text', 'visual-question-answering', 'video-text-to-text'] and ANY visual tasks, you should use `visual_question_answering` tool instead of Hugging Face models. +""" + return instructions + + tool_editor_agent: Agent = get_tool_editor_agent(model) + meta_plan_agent: Agent = get_meta_plan_agent(model) + def transfer_to_tool_editor_agent(sub_task: str): + """ + Args: + sub_task: The detailed description of the sub-task that the `Meta Agent` will ask the `Tool Editor Agent` to do. + """ + return tool_editor_agent + def transfer_to_meta_plan_agent(sub_task: str): + """ + Use this function when you want to plan how to use MetaChain to solve the user's request. + Args: + sub_task: The detailed description of the sub-task that the `Meta Agent` will ask the `Meta Plan Agent` to do. + """ + return meta_plan_agent + meta_agent = Agent( + name="Meta Agent", + model=model, + instructions=instructions, + functions=[transfer_to_meta_plan_agent, transfer_to_tool_editor_agent, case_resolved, case_not_resolved], + tool_choice = "required", + parallel_tool_calls = False + ) + + def transfer_back_to_meta_agent(task_status: str): + """ + Args: + task_status: The status of the task that the `Meta Agent` will ask the `Meta Agent` to do. + """ + return meta_agent + def transfer_back_to_meta_agent_with_plans(tool_development_steps: list[ToolPlan]) -> str: + """ + This function is used to plan how to use MetaChain to solve the user's request. You can use this function only after you have fully understood the user's request and have try your best to search information from exsiting resources. + + Args: + tool_development_steps: The steps of tool development. It is a list of dictionaries, each dictionary contains the tools name you should use in the exsiting MetaChain or the tools name you should develop. If the tool is not existing, dictionaries should contain the tool documentation. + """ + tool_str = "\n".join([f"{tool['tool_name']}: {tool['tool_description']['tool_functionalities']} [{tool['tool_description']['existing']}]" for tool in tool_development_steps]) + ret_val = f"""\ + Receiving user's request, I have the following plans to use MetaChain to solve the user's request: + As for using existing tools, I have the following plans: + {tool_str} + """ + return Result( + value=ret_val, + agent=meta_agent + ) + + tool_editor_agent.functions.append(transfer_back_to_meta_agent) + meta_plan_agent.functions.append(transfer_back_to_meta_agent_with_plans) + + return meta_agent + diff --git a/metachain/agents/tool_agent/tool_editor.py b/metachain/agents/tool_agent/tool_editor.py new file mode 100644 index 0000000..9fb9a6b --- /dev/null +++ b/metachain/agents/tool_agent/tool_editor.py @@ -0,0 +1,143 @@ +from metachain.registry import register_agent +from metachain.tools.meta.edit_tools import list_tools, create_tool, delete_tool, run_tool, get_metachain_path +from metachain.tools.meta.tool_retriever import get_api_plugin_tools_doc +from metachain.tools.meta.search_tools import search_trending_models_on_huggingface, get_hf_model_tools_doc +from metachain.types import Agent +from metachain.io_utils import read_file +from metachain.tools.terminal_tools import execute_command + +def get_tool_editor_agent(model: str) -> Agent: + """ + The tool editor is an agent that can be used to edit the tools. + """ + def instructions(context_variables): + return f"""\ +You are a tool editor agent responsible for managing plugin tools in the MetaChain framework. Your core responsibility is to edit, create, and manage plugin tools that can be used by other agents. + +[PLUGIN TOOLS SYSTEM] +- Plugin tools are the building blocks of MetaChain +- All available plugin tools are as follows: +{list_tools(context_variables)} +- Plugin tools can ONLY be executed using `run_tool(tool_name, run_code)` +- NEVER try to import and run tools directly - always use `run_tool` + +[AVAILABLE MANAGEMENT TOOLS] +1. list_tools(): + - Lists all existing plugin tools + - Returns: tool name, arguments, docstring, implementation details + - Use this FIRST to check existing tools + +2. create_tool(tool_name: str, tool_code: str): + - Creates new plugin tools + - Requires proper registration using @register_plugin_tool + +3. run_tool(tool_name: str, run_code: str,): + - REQUIRED method to execute any plugin tool + - Format: run_tool("tool_name", "from metachain.tools import tool_name; print(tool_name(args))") + +4. delete_tool(tool_name: str,): + - Removes existing plugin tools + - Use with caution + +5. get_api_plugin_tools_doc: + - Required for third-party API integrations + - Must be used for Finance, Entertainment, etc. + +6. execute_command: + - Handles system-level operations + - Use for dependency installation + +[CRITICAL PRINCIPLES FOR PLUGIN TOOLS] +1. Tools MUST be abstract, modular, and reusable: + - Use generic function names (e.g., `download_media` instead of `download_youtube_video`) + - Break complex tasks into smaller, reusable components + - Avoid task-specific implementations + - Use parameters instead of hardcoded values + +2. For ALL visual tasks (images, videos, visual analysis): + - MUST use the existing `visual_question_answering` plugin tool + - NO direct implementation of visual processing + - Chain `visual_question_answering` with other tools as needed + +[WORKFLOW FOR PLUGIN TOOL MANAGEMENT] +1. Always start with `list_tools()` to check existing tools +2. For new plugin tools: + a. Design generic, reusable interface + b. Follow the template format: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + c. Create using `create_tool` + d. Test using `run_tool` + e. Handle dependencies with `execute_command` + +[IMPORTANT RULES] +- ALL tools must be registered with @register_plugin_tool +- ALL tools must have type hints +- Each tool does ONE thing well +- Create modular tools that can be combined +- ALWAYS use `run_tool` to execute plugin tools +- NEVER modify the `visual_question_answering` tool + +[TOOL TESTING EXAMPLE] +Correct way to test a plugin tool: +```python +result = run_tool( + tool_name="your_tool", + run_code="from metachain.tools import your_tool; print(your_tool(param1='value1'))", + context_variables=context_variables +) +``` +""" + tool_list = [list_tools, create_tool, run_tool, delete_tool, get_api_plugin_tools_doc, execute_command] + return Agent( + name="Tool Editor Agent", + model=model, + instructions=instructions, + functions=tool_list, + tool_choice = "required", + parallel_tool_calls = False + ) + + +""" +5. [IMPORTANT] If you want to use Hugging Face models, especially for some tasks related to vision, audio, video, you should use the `search_trending_models_on_huggingface` tool to search trending models related to the specific task on Hugging Face, and then use the `get_hf_model_tools_doc` tool to get the detailed information about the specific model. + +6. [IMPORTANT] As for the tags ['image-text-to-text', 'visual-question-answering', 'video-text-to-text'] and ANY visual tasks, you should use `visual_question_answering` tool instead of Hugging Face models. +""" + +"""\ +You are a tool editor agent that can be used to edit the tools. You are working on a Agent framework named MetaChain, and your responsibility is to edit the tools in the MetaChain, so that the tools can be used by the agents to help the user with their request. + +The existing tools are shown below: +{list_tools(context_variables)} + +If you want to create a new tool, you should: +1. follow the format of the `tool_dummy` below. Note that if the tool should be used with third-part api key, you should write the api key inside the definition of the tool: +```python +{read_file('metachain/tools/dummy_tool.py')} +``` + +2. you successfully create the tool only after you have successfully run the tool with the `run_tool` function, and an example of testing the tool is shown below.: +```python +from metachain.tools import tool_dummy + +if __name__ == "__main__": + ... # some pre-operations + print(run_tool(tool_name="tool_dummy", run_code="from metachain.tools import tool_dummy; print(tool_dummy(args1=args1, args2=args1, ...))")) +``` + +3. If you encounter any error while creating and running the tool, like dependency missing, you should use the `execute_command` function to install the dependency. + +4. [IMPORTANT] If you want to use third-party api, especially for some tasks related to Finance, Entertainment, eCommerce, Food, Travel, Sports, you MUST use the `get_api_plugin_tools_doc` tool to search information from existing api documents, it contains how to implement the api and API keys. + +[IMPORTANT] The `register_plugin_tool` registry function is strictly required for a tool implementation to be recognized by the MetaChain framework. + +[IMPORTANT] The tool you create should be abstract, modular, and reusable. Specifically, the function name must be generic (e.g., +`count_objects` instead of `count_apples`). The function must use parameters instead of hard-coded values. The +function body must be self-contained. + +[IMPORTANT] Explicitly declare input and output data types using type hints. + +[IMPORTANT] For ANY visual tasks related to image and video, you should use `visual_question_answering` tool. +""" \ No newline at end of file diff --git a/metachain/agents/tool_check_agent.py b/metachain/agents/tool_check_agent.py new file mode 100644 index 0000000..7ff4ebe --- /dev/null +++ b/metachain/agents/tool_check_agent.py @@ -0,0 +1,28 @@ +from metachain.types import Agent +from metachain.tools import ( + get_api_plugin_tools_doc, check_tool +) +from metachain.registry import register_agent + + +@register_agent(name = "Tool Check Agent", func_name="get_tool_check_agent") +def get_tool_check_agent(model: str): + def instructions(context_variables): + return \ +f"""You are a developer working on a project named 'metachain'. +You are given a user request and required to use existing project code to solve the task. +Your goal is to enrich the functionality of existing list of tools in the `tools` folder as much as possible, so that once the similar task occurs again, the agent can solve it directly by using the tools without developing new tools. +whether you should develop some new tools to integrate into the agent to directly solve the task. +If you use an external api, you should always develop a new tool, rather than using coding-related tools. +Answer 'Needed' or 'Not needed' first and then give your reason. ('Needed' means you should develop a new tool, 'Not needed' means you should not develop a new tool). +You can use `check_tool` tool to review the existing tools and check whether developing a new tool is needed. +""" + return Agent( + name="Tool Check Agent", + model=model, + instructions=instructions, + functions=[check_tool], + parallel_tool_calls = False + ) + +"""If you need to develop a new tool, you must use `get_tool_doc` tool to get the tool doc.""" \ No newline at end of file diff --git a/metachain/agents/tool_retriver_agent.py b/metachain/agents/tool_retriver_agent.py new file mode 100644 index 0000000..cc379eb --- /dev/null +++ b/metachain/agents/tool_retriver_agent.py @@ -0,0 +1,21 @@ +from metachain.types import Agent +from metachain.tools import ( + get_api_plugin_tools_doc +) +from metachain.util import make_message, make_tool_message +from metachain.registry import register_agent +@register_agent(name = "Tool Retriver Agent", func_name="get_tool_retriver_agent") +def get_tool_retriver_agent(model: str): + def instructions(context_variables): + return \ +f""" +You are a tool retriver agent. +You are given a task instruction, and you need to retrieve the tool docs for the task using function `get_tool_doc`. +Note that if you want to complete the task, you may need to use more than one tool, so you should retrieve the tool docs for all the tools you may need. Finally, you should give a merged tool doc consisting of all the tool docs you retrieved, and the implementation code of each tool should be included in the tool doc. +""" + return Agent( + name="Tool Retriver Agent", + model=model, + instructions=instructions, + functions=[get_api_plugin_tools_doc], + ) \ No newline at end of file diff --git a/metachain/cli.py b/metachain/cli.py new file mode 100644 index 0000000..5969dfb --- /dev/null +++ b/metachain/cli.py @@ -0,0 +1,63 @@ +import click +import importlib +from metachain import MetaChain +from metachain.util import debug_print +import asyncio +@click.group() +def cli(): + """The command line interface for metachain""" + pass + +@cli.command() +@click.option('--model', default='gpt-4o-2024-08-06', help='the name of the model') +@click.option('--agent_func', default='get_dummy_agent', help='the function to get the agent') +@click.option('--query', default='...', help='the user query to the agent') +@click.argument('context_variables', nargs=-1) +def agent(model: str, agent_func: str, query: str, context_variables): + """ + Run an agent with a given model, agent function, query, and context variables. + Args: + model (str): The name of the model. + agent_func (str): The function to get the agent. + query (str): The user query to the agent. + context_variables (list): The context variables to pass to the agent. + Usage: + mc agent --model=gpt-4o-2024-08-06 --agent_func=get_weather_agent --query="What is the weather in Tokyo?" city=Tokyo unit=C timestamp=2024-01-01 + """ + context_storage = {} + for arg in context_variables: + if '=' in arg: + key, value = arg.split('=', 1) + context_storage[key] = value + agent_module = importlib.import_module(f'metachain.agents') + try: + agent_func = getattr(agent_module, agent_func) + except AttributeError: + raise ValueError(f'Agent function {agent_func} not found, you shoud check in the `metachain.agents` directory for the correct function name') + agent = agent_func(model) + mc = MetaChain() + messages = [ + {"role": "user", "content": query} + ] + response = mc.run(agent, messages, context_storage, debug=True) + debug_print(True, response.messages[-1]['content'], title = f'Result of running {agent.name} agent', color = 'pink3') + return response.messages[-1]['content'] + +@cli.command() +@click.option('--workflow_name', default=None, help='the name of the workflow') +@click.option('--system_input', default='...', help='the user query to the agent') +def workflow(workflow_name: str, system_input: str): + """命令行函数的同步包装器""" + return asyncio.run(async_workflow(workflow_name, system_input)) + +async def async_workflow(workflow_name: str, system_input: str): + """异步实现的workflow函数""" + workflow_module = importlib.import_module(f'metachain.workflows') + try: + workflow_func = getattr(workflow_module, workflow_name) + except AttributeError: + raise ValueError(f'Workflow function {workflow_name} not found...') + + result = await workflow_func(system_input) # 使用 await 等待异步函数完成 + debug_print(True, result, title=f'Result of running {workflow_name} workflow', color='pink3') + return result \ No newline at end of file diff --git a/metachain/core.py b/metachain/core.py new file mode 100644 index 0000000..e59789c --- /dev/null +++ b/metachain/core.py @@ -0,0 +1,620 @@ +# Standard library imports +import copy +import json +from collections import defaultdict +from typing import List, Callable, Union +from datetime import datetime +# Local imports +import os +from .util import function_to_json, debug_print, merge_chunk, pretty_print_messages +from .types import ( + Agent, + AgentFunction, + Message, + ChatCompletionMessageToolCall, + Function, + Response, + Result, +) +from litellm import completion, acompletion +from pathlib import Path +from .logger import MetaChainLogger, LoggerManager +from httpx import RemoteProtocolError, ConnectError +from litellm.exceptions import APIError +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type +) +from openai import AsyncOpenAI +import litellm +import inspect +from constant import MC_MODE, FN_CALL, API_BASE_URL, NOT_SUPPORT_SENDER, ADD_USER, NON_FN_CALL +from metachain.fn_call_converter import convert_tools_to_description, convert_non_fncall_messages_to_fncall_messages, SYSTEM_PROMPT_SUFFIX_TEMPLATE, convert_fn_messages_to_non_fn_messages, interleave_user_into_messages +from litellm.types.utils import Message as litellmMessage +# litellm.set_verbose=True +# client = AsyncOpenAI() +def should_retry_error(exception): + if MC_MODE is False: print(f"Caught exception: {type(exception).__name__} - {str(exception)}") + + # 匹配更多错误类型 + if isinstance(exception, (APIError, RemoteProtocolError, ConnectError)): + return True + + # 通过错误消息匹配 + error_msg = str(exception).lower() + return any([ + "connection error" in error_msg, + "server disconnected" in error_msg, + "eof occurred" in error_msg, + "timeout" in error_msg, + "event loop is closed" in error_msg, # 添加事件循环错误 + "anthropicexception" in error_msg # 添加 Anthropic 相关错误 + ]) +__CTX_VARS_NAME__ = "context_variables" +logger = LoggerManager.get_logger() + +class MetaChain: + def __init__(self, log_path: Union[str, None, MetaChainLogger] = None): + """ + log_path: path of log file, None + """ + if logger: + self.logger = logger + elif isinstance(log_path, MetaChainLogger): + self.logger = log_path + else: + self.logger = MetaChainLogger(log_path=log_path) + # if self.logger.log_path is None: self.logger.info("[Warning] Not specific log path, so log will not be saved", "...", title="Log Path", color="light_cyan3") + # else: self.logger.info("Log file is saved to", self.logger.log_path, "...", title="Log Path", color="light_cyan3") + @retry( + stop=stop_after_attempt(4), + wait=wait_exponential(multiplier=1, min=4, max=60), + retry=should_retry_error, + before_sleep=lambda retry_state: print(f"Retrying... (attempt {retry_state.attempt_number})") + ) + def get_chat_completion( + self, + agent: Agent, + history: List, + context_variables: dict, + model_override: str, + stream: bool, + debug: bool, + ) -> Message: + context_variables = defaultdict(str, context_variables) + instructions = ( + agent.instructions(context_variables) + if callable(agent.instructions) + else agent.instructions + ) + if agent.examples: + examples = agent.examples(context_variables) if callable(agent.examples) else agent.examples + history = examples + history + + messages = [{"role": "system", "content": instructions}] + history + # debug_print(debug, "Getting chat completion for...:", messages) + + tools = [function_to_json(f) for f in agent.functions] + # hide context_variables from model + for tool in tools: + params = tool["function"]["parameters"] + params["properties"].pop(__CTX_VARS_NAME__, None) + if __CTX_VARS_NAME__ in params["required"]: + params["required"].remove(__CTX_VARS_NAME__) + if FN_CALL: + create_model = model_override or agent.model + assert litellm.supports_function_calling(model = create_model) == True, f"Model {create_model} does not support function calling, please set `FN_CALL=False` to use non-function calling mode" + create_params = { + "model": create_model, + "messages": messages, + "tools": tools or None, + "tool_choice": agent.tool_choice, + "stream": stream, + } + NO_SENDER_MODE = False + for not_sender_model in NOT_SUPPORT_SENDER: + if not_sender_model in create_model: + NO_SENDER_MODE = True + break + + if NO_SENDER_MODE: + messages = create_params["messages"] + for message in messages: + if 'sender' in message: + del message['sender'] + create_params["messages"] = messages + + if tools and create_params['model'].startswith("gpt"): + create_params["parallel_tool_calls"] = agent.parallel_tool_calls + completion_response = completion(**create_params) + else: + create_model = model_override or agent.model + assert agent.tool_choice == "required", f"Non-function calling mode MUST use tool_choice = 'required' rather than {agent.tool_choice}" + last_content = messages[-1]["content"] + tools_description = convert_tools_to_description(tools) + messages[-1]["content"] = last_content + "\n[IMPORTANT] You MUST use the tools provided to complete the task.\n" + SYSTEM_PROMPT_SUFFIX_TEMPLATE.format(description=tools_description) + NO_SENDER_MODE = False + for not_sender_model in NOT_SUPPORT_SENDER: + if not_sender_model in create_model: + NO_SENDER_MODE = True + break + + if NO_SENDER_MODE: + for message in messages: + if 'sender' in message: + del message['sender'] + if NON_FN_CALL: + messages = convert_fn_messages_to_non_fn_messages(messages) + if ADD_USER and messages[-1]["role"] != "user": + # messages.append({"role": "user", "content": "Please think twice and take the next action according to your previous actions and observations."}) + messages = interleave_user_into_messages(messages) + create_params = { + "model": create_model, + "messages": messages, + "stream": stream, + "base_url": API_BASE_URL, + } + completion_response = completion(**create_params) + last_message = [{"role": "assistant", "content": completion_response.choices[0].message.content}] + converted_message = convert_non_fncall_messages_to_fncall_messages(last_message, tools) + converted_tool_calls = [ChatCompletionMessageToolCall(**tool_call) for tool_call in converted_message[0]["tool_calls"]] + completion_response.choices[0].message = litellmMessage(content = converted_message[0]["content"], role = "assistant", tool_calls = converted_tool_calls) + + return completion_response + + def handle_function_result(self, result, debug) -> Result: + match result: + case Result() as result: + return result + + case Agent() as agent: + return Result( + value=json.dumps({"assistant": agent.name}), + agent=agent, + ) + case _: + try: + return Result(value=str(result)) + except Exception as e: + error_message = f"Failed to cast response to string: {result}. Make sure agent functions return a string or Result object. Error: {str(e)}" + self.logger.info(error_message, title="Handle Function Result Error", color="red") + raise TypeError(error_message) + + def handle_tool_calls( + self, + tool_calls: List[ChatCompletionMessageToolCall], + functions: List[AgentFunction], + context_variables: dict, + debug: bool, + handle_mm_func: Callable[[], str] = None, + ) -> Response: + function_map = {f.__name__: f for f in functions} + partial_response = Response( + messages=[], agent=None, context_variables={}) + + for tool_call in tool_calls: + name = tool_call.function.name + # handle missing tool case, skip to next tool + if name not in function_map: + self.logger.info(f"Tool {name} not found in function map. You are recommended to use `run_tool` to run this tool.", title="Tool Call Error", color="red") + partial_response.messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "name": name, + "content": f"Error: Tool {name} not found. You are recommended to use `run_tool` to run this tool.", + } + ) + continue + args = json.loads(tool_call.function.arguments) + + # debug_print( + # debug, f"Processing tool call: {name} with arguments {args}") + func = function_map[name] + # pass context_variables to agent functions + # if __CTX_VARS_NAME__ in func.__code__.co_varnames: + # args[__CTX_VARS_NAME__] = context_variables + if __CTX_VARS_NAME__ in inspect.signature(func).parameters.keys(): + args[__CTX_VARS_NAME__] = context_variables + raw_result = function_map[name](**args) + + result: Result = self.handle_function_result(raw_result, debug) + + partial_response.messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "name": name, + "content": result.value, + } + ) + self.logger.pretty_print_messages(partial_response.messages[-1]) + if result.image: + assert handle_mm_func, f"handle_mm_func is not provided, but an image is returned by tool call {name}({tool_call.function.arguments})" + partial_response.messages.append( + { + "role": "user", + "content": [ + # {"type":"text", "text":f"After take last action `{name}({tool_call.function.arguments})`, the image of current page is shown below. Please take next action based on the image, the current state of the page as well as previous actions and observations."}, + {"type":"text", "text":handle_mm_func(name, tool_call.function.arguments)}, + { + "type":"image_url", + "image_url":{ + "url":f"data:image/png;base64,{result.image}" + } + } + ] + } + ) + # debug_print(debug, "Tool calling: ", json.dumps(partial_response.messages[-1], indent=4), log_path=log_path, title="Tool Calling", color="green") + + partial_response.context_variables.update(result.context_variables) + if result.agent: + partial_response.agent = result.agent + + return partial_response + + def run_and_stream( + self, + agent: Agent, + messages: List, + context_variables: dict = {}, + model_override: str = None, + debug: bool = False, + max_turns: int = float("inf"), + execute_tools: bool = True, + ): + active_agent = agent + context_variables = copy.deepcopy(context_variables) + history = copy.deepcopy(messages) + init_len = len(messages) + + while len(history) - init_len < max_turns: + + message = { + "content": "", + "sender": agent.name, + "role": "assistant", + "function_call": None, + "tool_calls": defaultdict( + lambda: { + "function": {"arguments": "", "name": ""}, + "id": "", + "type": "", + } + ), + } + + # get completion with current history, agent + completion = self.get_chat_completion( + agent=active_agent, + history=history, + context_variables=context_variables, + model_override=model_override, + stream=True, + debug=debug, + ) + + yield {"delim": "start"} + for chunk in completion: + delta = json.loads(chunk.choices[0].delta.json()) + if delta["role"] == "assistant": + delta["sender"] = active_agent.name + yield delta + delta.pop("role", None) + delta.pop("sender", None) + merge_chunk(message, delta) + yield {"delim": "end"} + + message["tool_calls"] = list( + message.get("tool_calls", {}).values()) + if not message["tool_calls"]: + message["tool_calls"] = None + debug_print(debug, "Received completion:", message) + history.append(message) + + if not message["tool_calls"] or not execute_tools: + debug_print(debug, "Ending turn.") + break + + # convert tool_calls to objects + tool_calls = [] + for tool_call in message["tool_calls"]: + function = Function( + arguments=tool_call["function"]["arguments"], + name=tool_call["function"]["name"], + ) + tool_call_object = ChatCompletionMessageToolCall( + id=tool_call["id"], function=function, type=tool_call["type"] + ) + tool_calls.append(tool_call_object) + + # handle function calls, updating context_variables, and switching agents + partial_response = self.handle_tool_calls( + tool_calls, active_agent.functions, context_variables, debug + ) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + if partial_response.agent: + active_agent = partial_response.agent + + yield { + "response": Response( + messages=history[init_len:], + agent=active_agent, + context_variables=context_variables, + ) + } + + def run( + self, + agent: Agent, + messages: List, + context_variables: dict = {}, + model_override: str = None, + stream: bool = False, + debug: bool = True, + max_turns: int = float("inf"), + execute_tools: bool = True, + ) -> Response: + if stream: + return self.run_and_stream( + agent=agent, + messages=messages, + context_variables=context_variables, + model_override=model_override, + debug=debug, + max_turns=max_turns, + execute_tools=execute_tools, + ) + active_agent = agent + enter_agent = agent + context_variables = copy.copy(context_variables) + history = copy.deepcopy(messages) + init_len = len(messages) + + self.logger.info("Receiveing the task:", history[-1]['content'], title="Receive Task", color="green") + + while len(history) - init_len < max_turns and active_agent: + + # get completion with current history, agent + completion = self.get_chat_completion( + agent=active_agent, + history=history, + context_variables=context_variables, + model_override=model_override, + stream=stream, + debug=debug, + ) + message: Message = completion.choices[0].message + message.sender = active_agent.name + # debug_print(debug, "Received completion:", message.model_dump_json(indent=4), log_path=log_path, title="Received Completion", color="blue") + self.logger.pretty_print_messages(message) + history.append( + json.loads(message.model_dump_json()) + ) # to avoid OpenAI types (?) + + # if not message.tool_calls or not execute_tools: + # self.logger.info("Ending turn.", title="End Turn", color="red") + # break + + if enter_agent.tool_choice != "required": + if (not message.tool_calls and active_agent.name == enter_agent.name) or not execute_tools: + self.logger.info("Ending turn.", title="End Turn", color="red") + break + else: + if (message.tool_calls and message.tool_calls[0].function.name == "case_resolved") or not execute_tools: + self.logger.info("Ending turn with case resolved.", title="End Turn", color="red") + partial_response = self.handle_tool_calls( + message.tool_calls, active_agent.functions, context_variables, debug, handle_mm_func=active_agent.handle_mm_func + ) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + break + elif (message.tool_calls and message.tool_calls[0].function.name == "case_not_resolved") or not execute_tools: + self.logger.info("Ending turn with case not resolved.", title="End Turn", color="red") + partial_response = self.handle_tool_calls( + message.tool_calls, active_agent.functions, context_variables, debug, handle_mm_func=active_agent.handle_mm_func + ) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + break + # if (message.tool_calls and message.tool_calls[0].function.name == "case_resolved") or not execute_tools: + # debug_print(debug, "Ending turn.", log_path=log_path, title="End Turn", color="red") + # break + + # handle function calls, updating context_variables, and switching agents + if message.tool_calls: + partial_response = self.handle_tool_calls( + message.tool_calls, active_agent.functions, context_variables, debug, handle_mm_func=active_agent.handle_mm_func + ) + else: + partial_response = Response(messages=[message]) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + if partial_response.agent: + active_agent = partial_response.agent + + return Response( + messages=history[init_len:], + agent=active_agent, + context_variables=context_variables, + ) + @retry( + stop=stop_after_attempt(4), + wait=wait_exponential(multiplier=1, min=10, max=180), + retry=should_retry_error, + before_sleep=lambda retry_state: print(f"Retrying... (attempt {retry_state.attempt_number})") + ) + async def get_chat_completion_async( + self, + agent: Agent, + history: List, + context_variables: dict, + model_override: str, + stream: bool, + debug: bool, + ) -> Message: + context_variables = defaultdict(str, context_variables) + instructions = ( + agent.instructions(context_variables) + if callable(agent.instructions) + else agent.instructions + ) + if agent.examples: + examples = agent.examples(context_variables) if callable(agent.examples) else agent.examples + history = examples + history + + messages = [{"role": "system", "content": instructions}] + history + # debug_print(debug, "Getting chat completion for...:", messages) + + tools = [function_to_json(f) for f in agent.functions] + # hide context_variables from model + for tool in tools: + params = tool["function"]["parameters"] + params["properties"].pop(__CTX_VARS_NAME__, None) + if __CTX_VARS_NAME__ in params["required"]: + params["required"].remove(__CTX_VARS_NAME__) + + if FN_CALL: + create_model = model_override or agent.model + assert litellm.supports_function_calling(model = create_model) == True, f"Model {create_model} does not support function calling, please set `FN_CALL=False` to use non-function calling mode" + + create_params = { + "model": create_model, + "messages": messages, + "tools": tools or None, + "tool_choice": agent.tool_choice, + "stream": stream, + } + NO_SENDER_MODE = False + for not_sender_model in NOT_SUPPORT_SENDER: + if not_sender_model in create_model: + NO_SENDER_MODE = True + break + + if NO_SENDER_MODE: + messages = create_params["messages"] + for message in messages: + if 'sender' in message: + del message['sender'] + create_params["messages"] = messages + + if tools and create_params['model'].startswith("gpt"): + create_params["parallel_tool_calls"] = agent.parallel_tool_calls + completion_response = await acompletion(**create_params) + else: + create_model = model_override or agent.model + assert agent.tool_choice == "required", f"Non-function calling mode MUST use tool_choice = 'required' rather than {agent.tool_choice}" + last_content = messages[-1]["content"] + tools_description = convert_tools_to_description(tools) + messages[-1]["content"] = last_content + "\n[IMPORTANT] You MUST use the tools provided to complete the task.\n" + SYSTEM_PROMPT_SUFFIX_TEMPLATE.format(description=tools_description) + NO_SENDER_MODE = False + for not_sender_model in NOT_SUPPORT_SENDER: + if not_sender_model in create_model: + NO_SENDER_MODE = True + break + + if NO_SENDER_MODE: + for message in messages: + if 'sender' in message: + del message['sender'] + create_params = { + "model": create_model, + "messages": messages, + "stream": stream, + "base_url": API_BASE_URL, + } + completion_response = await acompletion(**create_params) + last_message = [{"role": "assistant", "content": completion_response.choices[0].message.content}] + converted_message = convert_non_fncall_messages_to_fncall_messages(last_message, tools) + converted_tool_calls = [ChatCompletionMessageToolCall(**tool_call) for tool_call in converted_message[0]["tool_calls"]] + completion_response.choices[0].message = litellmMessage(content = converted_message[0]["content"], role = "assistant", tool_calls = converted_tool_calls) + + # response = await acompletion(**create_params) + # response = await client.chat.completions.create(**create_params) + return completion_response + + async def run_async( + self, + agent: Agent, + messages: List, + context_variables: dict = {}, + model_override: str = None, + stream: bool = False, + debug: bool = True, + max_turns: int = float("inf"), + execute_tools: bool = True, + ) -> Response: + assert stream == False, "Async run does not support stream" + active_agent = agent + enter_agent = agent + context_variables = copy.copy(context_variables) + history = copy.deepcopy(messages) + init_len = len(messages) + + self.logger.info("Receiveing the task:", history[-1]['content'], title="Receive Task", color="green") + + while len(history) - init_len < max_turns and active_agent: + + # get completion with current history, agent + completion = await self.get_chat_completion_async( + agent=active_agent, + history=history, + context_variables=context_variables, + model_override=model_override, + stream=stream, + debug=debug, + ) + message: Message = completion.choices[0].message + message.sender = active_agent.name + # debug_print(debug, "Received completion:", message.model_dump_json(indent=4), log_path=log_path, title="Received Completion", color="blue") + self.logger.pretty_print_messages(message) + history.append( + json.loads(message.model_dump_json()) + ) # to avoid OpenAI types (?) + + if enter_agent.tool_choice != "required": + if (not message.tool_calls and active_agent.name == enter_agent.name) or not execute_tools: + self.logger.info("Ending turn.", title="End Turn", color="red") + break + else: + if (message.tool_calls and message.tool_calls[0].function.name == "case_resolved") or not execute_tools: + self.logger.info("Ending turn with case resolved.", title="End Turn", color="red") + partial_response = self.handle_tool_calls( + message.tool_calls, active_agent.functions, context_variables, debug, handle_mm_func=active_agent.handle_mm_func + ) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + break + elif (message.tool_calls and message.tool_calls[0].function.name == "case_not_resolved") or not execute_tools: + self.logger.info("Ending turn with case not resolved.", title="End Turn", color="red") + partial_response = self.handle_tool_calls( + message.tool_calls, active_agent.functions, context_variables, debug, handle_mm_func=active_agent.handle_mm_func + ) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + break + # if (message.tool_calls and message.tool_calls[0].function.name == "case_resolved") or not execute_tools: + # debug_print(debug, "Ending turn.", log_path=log_path, title="End Turn", color="red") + # break + + # handle function calls, updating context_variables, and switching agents + if message.tool_calls: + partial_response = self.handle_tool_calls( + message.tool_calls, active_agent.functions, context_variables, debug, handle_mm_func=active_agent.handle_mm_func + ) + else: + partial_response = Response(messages=[message]) + history.extend(partial_response.messages) + context_variables.update(partial_response.context_variables) + if partial_response.agent: + active_agent = partial_response.agent + + return Response( + messages=history[init_len:], + agent=active_agent, + context_variables=context_variables, + ) diff --git a/metachain/environment/__init__.py b/metachain/environment/__init__.py new file mode 100644 index 0000000..359d4e3 --- /dev/null +++ b/metachain/environment/__init__.py @@ -0,0 +1,5 @@ +from .docker_env import DockerEnv, DockerConfig +from .local_env import LocalEnv +from .browser_env import BrowserEnv, VIEWPORT +from .markdown_browser import RequestsMarkdownBrowser +from .utils import setup_metachain \ No newline at end of file diff --git a/metachain/environment/browser_cookies.py b/metachain/environment/browser_cookies.py new file mode 100644 index 0000000..ecfa24e --- /dev/null +++ b/metachain/environment/browser_cookies.py @@ -0,0 +1,49 @@ +import json +from pathlib import Path +import glob + +wd = Path(__file__).parent.resolve() +def load_cookies_from_json(json_path): + with open(json_path, 'r') as f: + cookies = json.load(f) + return cookies + +# COOKIES_LIST = [] +# COOKIES_LIST += load_cookies_from_json(wd / "cookie_json" / "orcid.org.cookies.json") +# COOKIES_LIST += load_cookies_from_json(wd / "cookie_json" / "www.researchgate.net.cookies.json") +# COOKIES_LIST += load_cookies_from_json(wd / "cookie_json" / "github.com.cookies.json") +# COOKIES_LIST += load_cookies_from_json(wd / "cookie_json" / "www.youtube.com.cookies.json") +# COOKIES_LIST += load_cookies_from_json(wd / "cookie_json" / "www.ncbi.nlm.nih.gov.cookies.json") +# COOKIES_LIST += load_cookies_from_json(wd / "cookie_json" / "archive.org.cookies.json") + +def convert_cookies_to_python(): + all_cookies = [] + # cookie_files = [ + # "orcid.org.cookies.json", + # "www.researchgate.net.cookies.json", + # "github.com.cookies.json", + # "www.youtube.com.cookies.json", + # "www.ncbi.nlm.nih.gov.cookies.json", + # "archive.org.cookies.json", + # "nature.com.cookies.json" + # ] + json_dir = wd / "cookie_json" + cookie_files = glob.glob(str(json_dir / "*.json")) + + for cookie_file in cookie_files: + json_path = wd / "cookie_json" / cookie_file + cookies = load_cookies_from_json(json_path) + all_cookies.extend(cookies) + + # 生成Python格式的cookies文件 + output_path = wd / "cookies_data.py" + output_str = "COOKIES_LIST = [\n" + for cookie in all_cookies: + output_str += f" {repr(cookie)},\n" + output_str += "]\n" + with open(output_path, "w", encoding="utf-8") as f: + f.write(output_str) + return output_str + +if __name__ == "__main__": + print(convert_cookies_to_python()) \ No newline at end of file diff --git a/metachain/environment/browser_env.py b/metachain/environment/browser_env.py new file mode 100644 index 0000000..5a2c402 --- /dev/null +++ b/metachain/environment/browser_env.py @@ -0,0 +1,649 @@ +import atexit +import base64 +import io +import json +import multiprocessing +import time +import uuid + +import browsergym.core # noqa F401 (we register the openended task as a gym environment) +import gymnasium as gym +import html2text +import numpy as np +import tenacity +from browsergym.utils.obs import flatten_dom_to_str +from PIL import Image +from metachain.util import debug_print +from metachain.logger import LoggerManager +import inspect +import textwrap + +from .shutdown_listener import should_continue, should_exit +from .tenacity_stop import stop_if_should_exit +from datetime import datetime +from pathlib import Path +from browsergym.core.action.functions import goto, page, get_elem_by_bid, demo_mode, tab_focus +import os +from typing import Dict, Union, cast, Literal +from playwright.sync_api import Page, Download +from metachain.io_utils import read_file +from metachain.environment.mdconvert import _get_page_markdown +from metachain.environment.browser_cookies import convert_cookies_to_python +from metachain.environment.cookies_data import COOKIES_LIST +# from constant import DOCKER_WORKPLACE_NAME, LOCAL_ROOT +from functools import update_wrapper +from inspect import signature +import types +import sys +import tempfile +VIEWPORT = {"width": 1280, "height": 720} + +BROWSER_EVAL_GET_GOAL_ACTION = 'GET_EVAL_GOAL' +BROWSER_EVAL_GET_REWARDS_ACTION = 'GET_EVAL_REWARDS' +class BrowserInitException(Exception): + def __init__(self, message='Failed to initialize browser environment'): + super().__init__(message) +def _local_to_docker(local_path: str): + """ + Convert a local path to a docker path + local_path: the local path to convert, like `{local_workplace}/downloads/xxx` + docker_path: the docker path to convert, like `{docker_workplace}/downloads/xxx` + + Examples: + _local_to_docker('/Users/tangjiabin/Documents/reasoning/metachain/workplace_gaia_eval/downloads/xxx') + """ + local_workplace = None + docker_workplace = None + assert local_workplace in local_path, f"local_path must contain {local_workplace}" + return local_path.replace(local_workplace, docker_workplace) +def _visit_page(url: str): + """ + Visit a page, including downloading files based on the url + + Examples: + _visit_page('https://archive.org/download/higpt_stage2/instruct_ds_dblp.tar.gz') + """ + # def _local_to_docker(local_path: str): + # """ + # Convert a local path to a docker path + # local_path: the local path to convert, like `{LOCAL_ROOT}/{DOCKER_WORKPLACE_NAME}/downloads/xxx` + # docker_path: the docker path to convert, like `/{DOCKER_WORKPLACE_NAME}/downloads/xxx` + # """ + # assert LOCAL_ROOT in local_path, f"local_path must contain {LOCAL_ROOT}" + # return local_path.replace(LOCAL_ROOT, '') + try: + # 尝试作为普通网页访问 + page.context.add_cookies(COOKIES_LIST) + # goto(url) + page.set_extra_http_headers({ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9" + }) + page.goto(url, timeout=6000) + if page.get_by_text("Verify you are human by completing the action below.").count() > 0: + _checkMeetChallenge() + # 等待页面完全加载 + # 增加等待时间,确保页面完全加载 + page.wait_for_load_state("networkidle", timeout=3000) + # page.wait_for_timeout(3000) + + except Exception as e_outer: + # 处理文件下载情况 + if "net::ERR_ABORTED" in str(e_outer): + import os + import requests + import base64 + downloads_folder = f"{local_workplace}/downloads" + + os.makedirs(downloads_folder, exist_ok=True) + filename = os.path.basename(url) + filepath = os.path.join(downloads_folder, filename) + filepath = os.path.abspath(filepath) + try: + # 使用requests下载文件 + response = requests.get(url, stream=True) + response.raise_for_status() + + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + # 显示下载成功页面 + message = f""" +

Successfully downloaded '{filename}' to local path: +

{_local_to_docker(filepath)}

""" + + goto( + "data:text/html;base64," + + base64.b64encode(message.encode("utf-8")).decode("utf-8") + ) + + # 触发pageshow事件 + page.evaluate(""" + const event = new Event('pageshow', { + bubbles: true, + cancelable: false + }); + window.dispatchEvent(event); + """) + + except Exception as e: + raise Exception(f"Download error: {str(e)}") + else: + raise e_outer + +# def _click_id(bid: str, button: Literal["left", "middle", "right"] = "left"): +# """ +# Clicks the mouse on the target with the given element bid. + +# Examples: +# _click_id('12') +# _click_id('12', button='left') +# """ +# from typing import Dict, Union, cast +# try: +# elem = get_elem_by_bid(page, bid, demo_mode != "off") +# box = cast(Dict[str, Union[int, float]], elem.bounding_box()) +# # 如果既不是下载也不是新页面,在当前页面处理 +# page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, button=button) +# try: +# page.wait_for_load_state("networkidle", timeout=5000) +# except: +# pass +# return + +# except Exception as e: +# raise Exception(f"Click error: {str(e)}") + + +def _click_id(bid: str, button: Literal["left", "middle", "right"] = "left"): + """ + Clicks the mouse on the target with the given element bid. + + Examples: + _click_id('12') + _click_id('12', button='left') + """ + # def _local_to_docker(local_path: str): + # """ + # Convert a local path to a docker path + # local_path: the local path to convert, like `{LOCAL_ROOT}/{DOCKER_WORKPLACE_NAME}/downloads/xxx` + # docker_path: the docker path to convert, like `/{DOCKER_WORKPLACE_NAME}/downloads/xxx` + # """ + # assert LOCAL_ROOT in local_path, f"local_path must contain {LOCAL_ROOT}" + # return local_path.replace(LOCAL_ROOT, '') + from typing import Dict, Union, cast + import time + import base64 + import os + from playwright._impl._api_types import TimeoutError as playwright_TimeoutError + try: + global page + elem = get_elem_by_bid(page, bid, demo_mode != "off") + box = cast(Dict[str, Union[int, float]], elem.bounding_box()) + + # 获取当前页面URL + current_url = page.url + page.context.add_cookies(COOKIES_LIST) + # goto(url) + page.set_extra_http_headers({ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9" + }) + + # 执行点击并等待下载 + try: + with page.expect_download(timeout=5000) as download_info: # 增加到30秒 + page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, button=button) + download = download_info.value + print(f"Downloading file: {download.suggested_filename}") + # 确保下载目录存在 + + download_path = f"{local_workplace}/downloads" + os.makedirs(download_path, exist_ok=True) + # 保存文件 + filepath = os.path.join(download_path, download.suggested_filename) + filepath = os.path.abspath(filepath) + download.save_as(filepath) + # 显示下载成功页面 + + message = f""" +

Successfully downloaded '{download.suggested_filename}' to local path: +

{_local_to_docker(filepath)}

""" + + goto( + "data:text/html;base64," + + base64.b64encode(message.encode("utf-8")).decode("utf-8") + ) + + # 触发pageshow事件 + page.evaluate(""" + const event = new Event('pageshow', { + bubbles: true, + cancelable: false + }); + window.dispatchEvent(event); + """) + return + except playwright_TimeoutError: + # print("Download timeout, trying alternative approach...") + # # 如果超时,尝试获取PDF直接URL并下载 + # if "arxiv.org" in current_url: + # paper_id = current_url.split("/")[-1] + # pdf_url = f"https://arxiv.org/pdf/{paper_id}.pdf" + # _visit_page(pdf_url) + # return + pass + + # 等待可能的新标签页或导航 + time.sleep(1) + + # 检查是否有新标签页 + pages_after = len(page.context.pages) + if pages_after > 1: + # 切换到最新的标签页 + page = page.context.pages[-1] + page.bring_to_front() + elif page.url != current_url: + # URL改变了,说明发生了导航 + try: + page.wait_for_load_state("networkidle", timeout=5000) + if page.get_by_text("Verify you are human by completing the action below.").count() > 0: + _checkMeetChallenge() + # 等待页面完全加载 + # 增加等待时间,确保页面完全加载 + page.wait_for_load_state("networkidle", timeout=3000) + except: + pass + + return + + except Exception as e: + raise Exception(f"Click error: {str(e)}, {type(e)}") +def _checkMeetChallenge(): + """ + check if meet challenge + + Examples: + _checkMeetChallenge() + """ + global page + def tryToClickChallenge(this_page): + try: + # 尝试定位并点击验证框架中的复选框 + frame = this_page.frame_locator("iframe[title*='challenge']") + if frame: + checkbox = frame.locator("input[type='checkbox']") + if checkbox.is_visible(): + checkbox.click() + return True + + # 尝试点击验证按钮 (同时支持中英文) + verify_texts = ["请完成以下操作,验证您是真人。", "Verify you are human by completing the action below."] + for text in verify_texts: + verify_button = this_page.get_by_text(text) + if verify_button.is_visible(): + verify_button.click() + return True + + # 尝试点击任何可见的验证按钮 + challenge_buttons = this_page.locator("button[class*='challenge']") + if challenge_buttons.count() > 0: + challenge_buttons.first.click() + return True + + except Exception as e: + print(f"尝试点击验证失败: {str(e)}") + return False + + check_count = 1 + max_attempts = 6 + while check_count <= max_attempts: + # 检查是否存在验证页面的特征 (同时支持中英文) + if (page.get_by_text("请完成以下操作,验证您是真人。").count() == 0 and + page.get_by_text("Verify you are human by completing the action below.").count() == 0): + print("验证已完成") + break + + print(f"检测到 Cloudflare 验证页面,尝试处理... (第 {check_count}/{max_attempts} 次)") + + # 尝试处理验证 + if tryToClickChallenge(page): + print("已尝试点击验证按钮,等待响应...") + + # 等待验证结果 + try: + # 等待验证页面消失或出现新内容 + page.wait_for_function(""" + () => !document.querySelector("div#challenge-stage") || + (!document.body.textContent.includes("请完成以下操作,验证您是真人。") && + !document.body.textContent.includes("Verify you are human by completing the action below.")) + """, timeout=20000) + except: + print("等待验证超时") + + # 检查是否仍在验证页面 + if check_count >= max_attempts: + if (page.get_by_text("请完成以下操作,验证您是真人。").count() > 0 or + page.get_by_text("Verify you are human by completing the action below.").count() > 0): + raise Exception("cannot pass challenge, need to restart") + + check_count += 1 + page.wait_for_timeout(5000) # 短暂等待后再次检查 + +class BrowserEnv: + def __init__(self, browsergym_eval_env: str | None = None, local_root: str | None = None, workplace_name: str | None = None): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + log_dir = Path(f"logs/res_{timestamp}") + log_dir.mkdir(parents=True, exist_ok=True) # recursively create all necessary parent directories + log_path = str(log_dir / "browser_env.log") + self.log_path = log_path + # self.logger = LoggerManager.get_logger() + self.html_text_converter = self.get_html_text_converter() + self.eval_mode = False + self.eval_dir = '' + self.local_workplace = os.path.join(local_root, workplace_name) + self.docker_workplace = f"/{workplace_name}" + + # EVAL only: browsergym_eval_env must be provided for evaluation + self.browsergym_eval_env = browsergym_eval_env + self.eval_mode = bool(browsergym_eval_env) + + # Initialize browser environment process + multiprocessing.set_start_method('spawn', force=True) + self.browser_side, self.agent_side = multiprocessing.Pipe() + + # tmp_env = gym.make(self.browsergym_eval_env,tags_to_mark='all') if self.eval_mode else gym.make('browsergym/openended',task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'}, + # wait_for_user_message=False, + # headless=True, + # disable_env_checker=True, + # tags_to_mark='all' + # ) + # obs, info = tmp_env.reset() + # self.viewport = tmp_env.env.viewport if tmp_env.env.viewport else tmp_env.env.task.viewport + # tmp_env.close() + self.init_browser() + + atexit.register(self.close) + + def get_html_text_converter(self): + html_text_converter = html2text.HTML2Text() + # ignore links and images + html_text_converter.ignore_links = False + html_text_converter.ignore_images = True + # use alt text for images + html_text_converter.images_to_alt = True + # disable auto text wrapping + html_text_converter.body_width = 0 + return html_text_converter + + @tenacity.retry( + wait=tenacity.wait_fixed(1), + stop=tenacity.stop_after_attempt(5) | stop_if_should_exit(), + retry=tenacity.retry_if_exception_type(BrowserInitException), + ) + def init_browser(self): + debug_print(True, "Starting browser env...", title = "Browser Env", log_path=self.log_path) + # self.logger.info("Starting browser env...", title="Browser Env", color="green") + try: + self.process = multiprocessing.Process(target=self.browser_process) + self.process.start() + except Exception as e: + debug_print(True, f'Failed to start browser process: {e}', title = "Browser Env", log_path=self.log_path) + # self.logger.info(f'Failed to start browser process: {e}', title="Browser Env", color="red") + raise + + if not self.check_alive(): + self.close() + raise BrowserInitException('Failed to start browser environment.') + + def browser_process(self): + if self.eval_mode: + assert self.browsergym_eval_env is not None + debug_print(True, 'Initializing browser env for web browsing evaluation.', title = "Browser Env", log_path=self.log_path) + # self.logger.info('Initializing browser env for web browsing evaluation.', title="Browser Env", color="green") + if 'webarena' in self.browsergym_eval_env: + import browsergym.webarena # noqa F401 register webarena tasks as gym environments + elif 'miniwob' in self.browsergym_eval_env: + import browsergym.miniwob # noqa F401 register miniwob tasks as gym environments + else: + raise ValueError( + f'Unsupported browsergym eval env: {self.browsergym_eval_env}' + ) + env = gym.make( + self.browsergym_eval_env, + tags_to_mark='all', + ) + else: + from browsergym.core.action.highlevel import HighLevelActionSet + def _local_to_docker(local_path: str): + """ + Convert a local path to a docker path + local_path: the local path to convert, like `{local_workplace}/downloads/xxx` + docker_path: the docker path to convert, like `{docker_workplace}/downloads/xxx` + + Examples: + _local_to_docker('/Users/tangjiabin/Documents/reasoning/metachain/workplace_gaia_eval/downloads/xxx') + """ + local_workplace = None + docker_workplace = None + assert local_workplace in local_path, f"local_path must contain {local_workplace}" + return local_path.replace(local_workplace, docker_workplace) + source = inspect.getsource(_local_to_docker) + normalized_source = textwrap.dedent(source) + normalized_source = normalized_source.replace('local_workplace = None', f'local_workplace = {repr(self.local_workplace)}') + normalized_source = normalized_source.replace('docker_workplace = None', f'docker_workplace = {repr(self.docker_workplace)}') + + action_set = HighLevelActionSet(subsets = ["chat", "infeas", "bid", "nav", "tab", "custom"], custom_actions = [_visit_page, _click_id, _get_page_markdown, _checkMeetChallenge]) +# action_set.python_includes = \ +# f""" +# {repr(read_file('metachain/environment/markdown_browser/mdconvert.py'))} + +# """ + action_set.python_includes + + action_set.python_includes = f"""\ +{convert_cookies_to_python()} +""" + action_set.python_includes + action_set.python_includes = f"""\ +def _local_to_docker(local_path: str): + local_workplace = {repr(self.local_workplace)} + docker_workplace = {repr(self.docker_workplace)} + assert local_workplace in local_path + return local_path.replace(local_workplace, docker_workplace) + +""" + action_set.python_includes + action_set.python_includes = f"local_workplace = {repr(self.local_workplace)}\n" + action_set.python_includes + + # action_set.python_includes = f"LOCAL_ROOT = {repr(LOCAL_ROOT)}\n" + action_set.python_includes + + # print(action_set.python_includes) + action_mapping = action_set.to_python_code + env = gym.make( + 'browsergym/openended', + task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'}, + wait_for_user_message=False, + headless=True, + disable_env_checker=True, + tags_to_mark='all', + action_mapping = action_mapping + ) + + + obs, info = env.reset() + + # self.viewport = env.env.viewport if env.env.viewport else env.env.task.viewport + # print(f"Viewport: {self.viewport}") + # 通过管道发送viewport信息 + + # EVAL ONLY: save the goal into file for evaluation + self.eval_goal = None + self.eval_rewards: list[float] = [] + if self.eval_mode: + debug_print(True, f"Browsing goal: {obs['goal']}", title = "Browser Env", log_path=self.log_path) + # self.logger.info(f"Browsing goal: {obs['goal']}", title="Browser Env", color="green") + self.eval_goal = obs['goal'] + + debug_print(True, 'Browser env started.', title = "Browser Env", log_path=self.log_path) + # self.logger.info('Browser env started.', title="Browser Env", color="green") + while should_continue(): + try: + if self.browser_side.poll(timeout=0.01): + unique_request_id, action_data = self.browser_side.recv() + + # shutdown the browser environment + if unique_request_id == 'SHUTDOWN': + debug_print(False, 'SHUTDOWN recv, shutting down browser env...', title = "Browser Env", log_path=self.log_path) + # self.logger.info('SHUTDOWN recv, shutting down browser env...', title="Browser Env", color="green") + env.close() + return + elif unique_request_id == 'IS_ALIVE': + self.browser_side.send(('ALIVE', None)) + continue + + # EVAL ONLY: Get evaluation info + if action_data['action'] == BROWSER_EVAL_GET_GOAL_ACTION: + self.browser_side.send( + (unique_request_id, {'text_content': self.eval_goal}) + ) + continue + elif action_data['action'] == BROWSER_EVAL_GET_REWARDS_ACTION: + self.browser_side.send( + ( + unique_request_id, + {'text_content': json.dumps(self.eval_rewards)}, + ) + ) + continue + + action = action_data['action'] + obs, reward, terminated, truncated, info = env.step(action) + + # EVAL ONLY: Save the rewards into file for evaluation + if self.eval_mode: + self.eval_rewards.append(reward) + + # add text content of the page + html_str = flatten_dom_to_str(obs['dom_object']) + obs['text_content'] = self.html_text_converter.handle(html_str) + # make observation serializable + obs['screenshot'] = self.image_to_png_base64_url(obs['screenshot']) + obs['active_page_index'] = obs['active_page_index'].item() + obs['elapsed_time'] = obs['elapsed_time'].item() + self.browser_side.send((unique_request_id, obs)) + except KeyboardInterrupt: + debug_print(True, 'Browser env process interrupted by user.', title = "Browser Env", log_path=self.log_path) + # self.logger.info('Browser env process interrupted by user.', title="Browser Env", color="green") + try: + env.close() + except Exception: + pass + return + + def step(self, action_str: str, timeout: float = 30) -> dict: + """Execute an action in the browser environment and return the observation.""" + unique_request_id = str(uuid.uuid4()) + self.agent_side.send((unique_request_id, {'action': action_str})) + start_time = time.time() + while True: + if should_exit() or (time.time() - start_time > timeout and '_visit_page' not in action_str): + raise TimeoutError('Browser environment took too long to respond.') + if should_exit() or (time.time() - start_time > 600 and '_visit_page' in action_str): + raise TimeoutError('Browser environment took too long to respond.') + if self.agent_side.poll(timeout=0.01): + response_id, obs = self.agent_side.recv() + if response_id == unique_request_id: + return obs + + def check_alive(self, timeout: float = 60): + self.agent_side.send(('IS_ALIVE', None)) + if self.agent_side.poll(timeout=timeout): + response_id, _ = self.agent_side.recv() + if response_id == 'ALIVE': + return True + debug_print(True, f'Browser env is not alive. Response ID: {response_id}', title = "Browser Env", log_path=self.log_path) + # self.logger.info(f'Browser env is not alive. Response ID: {response_id}', title="Browser Env", color="red") + + def close(self): + if not self.process.is_alive(): + return + try: + self.agent_side.send(('SHUTDOWN', None)) + self.process.join(5) # Wait for the process to terminate + if self.process.is_alive(): + debug_print(True, 'Browser process did not terminate, forcefully terminating...', title = "Browser Env", log_path=self.log_path) + # self.logger.info('Browser process did not terminate, forcefully terminating...', title="Browser Env", color="red") + self.process.terminate() + self.process.join(5) # Wait for the process to terminate + if self.process.is_alive(): + self.process.kill() + self.process.join(5) # Wait for the process to terminate + self.agent_side.close() + self.browser_side.close() + except Exception: + debug_print(True, 'Encountered an error when closing browser env', exc_info=True, title = "Browser Env", log_path=self.log_path) + # self.logger.info('Encountered an error when closing browser env', exc_info=True, title="Browser Env", color="red") + + @staticmethod + def image_to_png_base64_url( + image: np.ndarray | Image.Image, add_data_prefix: bool = False + ): + """Convert a numpy array to a base64 encoded png image url.""" + if isinstance(image, np.ndarray): + image = Image.fromarray(image) + if image.mode in ('RGBA', 'LA'): + image = image.convert('RGB') + buffered = io.BytesIO() + image.save(buffered, format='PNG') + + image_base64 = base64.b64encode(buffered.getvalue()).decode() + return ( + f'data:image/png;base64,{image_base64}' + if add_data_prefix + else f'{image_base64}' + ) + + @staticmethod + def image_to_jpg_base64_url( + image: np.ndarray | Image.Image, add_data_prefix: bool = False + ): + """Convert a numpy array to a base64 encoded jpeg image url.""" + if isinstance(image, np.ndarray): + image = Image.fromarray(image) + if image.mode in ('RGBA', 'LA'): + image = image.convert('RGB') + buffered = io.BytesIO() + image.save(buffered, format='JPEG') + + image_base64 = base64.b64encode(buffered.getvalue()).decode() + return ( + f'data:image/jpeg;base64,{image_base64}' + if add_data_prefix + else f'{image_base64}' + ) +def source_to_function(source_code: str, func_name: str): + """将源代码字符串转换为函数,支持 inspect.getsource""" + # 创建临时文件 + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + f.write(source_code) + temp_path = f.name + + try: + # 导入临时模块 + import importlib.util + spec = importlib.util.spec_from_file_location("temp_module", temp_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 获取函数 + func = getattr(module, func_name) + return func + + finally: + # 清理临时文件 + os.unlink(temp_path) + + + \ No newline at end of file diff --git a/metachain/environment/cookie_json/archive.org.cookies.json b/metachain/environment/cookie_json/archive.org.cookies.json new file mode 100644 index 0000000..f608f89 --- /dev/null +++ b/metachain/environment/cookie_json/archive.org.cookies.json @@ -0,0 +1,83 @@ +[ + { + "name": "donation-identifier", + "value": "8ed6af4cc08b88b68b36fffcb6dd7323", + "domain": ".archive.org", + "path": "/", + "expires": 1741773847.95608, + "httpOnly": false, + "secure": false + }, + { + "name": "abtest-identifier", + "value": "ca9982a6c4240d53598f01665a3c6100", + "domain": ".archive.org", + "path": "/", + "expires": 1741773847.956153, + "httpOnly": false, + "secure": false + }, + { + "name": "test-cookie", + "value": "1", + "domain": ".archive.org", + "path": "/", + "expires": 1734348067.326946, + "httpOnly": false, + "secure": false + }, + { + "name": "g_state", + "value": "{\"i_l\":0}", + "domain": "archive.org", + "path": "/", + "expires": 1748690473, + "httpOnly": false, + "secure": false + }, + { + "name": "logged-in-sig", + "value": "1764674476%201733138476%20Y3yQCmHjxUil%2FcGs%2FgYR6m%2FHA%2F%2FtAtShDsn25N2tNIzvkGr6EkwbEsYEwDTjZ6%2Bu4Iy65eDH5gZVrZayaRZzJEa6R91agNjLC1rmw%2F47W5OXyDVFN5kLX%2Ba2OxNOzEx6Ws%2BLVwFVr%2Bdnbzhdt1vqNTEpECwy14%2Fu4n9qXGANJ5IKEO7pfu4ONymTb0RWH%2B158Wphp0Gluy9bR1a3t3TSGM%2FyhBEa37FJ56ckJJDghwIVsANhhu%2FextDlCDLXDkPtxLrwdX%2FAlbBoNFIeQ5%2BzoJX21KKQVdJxVWzSRLb4LXyFQsvhkpL221qlJ%2FDQER53IrTAIkmxrDI4cfjumUnKTQ%3D%3D", + "domain": ".archive.org", + "path": "/", + "expires": 1764674476.838234, + "httpOnly": false, + "secure": false + }, + { + "name": "logged-in-user", + "value": "jiabintang77%40gmail.com", + "domain": ".archive.org", + "path": "/", + "expires": 1764674476.838343, + "httpOnly": false, + "secure": false + }, + { + "name": "PHPSESSID", + "value": "jteta3bg9mb3t8e6dkp7r6mcd4", + "domain": ".archive.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "donation", + "value": "x", + "domain": ".archive.org", + "path": "/", + "expires": 1736767334, + "httpOnly": false, + "secure": false + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193871a38b622b1-030839df772cce-1e525636-1fa400-193871a38b71d9a%22%2C%22%24device_id%22%3A%20%22193871a38b622b1-030839df772cce-1e525636-1fa400-193871a38b71d9a%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".archive.org", + "path": "/", + "expires": 1764675133, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/dl-acm-org.eproxy.lib.hku.hk.cookies.json b/metachain/environment/cookie_json/dl-acm-org.eproxy.lib.hku.hk.cookies.json new file mode 100644 index 0000000..66bce88 --- /dev/null +++ b/metachain/environment/cookie_json/dl-acm-org.eproxy.lib.hku.hk.cookies.json @@ -0,0 +1,48 @@ +[ + { + "name": "AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "179643557%7CMCIDTS%7C20060%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733149186s%7CNONE%7CvVersion%7C5.5.0", + "domain": ".hku.hk", + "path": "/", + "expires": 1767701986.053151, + "httpOnly": false, + "secure": false + }, + { + "name": "ezproxy", + "value": "e1~OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false + }, + { + "name": "ezproxyl", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ezproxyn", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".lib.hku.hk", + "path": "/", + "expires": 1765083373, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/github.com.cookies.json b/metachain/environment/cookie_json/github.com.cookies.json new file mode 100644 index 0000000..a5e0c43 --- /dev/null +++ b/metachain/environment/cookie_json/github.com.cookies.json @@ -0,0 +1,159 @@ +[ + { + "name": "_device_id", + "value": "49f9d6cfbd603c8509e73807be70a438", + "domain": "github.com", + "path": "/", + "expires": 1764674868.858374, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "MicrosoftApplicationsTelemetryDeviceId", + "value": "3966ee53-78ca-4fa3-95d7-85e299cecee4", + "domain": "github.com", + "path": "/", + "expires": 1763890136.033527, + "httpOnly": false, + "secure": true + }, + { + "name": "_octo", + "value": "GH1.1.1313590405.1727940967", + "domain": ".github.com", + "path": "/", + "expires": 1759476967, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "GHCC", + "value": "Required:1-Analytics:1-SocialMedia:1-Advertising:1", + "domain": ".github.com", + "path": "/", + "expires": 1745563377, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "MSFPC", + "value": "GUID=3452f0b49fd14d349a6dbf8ddee26d60&HASH=3452&LV=202410&V=4&LU=1730011383391", + "domain": "github.com", + "path": "/", + "expires": 1761547383.513164, + "httpOnly": false, + "secure": true + }, + { + "name": "logged_in", + "value": "yes", + "domain": ".github.com", + "path": "/", + "expires": 1762511337.053395, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "saved_user_sessions", + "value": "151511798%3A8an8gJwE3la35NvNIyacuRFRSHlup_9RBaQ5q4CThhvPV89o%7C152840453%3A2Quysh6Cns_a0IpeKcw-GAUZIt6ZndbJ7BoGdxx11qkZa9bi%7C151510669%3AMpYw2DQuFwt3NJiimm36OWLTQmoWFzVcSUbLuV8SBFRPqN8-%7C165454715%3AZSjwi4MUxVCr91r-m1ElvPL2L0DGDSoSo6uwV7pPpliml3js%7C148674909%3ALnLJclEDIxFjFcwX0eBlgOJzbDpsxKedtd6So7_EFs6HPtL7%7C56426168%3AmM073me2REE4yufnvYBKQ84KsG54oHKLBiTSs5tXYCoyQx4g", + "domain": "github.com", + "path": "/", + "expires": 1739599354.295483, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "user_session", + "value": "mM073me2REE4yufnvYBKQ84KsG54oHKLBiTSs5tXYCoyQx4g", + "domain": "github.com", + "path": "/", + "expires": 1734348468.858989, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "__Host-user_session_same_site", + "value": "mM073me2REE4yufnvYBKQ84KsG54oHKLBiTSs5tXYCoyQx4g", + "domain": "github.com", + "path": "/", + "expires": 1734348468.859144, + "httpOnly": true, + "secure": true, + "sameSite": "Strict" + }, + { + "name": "dotcom_user", + "value": "tjb-tech", + "domain": ".github.com", + "path": "/", + "expires": 1763647073.257243, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "color_mode", + "value": "%7B%22color_mode%22%3A%22auto%22%2C%22light_theme%22%3A%7B%22name%22%3A%22light%22%2C%22color_mode%22%3A%22light%22%7D%2C%22dark_theme%22%3A%7B%22name%22%3A%22dark%22%2C%22color_mode%22%3A%22dark%22%7D%7D", + "domain": ".github.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "cpu_bucket", + "value": "xlg", + "domain": ".github.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "preferred_color_mode", + "value": "light", + "domain": ".github.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "tz", + "value": "Asia%2FHong_Kong", + "domain": ".github.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%221928eb980316cc-050dbe3db24bd2-16525637-16a7f0-1928eb980321bb8%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%7D", + "domain": ".github.com", + "path": "/", + "expires": 1764674869, + "httpOnly": false, + "secure": false + }, + { + "name": "_gh_sess", + "value": "oUZyg0XEvo5fm%2FC18yV17FMePsGYB4hM9R5q8AgiwOAjTritHx1Ux4jNGjnm7Jaxz99%2FOxD4agIy05dUdG6cnSxRP62NJE7bZxIWFV2W64ekLVCwz7ge2oaRcvVlN4HjVhw5dsl2czpD8Irn%2BZG0Dmw16tH9GZZ4yhaFW5%2Fshmte3DBYsndzLNn4rGje9B3P1IFYyz9sYx23j71xRb9wRjwoLHPYGf4Yp3vRKVAzTp3X6nrjvgr4XGU2N%2BGPH3OYDZQYCIPLckTIEmRg7a0dd2KvU2mfcm%2F%2B9N9%2FNNBFTbKvUhPwWM8kIRpv5WTzU%2FI5Y0qBv71gX2B7nNm%2FtIkWjbWUhgizf%2BpxOHAuhs89sRaicpc9NjasSUISwfxRCoH5evWqVXEifhqQvSU42iR4wkhnRHs%3D--za2vZwPq%2FBJxevj3--tEOzEYASRs0gepJUCIv8Mg%3D%3D", + "domain": "github.com", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/ieeexplore-ieee-org.eproxy.lib.hku.hk.cookies.json b/metachain/environment/cookie_json/ieeexplore-ieee-org.eproxy.lib.hku.hk.cookies.json new file mode 100644 index 0000000..587a1e6 --- /dev/null +++ b/metachain/environment/cookie_json/ieeexplore-ieee-org.eproxy.lib.hku.hk.cookies.json @@ -0,0 +1,103 @@ +[ + { + "name": "hum_ieee_visitor", + "value": "3403d64f-1870-4601-9ff7-e5900074a6db", + "domain": ".ieeexplore-ieee-org.eproxy.lib.hku.hk", + "path": "/", + "expires": 1756280921.91082, + "httpOnly": false, + "secure": true + }, + { + "name": "_zitok", + "value": "6273c58ab3f308a07a711718187500", + "domain": ".ieeexplore-ieee-org.eproxy.lib.hku.hk", + "path": "/", + "expires": 1761551757, + "httpOnly": false, + "secure": true, + "sameSite": "Strict" + }, + { + "name": "AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "179643557%7CMCIDTS%7C20060%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733149186s%7CNONE%7CvVersion%7C5.5.0", + "domain": ".hku.hk", + "path": "/", + "expires": 1767701986.053151, + "httpOnly": false, + "secure": false + }, + { + "name": "ezproxy", + "value": "e1~OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false + }, + { + "name": "ezproxyl", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ezproxyn", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "AMCVS_8E929CC25A1FB2B30A495C97%40AdobeOrg", + "value": "1", + "domain": ".ieeexplore-ieee-org.eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "s_cc", + "value": "true", + "domain": ".ieeexplore-ieee-org.eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".lib.hku.hk", + "path": "/", + "expires": 1765086053, + "httpOnly": false, + "secure": false + }, + { + "name": "utag_main", + "value": "v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:2$_ss:0$_st:1733551853250$ses_id:1733549982472%3Bexp-session$_pn:2%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk", + "domain": ".hku.hk", + "path": "/", + "expires": 1765086053, + "httpOnly": false, + "secure": false + }, + { + "name": "AMCV_8E929CC25A1FB2B30A495C97%40AdobeOrg", + "value": "359503849%7CMCIDTS%7C20065%7CMCMID%7C53777252718039557930823884447397163100%7CMCAID%7CNONE%7CMCOPTOUT-1733557253s%7CNONE%7CvVersion%7C5.0.1", + "domain": ".ieeexplore-ieee-org.eproxy.lib.hku.hk", + "path": "/", + "expires": 1768110053.386389, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/link-springer-com.eproxy.lib.hku.hk.cookies.json b/metachain/environment/cookie_json/link-springer-com.eproxy.lib.hku.hk.cookies.json new file mode 100644 index 0000000..81524ef --- /dev/null +++ b/metachain/environment/cookie_json/link-springer-com.eproxy.lib.hku.hk.cookies.json @@ -0,0 +1,67 @@ +[ + { + "name": "ezproxy", + "value": "e1~OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false + }, + { + "name": "ezproxyl", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ezproxyn", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "AMCVS_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "1", + "domain": ".hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "utag_main", + "value": "v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:13$_ss:0$_st:1733552707246$ses_id:1733549982472%3Bexp-session$_pn:9%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk", + "domain": ".hku.hk", + "path": "/", + "expires": 1765086907, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "179643557%7CMCIDTS%7C20065%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733558198s%7CNONE%7CvVersion%7C5.5.0", + "domain": ".hku.hk", + "path": "/", + "expires": 1768110998.70329, + "httpOnly": false, + "secure": false + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".lib.hku.hk", + "path": "/", + "expires": 1765087052, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/nature.com.cookies.json b/metachain/environment/cookie_json/nature.com.cookies.json new file mode 100644 index 0000000..1fc1e70 --- /dev/null +++ b/metachain/environment/cookie_json/nature.com.cookies.json @@ -0,0 +1,247 @@ +[ + { + "domain": "www.nature.com", + "secure": false, + "expirationDate": 1733745572000, + "hostOnly": true, + "name": "user.uuid.v2", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "\"765b07e9-028b-45d1-8abd-baa7b6c88125\"", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "hostOnly": false, + "name": "Hm_lpvt_d38bce82bcb44717ccc29a90c4b781ea", + "httpOnly": false, + "session": true, + "storeId": null, + "value": "1733140842", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1764676842000, + "hostOnly": false, + "name": "ajs_anonymous_id", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "7e4d00ab-3618-46a2-b0fb-c80b189a0584", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1764676842000, + "hostOnly": false, + "name": "ajs_user_id", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "b1ae7862-b9d6-49c5-a7a5-ad96682ac6dc_SN", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1767700841000, + "hostOnly": false, + "name": "_ga_B3E4QL2TPR", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "GS1.1.1733140776.1.1.1733140841.60.0.0", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1767700841000, + "hostOnly": false, + "name": "_ga_ERRNTNZ807", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "GS1.1.1733140776.1.1.1733140841.60.0.467679787", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1767304843000, + "hostOnly": false, + "name": "cto_bundle", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "5685XF9lQVd1dU4zd2xWRE1uZ3BpQTk3SUVXNkx2bGslMkZwTkZodjRWJTJCcGoyd0JWdiUyQjVlcGkwMVoyWHc4aGxKQkM2N3hyeGI4aFlIRzBZRDNTUTJFb1JYZVhPJTJGMUIlMkZka252a0RPZFdlbld4OU1jaUFrMHN6VDVaREYzSSUyRmFDMEtnb0FoaQ", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": true, + "expirationDate": 1766836842000, + "hostOnly": false, + "name": "__gpi", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "UID=00000fa61060e41d:T=1733140842:RT=1733140842:S=ALNI_Mai2WWloG6liac6hEyJYOSjI3WtCg", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1766836841000, + "hostOnly": false, + "name": "_uetvid", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "e6d7f220b0a411efaac753cc9ddac552", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1764676841000, + "hostOnly": false, + "name": "Hm_lvt_d38bce82bcb44717ccc29a90c4b781ea", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "1733140777", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": true, + "expirationDate": 1748692774000, + "hostOnly": false, + "name": "__eoi", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "ID=1ced890879e93934:T=1733140774:RT=1733140774:S=AA-AfjauQ5O9wXrdBjufrcsmQ-EM", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": true, + "expirationDate": 1766836842000, + "hostOnly": false, + "name": "__gads", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "ID=edf25ef88638a1b3:T=1733140842:RT=1733140842:S=ALNI_MYUdW0s3LG6IOpCKgjBo4gbGPsI1Q", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1740916843000, + "hostOnly": false, + "name": "_fbp", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "fb.1.1733140776577.688163329394303800", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1767700841000, + "hostOnly": false, + "name": "_ga", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "GA1.1.2115119478.1733140776", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1733227241000, + "hostOnly": false, + "name": "_uetsid", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "e6d7f280b0a411efaed4a5384bcc5d88", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "hostOnly": false, + "name": "HMACCOUNT", + "httpOnly": false, + "session": true, + "storeId": null, + "value": "7B6C1DFC72FE250C", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": true, + "expirationDate": 1767700841000, + "hostOnly": false, + "name": "permutive-id", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "7cbbccaf-2079-4e6d-99fc-186a9db51c90", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1767700841000, + "hostOnly": false, + "name": "permutive-session", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "%7B%22session_id%22%3A%221d3a9243-5c93-4975-ae30-63ca2047b7cf%22%2C%22last_updated%22%3A%222024-12-02T12%3A00%3A41.747Z%22%7D", + "path": "/", + "sameSite": "Lax" + }, + { + "domain": ".nature.com", + "secure": false, + "expirationDate": 1764676775000, + "hostOnly": false, + "name": "sncc", + "httpOnly": false, + "session": false, + "storeId": null, + "value": "P%3D8%3AV%3D68.0.0%26C%3DC01%2CC02%2CC03%2CC04%26D%3Dtrue", + "path": "/", + "sameSite": "Lax" + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/orcid.org.cookies.json b/metachain/environment/cookie_json/orcid.org.cookies.json new file mode 100644 index 0000000..7d9939e --- /dev/null +++ b/metachain/environment/cookie_json/orcid.org.cookies.json @@ -0,0 +1,105 @@ +[ + { + "name": "OptanonAlertBoxClosed", + "value": "2024-06-06T05:28:24.993Z", + "domain": ".orcid.org", + "path": "/", + "expires": 1749187704, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "AWSELB", + "value": "CBD1D7FF1216388FA48838CBCA4774FD22800B8FB55A37124459E84B59F34FE231A4AA84F4ACD29C01160D60FB2ABE4D73D23EFBBE355236CF44A8FEE381C3844BD946CF3D", + "domain": "orcid.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "AWSELBCORS", + "value": "CBD1D7FF1216388FA48838CBCA4774FD22800B8FB55A37124459E84B59F34FE231A4AA84F4ACD29C01160D60FB2ABE4D73D23EFBBE355236CF44A8FEE381C3844BD946CF3D", + "domain": "orcid.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "orcidUserConnectionId", + "value": "-114606494029392851", + "domain": "orcid.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "XSRF-TOKEN", + "value": "b64bcd3a-f0f5-407b-9115-a1f5183f3997", + "domain": "orcid.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "JSESSIONID", + "value": "48DD20615AC49336A91F9A3A6F5B1483", + "domain": "orcid.org", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "locale_v3", + "value": "en", + "domain": "orcid.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193871b8e67918-084bddcb7389ac-1e525636-1fa400-193871b8e682d76%22%2C%22%24device_id%22%3A%20%22193871b8e67918-084bddcb7389ac-1e525636-1fa400-193871b8e682d76%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".orcid.org", + "path": "/", + "expires": 1764674617, + "httpOnly": false, + "secure": false + }, + { + "name": "OptanonConsent", + "value": "isGpcEnabled=0&datestamp=Mon+Dec+02+2024+19%3A23%3A37+GMT%2B0800+(%E9%A6%99%E6%B8%AF%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=202310.2.0&browserGpcFlag=0&isIABGlobal=false&hosts=&consentId=71ca593a-5b7c-4963-87cf-52c27440ac95&interactionCount=1&landingPath=NotLandingPage&groups=C0001%3A1%2CC0003%3A1%2CC0002%3A1%2CC0004%3A1&geolocation=HK%3B&AwaitingReconsent=false", + "domain": ".orcid.org", + "path": "/", + "expires": 1764674617, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "cookieName", + "value": "dont%20show%20message", + "domain": "orcid.org", + "path": "/", + "expires": 1764674620, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "closable-unique-name", + "value": "understood", + "domain": "orcid.org", + "path": "/", + "expires": 1764674620, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www-nature-com.eproxy.lib.hku.hk.cookies.json b/metachain/environment/cookie_json/www-nature-com.eproxy.lib.hku.hk.cookies.json new file mode 100644 index 0000000..97326f4 --- /dev/null +++ b/metachain/environment/cookie_json/www-nature-com.eproxy.lib.hku.hk.cookies.json @@ -0,0 +1,75 @@ +[ + { + "name": "user.uuid.v2", + "value": "\"f9248aca-ac13-40e6-8b45-eaeb5fe20825\"", + "domain": "www-nature-com.eproxy.lib.hku.hk", + "path": "/", + "expires": 1740916756.716508, + "httpOnly": false, + "secure": false + }, + { + "name": "ezproxy", + "value": "e1~OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false + }, + { + "name": "ezproxyl", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ezproxyn", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "AMCVS_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "1", + "domain": ".hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "179643557%7CMCIDTS%7C20065%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733558198s%7CNONE%7CvVersion%7C5.5.0", + "domain": ".hku.hk", + "path": "/", + "expires": 1768110998.70329, + "httpOnly": false, + "secure": false + }, + { + "name": "utag_main", + "value": "v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:20$_ss:0$_st:1733553108768$ses_id:1733549982472%3Bexp-session$_pn:14%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk", + "domain": ".hku.hk", + "path": "/", + "expires": 1765087308, + "httpOnly": false, + "secure": false + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".lib.hku.hk", + "path": "/", + "expires": 1765087436, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www-science-org.eproxy.lib.hku.hk.cookies.json b/metachain/environment/cookie_json/www-science-org.eproxy.lib.hku.hk.cookies.json new file mode 100644 index 0000000..f127740 --- /dev/null +++ b/metachain/environment/cookie_json/www-science-org.eproxy.lib.hku.hk.cookies.json @@ -0,0 +1,138 @@ +[ + { + "name": "ezproxy", + "value": "e1~OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false + }, + { + "name": "ezproxyl", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ezproxyn", + "value": "OilZogbDH4iMWPK", + "domain": ".eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "AMCVS_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "1", + "domain": ".hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "utag_main", + "value": "v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:20$_ss:0$_st:1733553108768$ses_id:1733549982472%3Bexp-session$_pn:14%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk", + "domain": ".hku.hk", + "path": "/", + "expires": 1765087308, + "httpOnly": false, + "secure": false + }, + { + "name": "AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg", + "value": "179643557%7CMCIDTS%7C20065%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733559088s%7CNONE%7CvVersion%7C5.5.0", + "domain": ".hku.hk", + "path": "/", + "expires": 1768111888.617908, + "httpOnly": false, + "secure": false + }, + { + "name": "SID", + "value": "\"EUW1ED0CAFs37MFXuY5NakcbWc5Qu\"", + "domain": ".lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "CUSTOMER", + "value": "\"UNIVERSITY OF HONG KONG\"", + "domain": ".lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "E_GROUP_NAME", + "value": "\"University of Hong Kong\"", + "domain": ".lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "__cf_bm", + "value": "dU7HSmMJl6w4XDg.tZSoewkYsxb0bX7Barvg4RvulLw-1733551961-1.0.1.1-7Um2w5HRPO8C06bwjScmRD9BaTZkbArPDfX_e8urefWlKlH50nONZAxnxeL4VbDbHzBBcAY1OzwO5TyNuuCUfQ", + "domain": ".lib.hku.hk", + "path": "/", + "expires": 1733553761.117424, + "httpOnly": false, + "secure": false + }, + { + "name": "AMCVS_242B6472541199F70A4C98A6%40AdobeOrg", + "value": "1", + "domain": ".hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "adBlockEnabled", + "value": "blocked", + "domain": "www-science-org.eproxy.lib.hku.hk", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "cookiePolicy", + "value": "iaccept", + "domain": "www-science-org.eproxy.lib.hku.hk", + "path": "/", + "expires": 1733638777.524329, + "httpOnly": false, + "secure": true + }, + { + "name": "AMCV_242B6472541199F70A4C98A6%40AdobeOrg", + "value": "179643557%7CMCIDTS%7C20065%7CMCMID%7C90810009207598809487163227219398447255%7CMCOPTOUT-1733559578s%7CNONE%7CvVersion%7C5.5.0", + "domain": ".hku.hk", + "path": "/", + "expires": 1768112378.032281, + "httpOnly": false, + "secure": false + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%2C%22%24search_engine%22%3A%20%22google%22%7D", + "domain": ".lib.hku.hk", + "path": "/", + "expires": 1765088378, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.collinsdictionary.com.cookies.json b/metachain/environment/cookie_json/www.collinsdictionary.com.cookies.json new file mode 100644 index 0000000..84d7328 --- /dev/null +++ b/metachain/environment/cookie_json/www.collinsdictionary.com.cookies.json @@ -0,0 +1,103 @@ +[ + { + "name": "_sp_id.a65e", + "value": "a151b61b-0e26-493f-9885-ed0d9579e181.1712037732.1.1712037742..381bfab3-8c2a-4e54-8d4b-44a5c8c997ef..6db53b82-8b6d-471c-b7de-194adad46810.1712037732261.2", + "domain": "www.collinsdictionary.com", + "path": "/", + "expires": 1746597742.393476, + "httpOnly": false, + "secure": true + }, + { + "name": "__cflb", + "value": "02DiuFwNDm462z9fWfJeB58usqeie1xoTDrYZciipwE2x", + "domain": "www.collinsdictionary.com", + "path": "/", + "expires": 1733223382.743499, + "httpOnly": true, + "secure": true + }, + { + "name": "XSRF-TOKEN", + "value": "64a0f62d-dc8f-40cb-8aa2-66e3ad283ad4", + "domain": "www.collinsdictionary.com", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "dictcode", + "value": "english", + "domain": "www.collinsdictionary.com", + "path": "/", + "expires": 1763873478.639472, + "httpOnly": false, + "secure": false + }, + { + "name": "searchPanelOpen", + "value": "true", + "domain": ".collinsdictionary.com", + "path": "/", + "expires": 1733803809.434554, + "httpOnly": false, + "secure": false + }, + { + "name": "search", + "value": "hello", + "domain": "www.collinsdictionary.com", + "path": "/", + "expires": 1763871009.434808, + "httpOnly": false, + "secure": false + }, + { + "name": "__cf_bm", + "value": "xGchgbvqtkoAYddlxWT4VgRmeTZ1qTVmI0hjpRvOj0w-1733201062-1.0.1.1-SDl6_cuGUlqEOSm4oDQpU5rJdha8wEbITIgLoxdY69GgWrSt5GO7nX47Vc2AihzcBY.yS6GZ9qXVfRKEttQyLw", + "domain": ".collinsdictionary.com", + "path": "/", + "expires": 1733202862.320396, + "httpOnly": true, + "secure": true + }, + { + "name": "last_url", + "value": "https%3A%2F%2Fwww.collinsdictionary.com%2Fdictionary%2Fspanish-english%2Fcaminata", + "domain": "www.collinsdictionary.com", + "path": "/", + "expires": 1763873068.316249, + "httpOnly": false, + "secure": false + }, + { + "name": "OptanonConsent", + "value": "isGpcEnabled=0&datestamp=Tue+Dec+03+2024+12%3A51%3A18+GMT%2B0800+(%E9%A6%99%E6%B8%AF%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=202409.2.0&browserGpcFlag=0&isIABGlobal=false&hosts=&landingPath=NotLandingPage&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A1&AwaitingReconsent=false&geolocation=JP%3B27", + "domain": ".collinsdictionary.com", + "path": "/", + "expires": 1759121478, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "OptanonAlertBoxClosed", + "value": "2024-12-03T04:51:18.738Z", + "domain": ".collinsdictionary.com", + "path": "/", + "expires": 1759121478, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193873a93591e4d-05e2471014e6fb-1e525636-1fa400-193873a935a287e%22%2C%22%24device_id%22%3A%20%22193873a93591e4d-05e2471014e6fb-1e525636-1fa400-193873a935a287e%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".collinsdictionary.com", + "path": "/", + "expires": 1764737478, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.jstor.org.cookies.json b/metachain/environment/cookie_json/www.jstor.org.cookies.json new file mode 100644 index 0000000..76b5b9a --- /dev/null +++ b/metachain/environment/cookie_json/www.jstor.org.cookies.json @@ -0,0 +1,146 @@ +[ + { + "name": "UUID", + "value": "3c4dd735-8d33-4fd0-a40f-83d399a0dc46", + "domain": "www.jstor.org", + "path": "/", + "expires": 1740190342.420181, + "httpOnly": false, + "secure": true + }, + { + "name": "_pxvid", + "value": "2dd5c1cb-b670-11ee-9186-3dd546fa1c41", + "domain": "www.jstor.org", + "path": "/", + "expires": 1737166344, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "__zlcmid", + "value": "1O1n3oDgpvApbaQ", + "domain": ".jstor.org", + "path": "/", + "expires": 1764831447, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "csrftoken", + "value": "iyx0piwZPaxHwlEjMlBpPrxRasiSrbVv", + "domain": "www.jstor.org", + "path": "/", + "expires": 1764745045.418981, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "OptanonAlertBoxClosed", + "value": "2024-12-04T06:56:48.465Z", + "domain": ".jstor.org", + "path": "/", + "expires": 1764831408, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "AccessToken", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiIwMTkzOTA3NGY4MTg3OTQ1OTg4NzdiNWQzMWE4NDk3MSIsInV1aWQiOiIzYzRkZDczNS04ZDMzLTRmZDAtYTQwZi04M2QzOTlhMGRjNDYiLCJ1c2VyIjp7ImlkIjoiIiwibG9nZ2VkSW4iOmZhbHNlLCJhZG1pbiI6ZmFsc2V9LCJpYXQiOjE3MzMyOTU0MDksImV4cCI6MTczMzI5NTcwOX0.lIt08pG__dm_kZ3kJUYMw_bK0Ow2kAD8i2Jf8OQA0RM", + "domain": "www.jstor.org", + "path": "/", + "expires": 1733299009.610988, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "AccessSessionTimedSignature", + "value": "1b72fc2754973a3daa1baf1b169dfda5ed067ed4113573f1a1005ce5da900999", + "domain": "www.jstor.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "AccessSession", + "value": "H4sIAAAAAAAA_42U3W7bMAyF38XXUSFKlEXlLgv2f7Gi7S62oRgYSc68uXHg2B26ou8-KW4adxu6GoEhyYfi4Scqt8Uw1KGYF9pjCFYbQUFrgVWQglFWgnTQzrEMHstiVtTbpAVLJ2DsSfop1Hk17yDBaSetQYzKuqpy1q-YfbBKckWGkm47FWJFQNahcUTWrkzQwITOQhJ2e2HFu765ESFWPDT9nBQiWXIVeAmMUCkGUtJrU6UX5AQN9ylOSYUClJB4Icu5sXOJJ1YTgrJkPmfZ8KRMQ7mX7Z5WmVHV-2Led0OcFdfc7H1PQ9whJAW4Mm2dQ7jvu10xvy2WyyRfnKel5UUatTv-wV-VzfNPaf7uNI3OlofReda8vXizeL8o7tIuQ_9t4X2fdvpyW_Q325g_b3Z93Q993W4yx7aJ-fPlrLiqN_VV_Su-anh9MLx3CyVamaGMM5BSKq3LfAxxvxSjX1HJLGLJTqAMJNhqKYB1NICUzzvH3zuoHznwXQyjwWe3mXhmwoR7iM9v3Xt7L7r25y52p1x39WZdjBhHIHeXd7MJyFBf12HgZsKx-Hj-8qx4iiai1gpB6iNRQ45caj59JOqgghBABBmsQDBOMJUovFaByKtQAkyInsZu124-Jtd_Az3kwwmQdduumyhAOmegTHfMSJsOGYg0HUF8WOTC_6g_lcYPLTWemEWrFD54V-nmhtTNwliuBAZk4TywkBwlQVm5kswxyVlcj33wL2DNfQJNWtGk3m4ftK83H8hIMbXlEaCLJG2IXlTargR6awR7SjfOAzk2fiVZHk287tph-6QHNcm-zuoxeaLxffzL-s_zGONvRDIvs1UFAAA", + "domain": "www.jstor.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "AccessSessionSignature", + "value": "78ecce97f2a2de3ffb4af7c87424885165a11fe7d2e29bf960edff5c48167a35", + "domain": "www.jstor.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "_pxhd", + "value": "xbR2aQnYKWrHiFs3JKCUojuB1cOv3M4cNFTMhSdjqxqtIsdgsSi8ehBRY7OxVo9kz4W0VVvgcv6IyY0Ta0SJPA==:medf83pfHV213oGcCOKWmgsZc4Kr8q2rEC2GEWBrpMBibA5DYuL7eKs10ANKfVD8qmvJUfcosZrIkQ83XUVKtKmZa4Y6lK04fy46yN254wo=", + "domain": "www.jstor.org", + "path": "/", + "expires": 1764831425.214494, + "httpOnly": false, + "secure": false + }, + { + "name": "pxcts", + "value": "f8fbc8a1-b20c-11ef-a65c-4947163db9b8", + "domain": "www.jstor.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "_px2", + "value": "eyJ1IjoiZjhjZDk4ZjAtYjIwYy0xMWVmLWFkYzctZGQxMTkxM2QxNDlkIiwidiI6IjJkZDVjMWNiLWI2NzAtMTFlZS05MTg2LTNkZDU0NmZhMWM0MSIsInQiOjE3MzMyOTU3MjY4NjgsImgiOiIyMTFhMjMyMTRlZmQwOWE5OTNhZjlmODU2MDU1ZmI1N2U4MTcwY2RmNDNlZjM0MGFhYzg1Yzk2NzQ0NmVjOWI5In0=", + "domain": "www.jstor.org", + "path": "/", + "expires": 1733296026, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ReferringRequestId", + "value": "fastly-default:258d2927284d8837614cc35365d11c1d", + "domain": "www.jstor.org", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%221939074fba7618-0aaf84ba8a423a-1e525636-16a7f0-1939074fba82966%22%2C%22%24device_id%22%3A%20%221939074fba7618-0aaf84ba8a423a-1e525636-16a7f0-1939074fba82966%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%7D", + "domain": ".jstor.org", + "path": "/", + "expires": 1764831445, + "httpOnly": false, + "secure": false + }, + { + "name": "OptanonConsent", + "value": "isGpcEnabled=0&datestamp=Wed+Dec+04+2024+14%3A57%3A25+GMT%2B0800+(%E4%B8%AD%E5%9B%BD%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=202303.1.0&browserGpcFlag=0&isIABGlobal=false&hosts=&consentId=e6c4a174-c538-4f9f-8632-f5f8e9ff428d&interactionCount=2&landingPath=NotLandingPage&groups=C0001%3A1%2CC0002%3A1%2CC0005%3A1%2CC0004%3A1%2CC0003%3A1&AwaitingReconsent=false&geolocation=JP%3B27", + "domain": ".jstor.org", + "path": "/", + "expires": 1764831445, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.ncbi.nlm.nih.gov.cookies.json b/metachain/environment/cookie_json/www.ncbi.nlm.nih.gov.cookies.json new file mode 100644 index 0000000..7416a53 --- /dev/null +++ b/metachain/environment/cookie_json/www.ncbi.nlm.nih.gov.cookies.json @@ -0,0 +1,111 @@ +[ + { + "name": "ncbi_sid", + "value": "015E11D6531E8483_1525SID", + "domain": ".nih.gov", + "path": "/", + "expires": 1764675079.027761, + "httpOnly": false, + "secure": false + }, + { + "name": "pmc-frontend-csrftoken", + "value": "L3uvd1o5Uu2efxgCXWDzwxfDTl5QIFDR", + "domain": "www.ncbi.nlm.nih.gov", + "path": "/", + "expires": 1753769052.705813, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "ncbi-login-route", + "value": "google", + "domain": ".ncbi.nlm.nih.gov", + "path": "/", + "expires": 1741001395.405247, + "httpOnly": false, + "secure": false + }, + { + "name": "PRESERVTARGET", + "value": "%2FtYTXpgzJne16bwfb4ZN2lGInyYoZNk58TVbSvhIR0njSJplCp65%2BiF2SZAktvmmznDxgJBJhBCH%0ANoo2je1cMk0RXykLSXa4UwW7u0%2B%2Fc1X7WzHdCi209NjSVDPLNfOmFzmtz50Uuh6EfD95OQ%2BYQ2B%2B%0Aq7BP3es9s8ArLlZd9XW7NS72Ulu8cigULF%2FZADnu%2FPZf8DmPLOXuV6xWf0fqcNlZXwWhiCjrPJiU%0AU594rDm20QBWFe5y0VjWXnJtzYm7uSPkWDQYJ8htbKyWwjn4aG0xcYfTBSBUTOi9A%2Bo1BnUPHLIi%0A8V9%2Fi7S2i2vLCCwVTCSGS0pctKKWZRmzEmP9NB4rA167%2FSMuyX6ezHZNUyztiKaga84g5monl5bT%0AjNlmWeBFQV90piriK2wjmey3mIoTu2eJyDi%2Bx%2FO7pwMTfeiU2WXZ5h3U4kRBxw%2FR6%2FrCMYtVrzXp%0A%2FexiuMJDHQmiDPowP8dxw97tgs353jnBRGe8jpoCPoPG2hywQnwXtxW8SjWp19yTypxVFl4KnD1e%0A5aoPyq%2F7tPDRPbW7UikYuihFvX0mD1TH7A0G9Bk%2B36y%2F7jL8oW7OArzEbESjcx2aVRL%2B3VqzX1Oc%0AZcFWXfVarYgckE8EeyNwFwhPDoASs2T4SVNAJAQ38A0bYzCAxc6mQLqADqesOuuveClDDgB8WITg%0A1QnE32rGsLz37nzAQ89V", + "domain": ".nih.gov", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "NIHSMPROFILE", + "value": "9i9xFyZxcZ3DeEBWJ1M%2B1ygJsb2LhWqfanAC3W20fjIpeXaMrRQ%2F9L3R6DUjYzq5%2FqUDVLhYywfn1%2BT0RJpzID8efN8zNczLDneXLM7waIbhTdfwbIh%2BCnmN0fucHtqYylLU1altZcOhSRTow47jYwyEUFsmJ6kz3T1%2BnZWx3Ol0zaFC8onzZCtl4YHbCxMJVbHYuMcGM4f4YxpiDefQvlDdwY1soBI8z9nvb%2BKMs1B3GgplTzyllWIbC1RHxGLvdlNaz8Zlzw6MU4B3piqrAiCWAvoMF3%2FSShIchSdP0utP%2BMROhcGaoWBU%2FKfkjjDc3lHCPfydE%2F895aasf6uvrL7uccokjb6HxdVs0FA%2FHxfBNJXURVRSpRl9%2BPOd9%2FOOlXQQqhBh1FyAZs6WIxDvLhegMvLITcLh7ahcahuJnoeImSla4b4kK0Ayy6736mJCa0hhXUzGjab4Yhht11PliHlAlh4wLEXj0Dp7X9pj7Ws1%2BdCx8QZyiTWrbauCQJtS1hNXn%2Blg4BoQ2sIq%2FxltuA%3D%3D", + "domain": ".nih.gov", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "NIHSMSESSION", + "value": "ZITCB6DlveT31D7iO+eVBnrLBxVxrCJGSz/5hVXNSYwvjuPpvd0O7dD6XqsFf6LKdJXktiX+hhtPWtsNok2mgiSvcpYTBHJxHVefyBt+KiLTVm12lBwYTkx9Gb9OxZNQsMS/Hvoj45/ShvSKut3d7c8e2cEhtjA7DWjHEYHj0tuk3fEoQ4t0UAWkhj6bFt5Vo5tm6dyqen/1EH2o6cBZrVSLbk67LctlbRyV4pc5099Il2lTMPo6LqtyVI1AC/bcSioge+LqDbpDiuP4NOF3EPj/yFSWvBz76/bqQ0Hu5oRGCC1zVPhSGJ1iukio91F6IfYr5vTtMrN00vSuxHRnxj0BYCEuGKtCeNDohuGAZvQVkjhc1aI53oWFu8UNHZvYA+Xo2lpLZUOT+2pkjk1Z/WpAPzE8L+O6mRjwaq8/2b3rUS8gq4Y/2vQYhqmNxeHOBsM01BhpJdRF3Urp3dnIIYakYeCgzC/3Kww+p8ImsBTjvXDO1ivVEjKB4AdnCsRdLpGPszS9xF7439aXXCWHDItM4Wl458T12QWeV+DXiiwzD/kis1QQBWibzsZOzo9KDM3oapKa8I2ouKXb797Z7s+eLR1+Z10lyWZuNVLLZK5ckFT5riayLYeT8+IjFYVER/nfDzm3KpgVPnep/k4DANpDgAOK78iuTv3sBndNMoKrXz2qCZtfi3/gLGZTKcOy90meluFZy9+iLyb+M01VBWuDp/v0a2jSdsJPVmgUQqz7hLVvtc4KpMfiDhfxXGMQnaieP9jREFK3NutAiUrkjS96WS3v5eLK80o/aG1j5IsAvxU/0lMnEri3Yz6Qw1f0ymS6giKiFIUBRUWGXcm5S1qCjwL5GiU71r3nOcaC8T9T1pVLf1R558WqH6Ha95aJVqN6CnEHo8TsZl25lb5tlJgbgb2OFvLSrbUZwuM3R5mA9zP7ciQBywxNm7xFO8sX8QQk0bRhrhgk458KE72Ci/8lhZmvpYy5aqbI4OtaLkuFuu3lX3c7/LsGt+iTFkO6eDSS4CFEnFqg3W5Glvs7WZkTasVI7L0mN0q8DCPXaIDFVPlXEA0shxZuB6Iz+mx4MshQHwY9fMRSWB7gOF5cHjHYUBLfHT/gOwl35rkoJfVf9ikpcgT88mJyk9KTQpVM+CZAGUFDbgHsRqA0jPE19sBum3cqaA6fzh9AnWXfOlAY5KNDdTB4yip4UakCXWsiXVng0GfQ7KvxAguC59L7iZyFjdsIDESi7ZozcPHOpFZleeAU3yFTvMGHmO3G3RFrxyIGCwgWehus3YCqQxZPSE6+yLjXeXTqhqgk0kxcV/MlOFgzMcAhgKEYJS045sLZsmohsIVLV0ONY4uqogSxd3YUzc0WImi1mYdNbzYwbX5tPngah4SK61Nia8Z6xjZuKfXnxNFEkNneezPoPy97Hvd+9wzI+DkU5sa844DzGxeSY/ySE3DTtpowf440r5rX", + "domain": ".nih.gov", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "MyNcbiSigninPreferences", + "value": "O2dvb2dsZSY%3D", + "domain": ".nih.gov", + "path": "/", + "expires": 1740915025.611341, + "httpOnly": false, + "secure": false + }, + { + "name": "ncbi_prevPHID", + "value": "CE88342C74D8A32100000000003B0036", + "domain": ".ncbi.nlm.nih.gov", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "WebCubbyUser", + "value": "3GX25AI24DLUXL8LVDJFIVTH6LJRZBE1%3Blogged-in%3Dtrue%3Bmy-name%3Djiabintang77%2540gmail.com%3Bpersistent%3Dfalse%40015E11D6531E8483_1525SID", + "domain": ".nih.gov", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "WebEnv", + "value": "1D7wJH%40015E11D6531E8483_1525SID", + "domain": ".nlm.nih.gov", + "path": "/", + "expires": 1733167826.636953, + "httpOnly": true, + "secure": true + }, + { + "name": "ncbi_pinger", + "value": "N4IgDgTgpgbg+mAFgSwCYgFwgAwEYCsAorrgCIBs+AzLoQBwAsdAnLgOxU1XPZt354AygElSIAL5A===", + "domain": ".ncbi.nlm.nih.gov", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22193872246ca871-06560f33a3902-1e525636-1fa400-193872246cb267c%22%2C%22%24device_id%22%3A%20%22193872246ca871-06560f33a3902-1e525636-1fa400-193872246cb267c%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%7D", + "domain": ".nih.gov", + "path": "/", + "expires": 1764675078, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.pnas.org.cookies.json b/metachain/environment/cookie_json/www.pnas.org.cookies.json new file mode 100644 index 0000000..6fff493 --- /dev/null +++ b/metachain/environment/cookie_json/www.pnas.org.cookies.json @@ -0,0 +1,74 @@ +[ + { + "name": "MAID", + "value": "+O8mvi2rAtZrnJqF+2cRIQ==", + "domain": ".pnas.org", + "path": "/", + "expires": 1759078802.198648, + "httpOnly": true, + "secure": true + }, + { + "name": "MACHINE_LAST_SEEN", + "value": "2024-12-02T09%3A00%3A01.960-08%3A00", + "domain": ".pnas.org", + "path": "/", + "expires": 1759078802.198711, + "httpOnly": true, + "secure": true + }, + { + "name": "JSESSIONID", + "value": "CEDD494D14F0052C199B1D7AE667EF42", + "domain": ".pnas.org", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "__cf_bm", + "value": "YJQBFxCTLG1d3d9R0fVmwlmAgP9kqVl3zwf02v.COMQ-1733158802-1.0.1.1-tLccs1jD809lM7_9Bhy35sLQdM1TaakBEYvhdDEi1w9cWJS9IGjovTwKGdYQtse6_rWkJNYt._LsHQI2WCwDUQ", + "domain": ".pnas.org", + "path": "/", + "expires": 1733160603.504839, + "httpOnly": true, + "secure": true + }, + { + "name": "cookiePolicy", + "value": "accept", + "domain": ".pnas.org", + "path": "/", + "expires": 1767718816.994233, + "httpOnly": true, + "secure": true + }, + { + "name": "connect_auto_login", + "value": "true", + "domain": ".pnas.org", + "path": "/", + "expires": 1735750875.510643, + "httpOnly": true, + "secure": true + }, + { + "name": "PLUID", + "value": "l8nplDdx7mN9Xh4lErbknypxfmo=", + "domain": ".pnas.org", + "path": "/", + "expires": 1759078875.9476, + "httpOnly": true, + "secure": true + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%221938850d07a6d2-0446945abb35c6-1e525636-16a7f0-1938850d07c132a%22%2C%22%24device_id%22%3A%20%221938850d07a6d2-0446945abb35c6-1e525636-16a7f0-1938850d07c132a%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D", + "domain": ".pnas.org", + "path": "/", + "expires": 1764694876, + "httpOnly": false, + "secure": false + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.reddit.com.cookies.json b/metachain/environment/cookie_json/www.reddit.com.cookies.json new file mode 100644 index 0000000..bdd2a6c --- /dev/null +++ b/metachain/environment/cookie_json/www.reddit.com.cookies.json @@ -0,0 +1,122 @@ +[ + { + "name": "edgebucket", + "value": "lyxOSFtqXnsQEn0H9C", + "domain": ".reddit.com", + "path": "/", + "expires": 1738484736.570167, + "httpOnly": false, + "secure": true + }, + { + "name": "loid", + "value": "000000000r0luy1m5t.2.1703924736813.Z0FBQUFBQmxqOVFBLVZ2UHJIRWswQW4zQnlJZGtYU2ZBS1dSQlpncW1hQ2o2TmVLMk12QkFYRlBEMFpaOGpqTndjcXhuQjhFS3hhc0dSMXRyZ1o4SUg1cTZvSTNHejk5NW5xdlRTRUtfeUdSU250alJhQTFDY3RSeDJrekdnWG90bk1CWmhhc2hlMWU", + "domain": ".reddit.com", + "path": "/", + "expires": 1763278221.514142, + "httpOnly": false, + "secure": true + }, + { + "name": "csv", + "value": "2", + "domain": ".reddit.com", + "path": "/", + "expires": 1738484736.97362, + "httpOnly": false, + "secure": true + }, + { + "name": "g_state", + "value": "{\"i_l\":0}", + "domain": "www.reddit.com", + "path": "/", + "expires": 1744270240, + "httpOnly": false, + "secure": false + }, + { + "name": "pc", + "value": "nd", + "domain": ".reddit.com", + "path": "/", + "expires": 1760254304, + "httpOnly": false, + "secure": true + }, + { + "name": "__stripe_mid", + "value": "104997eb-5535-4dd4-a71a-5a7f697b8a4650cc1f", + "domain": ".www.reddit.com", + "path": "/", + "expires": 1761300348, + "httpOnly": false, + "secure": true, + "sameSite": "Strict" + }, + { + "name": "t2_r0luy1m5t_recentclicks3", + "value": "t3_o9s5iv%2Ct3_1ga952r%2Ct3_1eefr4x%2Ct3_1fs5q5b", + "domain": ".reddit.com", + "path": "/", + "expires": 1762676289, + "httpOnly": false, + "secure": false, + "sameSite": "Strict" + }, + { + "name": "reddit_session", + "value": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1NjpsVFdYNlFVUEloWktaRG1rR0pVd1gvdWNFK01BSjBYRE12RU1kNzVxTXQ4IiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0Ml9yMGx1eTFtNXQiLCJleHAiOjE3NDg4MzgwNzkuNzc0MDEzLCJpYXQiOjE3MzMxOTk2NzkuNzc0MDEzLCJqdGkiOiJyT3l6V2hFUmtxNDA0b0YzX1FSSVR3R240Y0gzS0EiLCJjaWQiOiJjb29raWUiLCJsY2EiOjE3MDM5MjQ3MzY4MTMsInNjcCI6ImVKeUtqZ1VFQUFEX193RVZBTGsiLCJ2MSI6Ijc2MjE3NTUxMDk3OTY5LDIwMjQtMTAtMTJUMDc6MzA6NDEsYWU3Y2U5ZDdiMjU3OGQ3MWVmMTEwYjFiNTc2NTU2NmNmYzJkNDljNiIsImZsbyI6Mn0.NUhfjOfX7pWC5FFUfJvTw0Ts8b0ZICrmmg_Eh4_O6hvnqEH5UHVjjwtS7YNGyxTRv5k0AJTx-GW5CWTUJvhciPOYokV1iM4RirTbijGfqyvSlbl7YIARX8gUMrm6X2TmFvHmQHem4S-0YcrhvBakEXb2TAk0e4KLiPBS6jbEa5c4EoIkp8PjvFVkWZhY_FMge6SxAmPlx2Xksk2c_9s_rJ-UTZkyOWP5ighh7TmA3B_0ZWEPRme7yDBtV-AJ1UH533suaBukxD_-O3afm1AtjMiQAygZ4tl78T7unCopK1_c8PGiunpfLCMTdTTb07NnI2_fo-8AfARF-9O1MPdYFQ", + "domain": ".reddit.com", + "path": "/", + "expires": 1748838078.887127, + "httpOnly": true, + "secure": true + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%221929b3d4cbd13e0-01d450fe6d3301-16525637-16a7f0-1929b3d4cbe2033%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%7D", + "domain": ".reddit.com", + "path": "/", + "expires": 1764735681, + "httpOnly": false, + "secure": false + }, + { + "name": "reddit_chat_view", + "value": "closed", + "domain": "www.reddit.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false + }, + { + "name": "token_v2", + "value": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1NjpzS3dsMnlsV0VtMjVmcXhwTU40cWY4MXE2OWFFdWFyMnpLMUdhVGxjdWNZIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNzMzMjg2MDgxLjgwMjc1NywiaWF0IjoxNzMzMTk5NjgxLjgwMjc1NywianRpIjoiTkFrUGZVTVVwRGZ1SmFNbjZkV3dqbnB4U0REMEFnIiwiY2lkIjoiMFItV0FNaHVvby1NeVEiLCJsaWQiOiJ0Ml9yMGx1eTFtNXQiLCJhaWQiOiJ0Ml9yMGx1eTFtNXQiLCJsY2EiOjE3MDM5MjQ3MzY4MTMsInNjcCI6ImVKeGtrZEdPdERBSWhkLWwxejdCX3lwX05odHNjWWFzTFFhb2szbjdEVm9jazcwN2NMNGlIUDhuS0lxRkxFMnVCS0drS1dFRld0T1VOaUx2NTh5OU9aRUZTeUZUUjg0M3l3b2thVXBQVW1ONXB5bFJ3V1prTGxmYXNVS0RCNllwVlM2WjIwS1BTNXZRM0kxRnowNk1xbHhXSHRUWW8zSnBiR01LMnhQanpjWnFReXF1eTZsTVlGa29uOFdMZnZ5Ry10WS1mN2JmaEhZd3JLZ0tEX1RPdUZ4d1lfSERGSGJfbnByMGJGMndxTDNYZzlRLTEtTjI3Yk5tb2RtNV9WelB2emFTY1RtRzVpZll2N3QtQ1IxNDVIbVpVUWN3WWcwX3lyQWo2X0N2T29ES0JRV01KWWhQSTVBcmwyX19KZGl1VGY4YXR5ZC0tR2JFVFdfNHJSbW81eExFb1VfajZ6Y0FBUF9fWERfZTR3IiwicmNpZCI6Ill6STUzaXNuVVRQUm42M3NQbjRSNFBNbVdOcjE4SU1uUU93T2VfaHFuem8iLCJmbG8iOjJ9.F_24jXHdZDXCmMx4aubrjT94AtnYDzD7eg7SjV1Rwa6ymrvrXW8uZnIqgqVkHJio-mZW_JsxlSKzlIDMJ_lrWtgxFHhgGFWnWkS-raKhYrrQt3gwN-C5VPc3iF-1pVUaf0Jf0gX1aYyvdtRD48rRd8sjCoAwHcGiNH8B7abUPN8JJuQcAEH2GzYSc9Zarb0jANLyw7fGdTdWXfWjUXjy33alItwyhMVcgCIXlVf5wlayRBsRXS_ObpKiril2BuAgCrrVuOWDdflpi58FTA0pki4F0wTdcJfORP9yjZ_L7AJUXhXhswx5Lcf0kTU1hFy4RqFRRd95Q0xZg7Yj2uIC7w", + "domain": ".reddit.com", + "path": "/", + "expires": 1733286081.968748, + "httpOnly": true, + "secure": true + }, + { + "name": "session_tracker", + "value": "hhjeikkilpgneqelrj.0.1733199684513.Z0FBQUFBQm5Ub2RFN25sX3lzSVRyLUFxSEtEcmozdW1BbGdaQUtmZUZCMVNteW1PSGhxRHNEdkhrR0lneUNCTUgzUlE2QkdUb2tRRHVyTnNNNlpnOV80TVZzd2hlSzFCRUtLLVZmREYwejhDSXIyX1Q4T04yMnlrSGJkMlVla2h5R1huYnFGOUFleGU", + "domain": ".reddit.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true + }, + { + "name": "csrf_token", + "value": "04b1407f1ebd23723b261a469d4dac84", + "domain": ".reddit.com", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Strict" + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.researchgate.net.cookies.json b/metachain/environment/cookie_json/www.researchgate.net.cookies.json new file mode 100644 index 0000000..fe06670 --- /dev/null +++ b/metachain/environment/cookie_json/www.researchgate.net.cookies.json @@ -0,0 +1,133 @@ +[ + { + "name": "did", + "value": "iXxy5Y0Lo7cY1c90Riq7yRkq4VdJdrz9F1T7N0fqKOFKoaNbUas5EeVE3Oo7jl4M", + "domain": ".www.researchgate.net", + "path": "/", + "expires": 1764671442.13171, + "httpOnly": true, + "secure": true + }, + { + "name": "ptc", + "value": "RG1.8779880038783811042.1696569455", + "domain": ".www.researchgate.net", + "path": "/", + "expires": 1767695442.13179, + "httpOnly": true, + "secure": true + }, + { + "name": "pl", + "value": "wbbzs5zjXc51gyzsE5huVpQxOu7nxEnyZDiQcl7KEpwjXmoGTD064RedCDxz696tPbB38xaV8xe1oIJFEAAanE6P4MKWta1rUAq6iCLxSGnCYhfeB9JhdfN5cz70vq1R", + "domain": ".www.researchgate.net", + "path": "/", + "expires": 1764671442.13175, + "httpOnly": true, + "secure": true + }, + { + "name": "didomi_token", + "value": "eyJ1c2VyX2lkIjoiMTkxOTNiYWQtMDhhMS02Njg5LWE4NTUtMzAwN2VhYzA5MjAwIiwiY3JlYXRlZCI6IjIwMjQtMDgtMjdUMTI6MDY6MTkuNTMwWiIsInVwZGF0ZWQiOiIyMDI0LTA4LTI3VDEyOjA2OjI4LjQ4M1oiLCJ2ZW5kb3JzIjp7ImVuYWJsZWQiOlsiZ29vZ2xlIiwiYzpnb29nbGVhbmEtNFRYbkppZ1IiLCJjOnBvc3Rob2ctQmpKeEZkRjkiLCJjOmRpZG9taSJdfSwidmVuZG9yc19saSI6eyJlbmFibGVkIjpbImdvb2dsZSJdfSwidmVyc2lvbiI6Mn0=", + "domain": ".researchgate.net", + "path": "/", + "expires": 1756382788, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "euconsent-v2-didomi", + "value": "CQEAuYAQEAuYAAHABBENBDFgALHAAELAAAYgF5wAQF5gXnABAXmAAAAA.djgACFgAAAAA", + "domain": ".researchgate.net", + "path": "/", + "expires": 1756382788, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "dmd-tag", + "value": "29c64880-8f9e-11ef-aabb-5bb25e381cbc", + "domain": ".www.researchgate.net", + "path": "/", + "expires": 1767695450.916031, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "sid", + "value": "TCoR8Z544diaoyMwqLA8X9TS7HfkN6SpWX1ropwuTqxNP2j10bZlYc7YIVBKPyVcfem0NqbdChel00sIOkOeF5GtceLW00ubTrpe4dAD5xLJ81ocWo9svlf6J9gynIf6", + "domain": ".www.researchgate.net", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "cili", + "value": "_2_MjZhMmQ5N2FmMzgyNmJkYTJiZjc2ZjE0ZjRmMDUxYjMzYjJkZmQxYWY0Njg4Nzc4MTM4ZDE1MGVmNWRhYTc0Nl8yOTU3MzAzNjsw", + "domain": ".www.researchgate.net", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "cirgu", + "value": "_1_SaiBmdG2CNFFXfi8YLMIIkC%2BD5oxd2H0CqLqIhkm0V8y0ncsQEoe%2FGUZOhACuKUy5feYcA%3D%3D", + "domain": ".www.researchgate.net", + "path": "/", + "expires": 1764692368.131654, + "httpOnly": false, + "secure": true + }, + { + "name": "_cfuvid", + "value": "GehzRPPGzCCv.nPAiw9L7tRQCAi.hQAAinF5RqUksz0-1733135442009-0.0.1.1-604800000", + "domain": ".researchgate.net", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true + }, + { + "name": "dmd-sid4", + "value": "{%22id%22:%227cce57f0-b098-11ef-924a-5d9117969fc2%22%2C%22timestamp%22:1733135444000%2C%22lastUpdate%22:1733135450000}", + "domain": ".www.researchgate.net", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%22192aea49fbae19-0a0ceb4f539c6b-16525637-16a7f0-192aea49fbb210e%22%2C%22utm_source%22%3A%20%22researchgate%22%2C%22utm_medium%22%3A%20%22email%22%2C%22utm_campaign%22%3A%20%22re442%22%2C%22utm_content%22%3A%20%22re442_up_pb_hnsg_nnaas_p110%22%2C%22utm_term%22%3A%20%22re442_up_pb_hnsg_nnaas%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24search_engine%22%3A%20%22google%22%7D", + "domain": ".researchgate.net", + "path": "/", + "expires": 1764671451, + "httpOnly": false, + "secure": false + }, + { + "name": "__cf_bm", + "value": "X2xX99VpHc3Xm_TRnIRhN7Wp533PLOt.311xMWQYn28-1733136361-1.0.1.1-qskSGITd3dmHUV3UswCd8O6ygg3jdBOF9Wz1PxlPQq66VUx0blvFKst7fH33pTnkl2W1VBjBjlF8CgPVLesusQ", + "domain": ".researchgate.net", + "path": "/", + "expires": 1733138161.246979, + "httpOnly": true, + "secure": true + }, + { + "name": "ph_phc_ma1XTQyee96N1GML6qUTgLQRiDifnRcE9STiHTZ0CfZ_posthog", + "value": "%7B%22distinct_id%22%3A%22AC%3A29573036%22%2C%22%24sesid%22%3A%5B1733137066914%2C%22019386ec-50e4-79f9-9b7b-d8ed5854cf9f%22%2C1733135454436%5D%2C%22%24epp%22%3Atrue%2C%22%24initial_person_info%22%3A%7B%22r%22%3A%22%24direct%22%2C%22u%22%3A%22https%3A%2F%2Fwww.researchgate.net%2Fprofile%2FJiabin_Tang4%2Fpublications%3FeditMode%3D1%26sorting%3DrecentlyAdded%26utm_medium%3Demail%26utm_source%3Dresearchgate%26utm_campaign%3Dre214%26loginT%3DeX2d52IqLj-iYd58KHUvU88w6Ub-0Rjh_XCM-6tyfVf3Goy3Bf0swonajNlIbQg6gax3uaL6ulhi_ik9eMs%26pli%3D1%26utm_term%3Dre214_x%26utm_content%3Dre214_x_p2%26cp%3Dre214_x_p2%26uid%3D2aJ4s09Uf8rvZLKbNnk9UiFVrlZcTRXt51G2%26ch%3Dreg%22%7D%7D", + "domain": ".researchgate.net", + "path": "/", + "expires": 1764673066, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + } +] \ No newline at end of file diff --git a/metachain/environment/cookie_json/www.youtube.com.cookies.json b/metachain/environment/cookie_json/www.youtube.com.cookies.json new file mode 100644 index 0000000..a46a633 --- /dev/null +++ b/metachain/environment/cookie_json/www.youtube.com.cookies.json @@ -0,0 +1,173 @@ +[ + { + "name": "PREF", + "value": "f7=4100&tz=Asia.Hong_Kong&f4=4000000", + "domain": ".youtube.com", + "path": "/", + "expires": 1767698936.819909, + "httpOnly": false, + "secure": true + }, + { + "name": "HSID", + "value": "AuvRBV-Q9GEReACoE", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.11147, + "httpOnly": true, + "secure": false + }, + { + "name": "SSID", + "value": "AHum7OsxEOAD-Ibp4", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111503, + "httpOnly": true, + "secure": true + }, + { + "name": "APISID", + "value": "FRZgwlTWYfVE-B2B/A7FrDbUAZCyMOb6ue", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111539, + "httpOnly": false, + "secure": false + }, + { + "name": "SAPISID", + "value": "mPwGiY6zyqe8IurQ/An35YGZHoL6Ged8z8", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111575, + "httpOnly": false, + "secure": true + }, + { + "name": "__Secure-1PAPISID", + "value": "mPwGiY6zyqe8IurQ/An35YGZHoL6Ged8z8", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111613, + "httpOnly": false, + "secure": true + }, + { + "name": "__Secure-3PAPISID", + "value": "mPwGiY6zyqe8IurQ/An35YGZHoL6Ged8z8", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111646, + "httpOnly": false, + "secure": true + }, + { + "name": "LOGIN_INFO", + "value": "AFmmF2swRgIhALZXJQRg7B6iILvfx41A-mHr8rh7RMGV3cNkppAPlxxvAiEA38fh68Ct3o4p-ywc1zHhWZxrJ5Dpcd0AcsMp4RZONUs:QUQ3MjNmeTdGelpVWXZuN1RTeUMzQkYwNEZhVXY1emtGT1pycWFmWC1LU0txanZReHBLaDRxVHJEZGRyOV8wajFIajdyLWYwcE1rSFZfRVlBM3BNaXZSQlMtLVlLR3RmSURpQjhKRlJaU0xJcHQySmZVNUp6eWFFak9rbE4yWDg5WGdjSkM4QjJhcFRYZTAwVEF6a3RPUzhsSzV0R05YWkVB", + "domain": ".youtube.com", + "path": "/", + "expires": 1766493681.602721, + "httpOnly": true, + "secure": true + }, + { + "name": "SID", + "value": "g.a000qQgZRYfZgm7ZwgtAwFbDQEBvZXpLRLlzTLW27vxVIiEvbgDsOzHaI33jRkuMY7qPd2ZFEAACgYKAUMSARYSFQHGX2Miunh4qpdUynkY2PXqPSvzGxoVAUF8yKqW-lS90SUYH-cprrTRIWDZ0076", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111199, + "httpOnly": false, + "secure": false + }, + { + "name": "__Secure-1PSID", + "value": "g.a000qQgZRYfZgm7ZwgtAwFbDQEBvZXpLRLlzTLW27vxVIiEvbgDsSADU_B-86vJipiHTuQxp3gACgYKATgSARYSFQHGX2MinsKT7OE3L_H9SEzrOgndQRoVAUF8yKqLdTsMTC89NTlwps9-uRSu0076", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111393, + "httpOnly": true, + "secure": true + }, + { + "name": "__Secure-3PSID", + "value": "g.a000qQgZRYfZgm7ZwgtAwFbDQEBvZXpLRLlzTLW27vxVIiEvbgDsvr3qlDZAuj7i33G332SpFwACgYKAekSARYSFQHGX2Mi9DYKrElENMuHNeaGctBCdBoVAUF8yKrmoWXREkhXzva2a6J3B2ps0076", + "domain": ".youtube.com", + "path": "/", + "expires": 1766544331.111431, + "httpOnly": true, + "secure": true + }, + { + "name": "NID", + "value": "519=I7v3EQhK2PGhnXSOCZyz5QYVGbJ383LEPRVqShP2G15ip3zj5VjyDJWEAlJtS3ifC0qs-7cxlwk_vCTqntg_LWW_hfONxTqG6JVJ8JvpMEr2eM_Fqb9n8nVbc_YNrwSIUEorM5N5FUoZmW2u4Qksi_a0-ssHJWsOBEwdxpDONaY", + "domain": ".youtube.com", + "path": "/", + "expires": 1747984059.767459, + "httpOnly": true, + "secure": true + }, + { + "name": "mp_94085d51c4102efbb82a71d85705cdcf_mixpanel", + "value": "%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%221928f27a2a80-04cb41970e6945-16525637-16a7f0-1928f27a2a920cc%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22utm_source%22%3A%20%22ythp%22%2C%22utm_medium%22%3A%20%22LeftNav%22%2C%22utm_campaign%22%3A%20%22ytgen%22%2C%22utm_content%22%3A%20%22txt%22%7D", + "domain": ".youtube.com", + "path": "/", + "expires": 1764674936, + "httpOnly": false, + "secure": false + }, + { + "name": "ST-xuwub9", + "value": "session_logininfo=AFmmF2swRgIhALZXJQRg7B6iILvfx41A-mHr8rh7RMGV3cNkppAPlxxvAiEA38fh68Ct3o4p-ywc1zHhWZxrJ5Dpcd0AcsMp4RZONUs%3AQUQ3MjNmeTdGelpVWXZuN1RTeUMzQkYwNEZhVXY1emtGT1pycWFmWC1LU0txanZReHBLaDRxVHJEZGRyOV8wajFIajdyLWYwcE1rSFZfRVlBM3BNaXZSQlMtLVlLR3RmSURpQjhKRlJaU0xJcHQySmZVNUp6eWFFak9rbE4yWDg5WGdjSkM4QjJhcFRYZTAwVEF6a3RPUzhsSzV0R05YWkVB", + "domain": ".youtube.com", + "path": "/", + "expires": 1733138942, + "httpOnly": false, + "secure": false + }, + { + "name": "__Secure-1PSIDTS", + "value": "sidts-CjIBQT4rXxBz2VTDVx5cMn6A_YgFHPgo9Z-eWATlXeINT58ZUInn2_vTeUb4czgvWV-j5BAA", + "domain": ".youtube.com", + "path": "/", + "expires": 1764674938.226552, + "httpOnly": true, + "secure": true + }, + { + "name": "__Secure-3PSIDTS", + "value": "sidts-CjIBQT4rXxBz2VTDVx5cMn6A_YgFHPgo9Z-eWATlXeINT58ZUInn2_vTeUb4czgvWV-j5BAA", + "domain": ".youtube.com", + "path": "/", + "expires": 1764674938.226624, + "httpOnly": true, + "secure": true + }, + { + "name": "SIDCC", + "value": "AKEyXzWXdf72zjmIboZNkzmg9VURwnmM1MpJVRgAxjRuMRib1b7iu5SkCZccexcK6jf2abrLEoQ", + "domain": ".youtube.com", + "path": "/", + "expires": 1764674940.01384, + "httpOnly": false, + "secure": false + }, + { + "name": "__Secure-1PSIDCC", + "value": "AKEyXzXWzx9lRoJCEXrHvqZeWtAugc_tFou4ucmylPeSpc0nRX2EZ-t3QGTGqberRiTB3QIHjQ", + "domain": ".youtube.com", + "path": "/", + "expires": 1764674940.013891, + "httpOnly": true, + "secure": true + }, + { + "name": "__Secure-3PSIDCC", + "value": "AKEyXzVyGt5J-awGqBrP5_hTnwTmCMsUu5oWISlljhXbP9P7vrGxlzOg05O_vwlgbuGOKRUQGYA", + "domain": ".youtube.com", + "path": "/", + "expires": 1764674940.013962, + "httpOnly": true, + "secure": true + } +] \ No newline at end of file diff --git a/metachain/environment/cookies_data.py b/metachain/environment/cookies_data.py new file mode 100644 index 0000000..a9d819b --- /dev/null +++ b/metachain/environment/cookies_data.py @@ -0,0 +1,195 @@ +COOKIES_LIST = [ + {'name': 'edgebucket', 'value': 'lyxOSFtqXnsQEn0H9C', 'domain': '.reddit.com', 'path': '/', 'expires': 1738484736.570167, 'httpOnly': False, 'secure': True}, + {'name': 'loid', 'value': '000000000r0luy1m5t.2.1703924736813.Z0FBQUFBQmxqOVFBLVZ2UHJIRWswQW4zQnlJZGtYU2ZBS1dSQlpncW1hQ2o2TmVLMk12QkFYRlBEMFpaOGpqTndjcXhuQjhFS3hhc0dSMXRyZ1o4SUg1cTZvSTNHejk5NW5xdlRTRUtfeUdSU250alJhQTFDY3RSeDJrekdnWG90bk1CWmhhc2hlMWU', 'domain': '.reddit.com', 'path': '/', 'expires': 1763278221.514142, 'httpOnly': False, 'secure': True}, + {'name': 'csv', 'value': '2', 'domain': '.reddit.com', 'path': '/', 'expires': 1738484736.97362, 'httpOnly': False, 'secure': True}, + {'name': 'g_state', 'value': '{"i_l":0}', 'domain': 'www.reddit.com', 'path': '/', 'expires': 1744270240, 'httpOnly': False, 'secure': False}, + {'name': 'pc', 'value': 'nd', 'domain': '.reddit.com', 'path': '/', 'expires': 1760254304, 'httpOnly': False, 'secure': True}, + {'name': '__stripe_mid', 'value': '104997eb-5535-4dd4-a71a-5a7f697b8a4650cc1f', 'domain': '.www.reddit.com', 'path': '/', 'expires': 1761300348, 'httpOnly': False, 'secure': True, 'sameSite': 'Strict'}, + {'name': 't2_r0luy1m5t_recentclicks3', 'value': 't3_o9s5iv%2Ct3_1ga952r%2Ct3_1eefr4x%2Ct3_1fs5q5b', 'domain': '.reddit.com', 'path': '/', 'expires': 1762676289, 'httpOnly': False, 'secure': False, 'sameSite': 'Strict'}, + {'name': 'reddit_session', 'value': 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1NjpsVFdYNlFVUEloWktaRG1rR0pVd1gvdWNFK01BSjBYRE12RU1kNzVxTXQ4IiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0Ml9yMGx1eTFtNXQiLCJleHAiOjE3NDg4MzgwNzkuNzc0MDEzLCJpYXQiOjE3MzMxOTk2NzkuNzc0MDEzLCJqdGkiOiJyT3l6V2hFUmtxNDA0b0YzX1FSSVR3R240Y0gzS0EiLCJjaWQiOiJjb29raWUiLCJsY2EiOjE3MDM5MjQ3MzY4MTMsInNjcCI6ImVKeUtqZ1VFQUFEX193RVZBTGsiLCJ2MSI6Ijc2MjE3NTUxMDk3OTY5LDIwMjQtMTAtMTJUMDc6MzA6NDEsYWU3Y2U5ZDdiMjU3OGQ3MWVmMTEwYjFiNTc2NTU2NmNmYzJkNDljNiIsImZsbyI6Mn0.NUhfjOfX7pWC5FFUfJvTw0Ts8b0ZICrmmg_Eh4_O6hvnqEH5UHVjjwtS7YNGyxTRv5k0AJTx-GW5CWTUJvhciPOYokV1iM4RirTbijGfqyvSlbl7YIARX8gUMrm6X2TmFvHmQHem4S-0YcrhvBakEXb2TAk0e4KLiPBS6jbEa5c4EoIkp8PjvFVkWZhY_FMge6SxAmPlx2Xksk2c_9s_rJ-UTZkyOWP5ighh7TmA3B_0ZWEPRme7yDBtV-AJ1UH533suaBukxD_-O3afm1AtjMiQAygZ4tl78T7unCopK1_c8PGiunpfLCMTdTTb07NnI2_fo-8AfARF-9O1MPdYFQ', 'domain': '.reddit.com', 'path': '/', 'expires': 1748838078.887127, 'httpOnly': True, 'secure': True}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%221929b3d4cbd13e0-01d450fe6d3301-16525637-16a7f0-1929b3d4cbe2033%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%7D', 'domain': '.reddit.com', 'path': '/', 'expires': 1764735681, 'httpOnly': False, 'secure': False}, + {'name': 'reddit_chat_view', 'value': 'closed', 'domain': 'www.reddit.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'token_v2', 'value': 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1NjpzS3dsMnlsV0VtMjVmcXhwTU40cWY4MXE2OWFFdWFyMnpLMUdhVGxjdWNZIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNzMzMjg2MDgxLjgwMjc1NywiaWF0IjoxNzMzMTk5NjgxLjgwMjc1NywianRpIjoiTkFrUGZVTVVwRGZ1SmFNbjZkV3dqbnB4U0REMEFnIiwiY2lkIjoiMFItV0FNaHVvby1NeVEiLCJsaWQiOiJ0Ml9yMGx1eTFtNXQiLCJhaWQiOiJ0Ml9yMGx1eTFtNXQiLCJsY2EiOjE3MDM5MjQ3MzY4MTMsInNjcCI6ImVKeGtrZEdPdERBSWhkLWwxejdCX3lwX05odHNjWWFzTFFhb2szbjdEVm9jazcwN2NMNGlIUDhuS0lxRkxFMnVCS0drS1dFRld0T1VOaUx2NTh5OU9aRUZTeUZUUjg0M3l3b2thVXBQVW1ONXB5bFJ3V1prTGxmYXNVS0RCNllwVlM2WjIwS1BTNXZRM0kxRnowNk1xbHhXSHRUWW8zSnBiR01LMnhQanpjWnFReXF1eTZsTVlGa29uOFdMZnZ5Ry10WS1mN2JmaEhZd3JLZ0tEX1RPdUZ4d1lfSERGSGJfbnByMGJGMndxTDNYZzlRLTEtTjI3Yk5tb2RtNV9WelB2emFTY1RtRzVpZll2N3QtQ1IxNDVIbVpVUWN3WWcwX3lyQWo2X0N2T29ES0JRV01KWWhQSTVBcmwyX19KZGl1VGY4YXR5ZC0tR2JFVFdfNHJSbW81eExFb1VfajZ6Y0FBUF9fWERfZTR3IiwicmNpZCI6Ill6STUzaXNuVVRQUm42M3NQbjRSNFBNbVdOcjE4SU1uUU93T2VfaHFuem8iLCJmbG8iOjJ9.F_24jXHdZDXCmMx4aubrjT94AtnYDzD7eg7SjV1Rwa6ymrvrXW8uZnIqgqVkHJio-mZW_JsxlSKzlIDMJ_lrWtgxFHhgGFWnWkS-raKhYrrQt3gwN-C5VPc3iF-1pVUaf0Jf0gX1aYyvdtRD48rRd8sjCoAwHcGiNH8B7abUPN8JJuQcAEH2GzYSc9Zarb0jANLyw7fGdTdWXfWjUXjy33alItwyhMVcgCIXlVf5wlayRBsRXS_ObpKiril2BuAgCrrVuOWDdflpi58FTA0pki4F0wTdcJfORP9yjZ_L7AJUXhXhswx5Lcf0kTU1hFy4RqFRRd95Q0xZg7Yj2uIC7w', 'domain': '.reddit.com', 'path': '/', 'expires': 1733286081.968748, 'httpOnly': True, 'secure': True}, + {'name': 'session_tracker', 'value': 'hhjeikkilpgneqelrj.0.1733199684513.Z0FBQUFBQm5Ub2RFN25sX3lzSVRyLUFxSEtEcmozdW1BbGdaQUtmZUZCMVNteW1PSGhxRHNEdkhrR0lneUNCTUgzUlE2QkdUb2tRRHVyTnNNNlpnOV80TVZzd2hlSzFCRUtLLVZmREYwejhDSXIyX1Q4T04yMnlrSGJkMlVla2h5R1huYnFGOUFleGU', 'domain': '.reddit.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'csrf_token', 'value': '04b1407f1ebd23723b261a469d4dac84', 'domain': '.reddit.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Strict'}, + {'name': 'hum_ieee_visitor', 'value': '3403d64f-1870-4601-9ff7-e5900074a6db', 'domain': '.ieeexplore-ieee-org.eproxy.lib.hku.hk', 'path': '/', 'expires': 1756280921.91082, 'httpOnly': False, 'secure': True}, + {'name': '_zitok', 'value': '6273c58ab3f308a07a711718187500', 'domain': '.ieeexplore-ieee-org.eproxy.lib.hku.hk', 'path': '/', 'expires': 1761551757, 'httpOnly': False, 'secure': True, 'sameSite': 'Strict'}, + {'name': 'AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '179643557%7CMCIDTS%7C20060%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733149186s%7CNONE%7CvVersion%7C5.5.0', 'domain': '.hku.hk', 'path': '/', 'expires': 1767701986.053151, 'httpOnly': False, 'secure': False}, + {'name': 'ezproxy', 'value': 'e1~OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False}, + {'name': 'ezproxyl', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ezproxyn', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'AMCVS_8E929CC25A1FB2B30A495C97%40AdobeOrg', 'value': '1', 'domain': '.ieeexplore-ieee-org.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 's_cc', 'value': 'true', 'domain': '.ieeexplore-ieee-org.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.lib.hku.hk', 'path': '/', 'expires': 1765086053, 'httpOnly': False, 'secure': False}, + {'name': 'utag_main', 'value': 'v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:2$_ss:0$_st:1733551853250$ses_id:1733549982472%3Bexp-session$_pn:2%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk', 'domain': '.hku.hk', 'path': '/', 'expires': 1765086053, 'httpOnly': False, 'secure': False}, + {'name': 'AMCV_8E929CC25A1FB2B30A495C97%40AdobeOrg', 'value': '359503849%7CMCIDTS%7C20065%7CMCMID%7C53777252718039557930823884447397163100%7CMCAID%7CNONE%7CMCOPTOUT-1733557253s%7CNONE%7CvVersion%7C5.0.1', 'domain': '.ieeexplore-ieee-org.eproxy.lib.hku.hk', 'path': '/', 'expires': 1768110053.386389, 'httpOnly': False, 'secure': False}, + {'name': 'did', 'value': 'iXxy5Y0Lo7cY1c90Riq7yRkq4VdJdrz9F1T7N0fqKOFKoaNbUas5EeVE3Oo7jl4M', 'domain': '.www.researchgate.net', 'path': '/', 'expires': 1764671442.13171, 'httpOnly': True, 'secure': True}, + {'name': 'ptc', 'value': 'RG1.8779880038783811042.1696569455', 'domain': '.www.researchgate.net', 'path': '/', 'expires': 1767695442.13179, 'httpOnly': True, 'secure': True}, + {'name': 'pl', 'value': 'wbbzs5zjXc51gyzsE5huVpQxOu7nxEnyZDiQcl7KEpwjXmoGTD064RedCDxz696tPbB38xaV8xe1oIJFEAAanE6P4MKWta1rUAq6iCLxSGnCYhfeB9JhdfN5cz70vq1R', 'domain': '.www.researchgate.net', 'path': '/', 'expires': 1764671442.13175, 'httpOnly': True, 'secure': True}, + {'name': 'didomi_token', 'value': 'eyJ1c2VyX2lkIjoiMTkxOTNiYWQtMDhhMS02Njg5LWE4NTUtMzAwN2VhYzA5MjAwIiwiY3JlYXRlZCI6IjIwMjQtMDgtMjdUMTI6MDY6MTkuNTMwWiIsInVwZGF0ZWQiOiIyMDI0LTA4LTI3VDEyOjA2OjI4LjQ4M1oiLCJ2ZW5kb3JzIjp7ImVuYWJsZWQiOlsiZ29vZ2xlIiwiYzpnb29nbGVhbmEtNFRYbkppZ1IiLCJjOnBvc3Rob2ctQmpKeEZkRjkiLCJjOmRpZG9taSJdfSwidmVuZG9yc19saSI6eyJlbmFibGVkIjpbImdvb2dsZSJdfSwidmVyc2lvbiI6Mn0=', 'domain': '.researchgate.net', 'path': '/', 'expires': 1756382788, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'euconsent-v2-didomi', 'value': 'CQEAuYAQEAuYAAHABBENBDFgALHAAELAAAYgF5wAQF5gXnABAXmAAAAA.djgACFgAAAAA', 'domain': '.researchgate.net', 'path': '/', 'expires': 1756382788, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'dmd-tag', 'value': '29c64880-8f9e-11ef-aabb-5bb25e381cbc', 'domain': '.www.researchgate.net', 'path': '/', 'expires': 1767695450.916031, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'sid', 'value': 'TCoR8Z544diaoyMwqLA8X9TS7HfkN6SpWX1ropwuTqxNP2j10bZlYc7YIVBKPyVcfem0NqbdChel00sIOkOeF5GtceLW00ubTrpe4dAD5xLJ81ocWo9svlf6J9gynIf6', 'domain': '.www.researchgate.net', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'cili', 'value': '_2_MjZhMmQ5N2FmMzgyNmJkYTJiZjc2ZjE0ZjRmMDUxYjMzYjJkZmQxYWY0Njg4Nzc4MTM4ZDE1MGVmNWRhYTc0Nl8yOTU3MzAzNjsw', 'domain': '.www.researchgate.net', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'cirgu', 'value': '_1_SaiBmdG2CNFFXfi8YLMIIkC%2BD5oxd2H0CqLqIhkm0V8y0ncsQEoe%2FGUZOhACuKUy5feYcA%3D%3D', 'domain': '.www.researchgate.net', 'path': '/', 'expires': 1764692368.131654, 'httpOnly': False, 'secure': True}, + {'name': '_cfuvid', 'value': 'GehzRPPGzCCv.nPAiw9L7tRQCAi.hQAAinF5RqUksz0-1733135442009-0.0.1.1-604800000', 'domain': '.researchgate.net', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'dmd-sid4', 'value': '{%22id%22:%227cce57f0-b098-11ef-924a-5d9117969fc2%22%2C%22timestamp%22:1733135444000%2C%22lastUpdate%22:1733135450000}', 'domain': '.www.researchgate.net', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%22192aea49fbae19-0a0ceb4f539c6b-16525637-16a7f0-192aea49fbb210e%22%2C%22utm_source%22%3A%20%22researchgate%22%2C%22utm_medium%22%3A%20%22email%22%2C%22utm_campaign%22%3A%20%22re442%22%2C%22utm_content%22%3A%20%22re442_up_pb_hnsg_nnaas_p110%22%2C%22utm_term%22%3A%20%22re442_up_pb_hnsg_nnaas%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24search_engine%22%3A%20%22google%22%7D', 'domain': '.researchgate.net', 'path': '/', 'expires': 1764671451, 'httpOnly': False, 'secure': False}, + {'name': '__cf_bm', 'value': 'X2xX99VpHc3Xm_TRnIRhN7Wp533PLOt.311xMWQYn28-1733136361-1.0.1.1-qskSGITd3dmHUV3UswCd8O6ygg3jdBOF9Wz1PxlPQq66VUx0blvFKst7fH33pTnkl2W1VBjBjlF8CgPVLesusQ', 'domain': '.researchgate.net', 'path': '/', 'expires': 1733138161.246979, 'httpOnly': True, 'secure': True}, + {'name': 'ph_phc_ma1XTQyee96N1GML6qUTgLQRiDifnRcE9STiHTZ0CfZ_posthog', 'value': '%7B%22distinct_id%22%3A%22AC%3A29573036%22%2C%22%24sesid%22%3A%5B1733137066914%2C%22019386ec-50e4-79f9-9b7b-d8ed5854cf9f%22%2C1733135454436%5D%2C%22%24epp%22%3Atrue%2C%22%24initial_person_info%22%3A%7B%22r%22%3A%22%24direct%22%2C%22u%22%3A%22https%3A%2F%2Fwww.researchgate.net%2Fprofile%2FJiabin_Tang4%2Fpublications%3FeditMode%3D1%26sorting%3DrecentlyAdded%26utm_medium%3Demail%26utm_source%3Dresearchgate%26utm_campaign%3Dre214%26loginT%3DeX2d52IqLj-iYd58KHUvU88w6Ub-0Rjh_XCM-6tyfVf3Goy3Bf0swonajNlIbQg6gax3uaL6ulhi_ik9eMs%26pli%3D1%26utm_term%3Dre214_x%26utm_content%3Dre214_x_p2%26cp%3Dre214_x_p2%26uid%3D2aJ4s09Uf8rvZLKbNnk9UiFVrlZcTRXt51G2%26ch%3Dreg%22%7D%7D', 'domain': '.researchgate.net', 'path': '/', 'expires': 1764673066, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'user.uuid.v2', 'value': '"f9248aca-ac13-40e6-8b45-eaeb5fe20825"', 'domain': 'www-nature-com.eproxy.lib.hku.hk', 'path': '/', 'expires': 1740916756.716508, 'httpOnly': False, 'secure': False}, + {'name': 'ezproxy', 'value': 'e1~OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False}, + {'name': 'ezproxyl', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ezproxyn', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'AMCVS_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '1', 'domain': '.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '179643557%7CMCIDTS%7C20065%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733558198s%7CNONE%7CvVersion%7C5.5.0', 'domain': '.hku.hk', 'path': '/', 'expires': 1768110998.70329, 'httpOnly': False, 'secure': False}, + {'name': 'utag_main', 'value': 'v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:20$_ss:0$_st:1733553108768$ses_id:1733549982472%3Bexp-session$_pn:14%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk', 'domain': '.hku.hk', 'path': '/', 'expires': 1765087308, 'httpOnly': False, 'secure': False}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.lib.hku.hk', 'path': '/', 'expires': 1765087436, 'httpOnly': False, 'secure': False}, + {'name': '_sp_id.a65e', 'value': 'a151b61b-0e26-493f-9885-ed0d9579e181.1712037732.1.1712037742..381bfab3-8c2a-4e54-8d4b-44a5c8c997ef..6db53b82-8b6d-471c-b7de-194adad46810.1712037732261.2', 'domain': 'www.collinsdictionary.com', 'path': '/', 'expires': 1746597742.393476, 'httpOnly': False, 'secure': True}, + {'name': '__cflb', 'value': '02DiuFwNDm462z9fWfJeB58usqeie1xoTDrYZciipwE2x', 'domain': 'www.collinsdictionary.com', 'path': '/', 'expires': 1733223382.743499, 'httpOnly': True, 'secure': True}, + {'name': 'XSRF-TOKEN', 'value': '64a0f62d-dc8f-40cb-8aa2-66e3ad283ad4', 'domain': 'www.collinsdictionary.com', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'dictcode', 'value': 'english', 'domain': 'www.collinsdictionary.com', 'path': '/', 'expires': 1763873478.639472, 'httpOnly': False, 'secure': False}, + {'name': 'searchPanelOpen', 'value': 'true', 'domain': '.collinsdictionary.com', 'path': '/', 'expires': 1733803809.434554, 'httpOnly': False, 'secure': False}, + {'name': 'search', 'value': 'hello', 'domain': 'www.collinsdictionary.com', 'path': '/', 'expires': 1763871009.434808, 'httpOnly': False, 'secure': False}, + {'name': '__cf_bm', 'value': 'xGchgbvqtkoAYddlxWT4VgRmeTZ1qTVmI0hjpRvOj0w-1733201062-1.0.1.1-SDl6_cuGUlqEOSm4oDQpU5rJdha8wEbITIgLoxdY69GgWrSt5GO7nX47Vc2AihzcBY.yS6GZ9qXVfRKEttQyLw', 'domain': '.collinsdictionary.com', 'path': '/', 'expires': 1733202862.320396, 'httpOnly': True, 'secure': True}, + {'name': 'last_url', 'value': 'https%3A%2F%2Fwww.collinsdictionary.com%2Fdictionary%2Fspanish-english%2Fcaminata', 'domain': 'www.collinsdictionary.com', 'path': '/', 'expires': 1763873068.316249, 'httpOnly': False, 'secure': False}, + {'name': 'OptanonConsent', 'value': 'isGpcEnabled=0&datestamp=Tue+Dec+03+2024+12%3A51%3A18+GMT%2B0800+(%E9%A6%99%E6%B8%AF%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=202409.2.0&browserGpcFlag=0&isIABGlobal=false&hosts=&landingPath=NotLandingPage&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A1&AwaitingReconsent=false&geolocation=JP%3B27', 'domain': '.collinsdictionary.com', 'path': '/', 'expires': 1759121478, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'OptanonAlertBoxClosed', 'value': '2024-12-03T04:51:18.738Z', 'domain': '.collinsdictionary.com', 'path': '/', 'expires': 1759121478, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193873a93591e4d-05e2471014e6fb-1e525636-1fa400-193873a935a287e%22%2C%22%24device_id%22%3A%20%22193873a93591e4d-05e2471014e6fb-1e525636-1fa400-193873a935a287e%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.collinsdictionary.com', 'path': '/', 'expires': 1764737478, 'httpOnly': False, 'secure': False}, + {'name': 'UUID', 'value': '3c4dd735-8d33-4fd0-a40f-83d399a0dc46', 'domain': 'www.jstor.org', 'path': '/', 'expires': 1740190342.420181, 'httpOnly': False, 'secure': True}, + {'name': '_pxvid', 'value': '2dd5c1cb-b670-11ee-9186-3dd546fa1c41', 'domain': 'www.jstor.org', 'path': '/', 'expires': 1737166344, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': '__zlcmid', 'value': '1O1n3oDgpvApbaQ', 'domain': '.jstor.org', 'path': '/', 'expires': 1764831447, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'csrftoken', 'value': 'iyx0piwZPaxHwlEjMlBpPrxRasiSrbVv', 'domain': 'www.jstor.org', 'path': '/', 'expires': 1764745045.418981, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'OptanonAlertBoxClosed', 'value': '2024-12-04T06:56:48.465Z', 'domain': '.jstor.org', 'path': '/', 'expires': 1764831408, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'AccessToken', 'value': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiIwMTkzOTA3NGY4MTg3OTQ1OTg4NzdiNWQzMWE4NDk3MSIsInV1aWQiOiIzYzRkZDczNS04ZDMzLTRmZDAtYTQwZi04M2QzOTlhMGRjNDYiLCJ1c2VyIjp7ImlkIjoiIiwibG9nZ2VkSW4iOmZhbHNlLCJhZG1pbiI6ZmFsc2V9LCJpYXQiOjE3MzMyOTU0MDksImV4cCI6MTczMzI5NTcwOX0.lIt08pG__dm_kZ3kJUYMw_bK0Ow2kAD8i2Jf8OQA0RM', 'domain': 'www.jstor.org', 'path': '/', 'expires': 1733299009.610988, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'AccessSessionTimedSignature', 'value': '1b72fc2754973a3daa1baf1b169dfda5ed067ed4113573f1a1005ce5da900999', 'domain': 'www.jstor.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'AccessSession', 'value': 'H4sIAAAAAAAA_42U3W7bMAyF38XXUSFKlEXlLgv2f7Gi7S62oRgYSc68uXHg2B26ou8-KW4adxu6GoEhyYfi4Scqt8Uw1KGYF9pjCFYbQUFrgVWQglFWgnTQzrEMHstiVtTbpAVLJ2DsSfop1Hk17yDBaSetQYzKuqpy1q-YfbBKckWGkm47FWJFQNahcUTWrkzQwITOQhJ2e2HFu765ESFWPDT9nBQiWXIVeAmMUCkGUtJrU6UX5AQN9ylOSYUClJB4Icu5sXOJJ1YTgrJkPmfZ8KRMQ7mX7Z5WmVHV-2Led0OcFdfc7H1PQ9whJAW4Mm2dQ7jvu10xvy2WyyRfnKel5UUatTv-wV-VzfNPaf7uNI3OlofReda8vXizeL8o7tIuQ_9t4X2fdvpyW_Q325g_b3Z93Q993W4yx7aJ-fPlrLiqN_VV_Su-anh9MLx3CyVamaGMM5BSKq3LfAxxvxSjX1HJLGLJTqAMJNhqKYB1NICUzzvH3zuoHznwXQyjwWe3mXhmwoR7iM9v3Xt7L7r25y52p1x39WZdjBhHIHeXd7MJyFBf12HgZsKx-Hj-8qx4iiai1gpB6iNRQ45caj59JOqgghBABBmsQDBOMJUovFaByKtQAkyInsZu124-Jtd_Az3kwwmQdduumyhAOmegTHfMSJsOGYg0HUF8WOTC_6g_lcYPLTWemEWrFD54V-nmhtTNwliuBAZk4TywkBwlQVm5kswxyVlcj33wL2DNfQJNWtGk3m4ftK83H8hIMbXlEaCLJG2IXlTargR6awR7SjfOAzk2fiVZHk287tph-6QHNcm-zuoxeaLxffzL-s_zGONvRDIvs1UFAAA', 'domain': 'www.jstor.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'AccessSessionSignature', 'value': '78ecce97f2a2de3ffb4af7c87424885165a11fe7d2e29bf960edff5c48167a35', 'domain': 'www.jstor.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': '_pxhd', 'value': 'xbR2aQnYKWrHiFs3JKCUojuB1cOv3M4cNFTMhSdjqxqtIsdgsSi8ehBRY7OxVo9kz4W0VVvgcv6IyY0Ta0SJPA==:medf83pfHV213oGcCOKWmgsZc4Kr8q2rEC2GEWBrpMBibA5DYuL7eKs10ANKfVD8qmvJUfcosZrIkQ83XUVKtKmZa4Y6lK04fy46yN254wo=', 'domain': 'www.jstor.org', 'path': '/', 'expires': 1764831425.214494, 'httpOnly': False, 'secure': False}, + {'name': 'pxcts', 'value': 'f8fbc8a1-b20c-11ef-a65c-4947163db9b8', 'domain': 'www.jstor.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': '_px2', 'value': 'eyJ1IjoiZjhjZDk4ZjAtYjIwYy0xMWVmLWFkYzctZGQxMTkxM2QxNDlkIiwidiI6IjJkZDVjMWNiLWI2NzAtMTFlZS05MTg2LTNkZDU0NmZhMWM0MSIsInQiOjE3MzMyOTU3MjY4NjgsImgiOiIyMTFhMjMyMTRlZmQwOWE5OTNhZjlmODU2MDU1ZmI1N2U4MTcwY2RmNDNlZjM0MGFhYzg1Yzk2NzQ0NmVjOWI5In0=', 'domain': 'www.jstor.org', 'path': '/', 'expires': 1733296026, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ReferringRequestId', 'value': 'fastly-default:258d2927284d8837614cc35365d11c1d', 'domain': 'www.jstor.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%221939074fba7618-0aaf84ba8a423a-1e525636-16a7f0-1939074fba82966%22%2C%22%24device_id%22%3A%20%221939074fba7618-0aaf84ba8a423a-1e525636-16a7f0-1939074fba82966%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%7D', 'domain': '.jstor.org', 'path': '/', 'expires': 1764831445, 'httpOnly': False, 'secure': False}, + {'name': 'OptanonConsent', 'value': 'isGpcEnabled=0&datestamp=Wed+Dec+04+2024+14%3A57%3A25+GMT%2B0800+(%E4%B8%AD%E5%9B%BD%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=202303.1.0&browserGpcFlag=0&isIABGlobal=false&hosts=&consentId=e6c4a174-c538-4f9f-8632-f5f8e9ff428d&interactionCount=2&landingPath=NotLandingPage&groups=C0001%3A1%2CC0002%3A1%2CC0005%3A1%2CC0004%3A1%2CC0003%3A1&AwaitingReconsent=false&geolocation=JP%3B27', 'domain': '.jstor.org', 'path': '/', 'expires': 1764831445, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '179643557%7CMCIDTS%7C20060%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733149186s%7CNONE%7CvVersion%7C5.5.0', 'domain': '.hku.hk', 'path': '/', 'expires': 1767701986.053151, 'httpOnly': False, 'secure': False}, + {'name': 'ezproxy', 'value': 'e1~OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False}, + {'name': 'ezproxyl', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ezproxyn', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.lib.hku.hk', 'path': '/', 'expires': 1765083373, 'httpOnly': False, 'secure': False}, + {'name': 'ezproxy', 'value': 'e1~OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False}, + {'name': 'ezproxyl', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ezproxyn', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'AMCVS_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '1', 'domain': '.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'utag_main', 'value': 'v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:20$_ss:0$_st:1733553108768$ses_id:1733549982472%3Bexp-session$_pn:14%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk', 'domain': '.hku.hk', 'path': '/', 'expires': 1765087308, 'httpOnly': False, 'secure': False}, + {'name': 'AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '179643557%7CMCIDTS%7C20065%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733559088s%7CNONE%7CvVersion%7C5.5.0', 'domain': '.hku.hk', 'path': '/', 'expires': 1768111888.617908, 'httpOnly': False, 'secure': False}, + {'name': 'SID', 'value': '"EUW1ED0CAFs37MFXuY5NakcbWc5Qu"', 'domain': '.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'CUSTOMER', 'value': '"UNIVERSITY OF HONG KONG"', 'domain': '.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'E_GROUP_NAME', 'value': '"University of Hong Kong"', 'domain': '.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': '__cf_bm', 'value': 'dU7HSmMJl6w4XDg.tZSoewkYsxb0bX7Barvg4RvulLw-1733551961-1.0.1.1-7Um2w5HRPO8C06bwjScmRD9BaTZkbArPDfX_e8urefWlKlH50nONZAxnxeL4VbDbHzBBcAY1OzwO5TyNuuCUfQ', 'domain': '.lib.hku.hk', 'path': '/', 'expires': 1733553761.117424, 'httpOnly': False, 'secure': False}, + {'name': 'AMCVS_242B6472541199F70A4C98A6%40AdobeOrg', 'value': '1', 'domain': '.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'adBlockEnabled', 'value': 'blocked', 'domain': 'www-science-org.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'cookiePolicy', 'value': 'iaccept', 'domain': 'www-science-org.eproxy.lib.hku.hk', 'path': '/', 'expires': 1733638777.524329, 'httpOnly': False, 'secure': True}, + {'name': 'AMCV_242B6472541199F70A4C98A6%40AdobeOrg', 'value': '179643557%7CMCIDTS%7C20065%7CMCMID%7C90810009207598809487163227219398447255%7CMCOPTOUT-1733559578s%7CNONE%7CvVersion%7C5.5.0', 'domain': '.hku.hk', 'path': '/', 'expires': 1768112378.032281, 'httpOnly': False, 'secure': False}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%2C%22%24search_engine%22%3A%20%22google%22%7D', 'domain': '.lib.hku.hk', 'path': '/', 'expires': 1765088378, 'httpOnly': False, 'secure': False}, + {'name': 'OptanonAlertBoxClosed', 'value': '2024-06-06T05:28:24.993Z', 'domain': '.orcid.org', 'path': '/', 'expires': 1749187704, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'AWSELB', 'value': 'CBD1D7FF1216388FA48838CBCA4774FD22800B8FB55A37124459E84B59F34FE231A4AA84F4ACD29C01160D60FB2ABE4D73D23EFBBE355236CF44A8FEE381C3844BD946CF3D', 'domain': 'orcid.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'AWSELBCORS', 'value': 'CBD1D7FF1216388FA48838CBCA4774FD22800B8FB55A37124459E84B59F34FE231A4AA84F4ACD29C01160D60FB2ABE4D73D23EFBBE355236CF44A8FEE381C3844BD946CF3D', 'domain': 'orcid.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'orcidUserConnectionId', 'value': '-114606494029392851', 'domain': 'orcid.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'XSRF-TOKEN', 'value': 'b64bcd3a-f0f5-407b-9115-a1f5183f3997', 'domain': 'orcid.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'JSESSIONID', 'value': '48DD20615AC49336A91F9A3A6F5B1483', 'domain': 'orcid.org', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'locale_v3', 'value': 'en', 'domain': 'orcid.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193871b8e67918-084bddcb7389ac-1e525636-1fa400-193871b8e682d76%22%2C%22%24device_id%22%3A%20%22193871b8e67918-084bddcb7389ac-1e525636-1fa400-193871b8e682d76%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.orcid.org', 'path': '/', 'expires': 1764674617, 'httpOnly': False, 'secure': False}, + {'name': 'OptanonConsent', 'value': 'isGpcEnabled=0&datestamp=Mon+Dec+02+2024+19%3A23%3A37+GMT%2B0800+(%E9%A6%99%E6%B8%AF%E6%A0%87%E5%87%86%E6%97%B6%E9%97%B4)&version=202310.2.0&browserGpcFlag=0&isIABGlobal=false&hosts=&consentId=71ca593a-5b7c-4963-87cf-52c27440ac95&interactionCount=1&landingPath=NotLandingPage&groups=C0001%3A1%2CC0003%3A1%2CC0002%3A1%2CC0004%3A1&geolocation=HK%3B&AwaitingReconsent=false', 'domain': '.orcid.org', 'path': '/', 'expires': 1764674617, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'cookieName', 'value': 'dont%20show%20message', 'domain': 'orcid.org', 'path': '/', 'expires': 1764674620, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'closable-unique-name', 'value': 'understood', 'domain': 'orcid.org', 'path': '/', 'expires': 1764674620, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ezproxy', 'value': 'e1~OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False}, + {'name': 'ezproxyl', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ezproxyn', 'value': 'OilZogbDH4iMWPK', 'domain': '.eproxy.lib.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'AMCVS_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '1', 'domain': '.hku.hk', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'utag_main', 'value': 'v_id:01939fa183070055958b6429f88c05075005506d00bd0$_sn:1$_se:13$_ss:0$_st:1733552707246$ses_id:1733549982472%3Bexp-session$_pn:9%3Bexp-session$vapi_domain:ieeexplore-ieee-org.eproxy.lib.hku.hk', 'domain': '.hku.hk', 'path': '/', 'expires': 1765086907, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'AMCV_4D6368F454EC41940A4C98A6%40AdobeOrg', 'value': '179643557%7CMCIDTS%7C20065%7CMCMID%7C09958998665032490705964449674769238820%7CMCAID%7CNONE%7CMCOPTOUT-1733558198s%7CNONE%7CvVersion%7C5.5.0', 'domain': '.hku.hk', 'path': '/', 'expires': 1768110998.70329, 'httpOnly': False, 'secure': False}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24device_id%22%3A%20%22193873d31db19b2-00bbd0bd5ad31-1e525636-1fa400-193873d31dc28b3%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.lib.hku.hk', 'path': '/', 'expires': 1765087052, 'httpOnly': False, 'secure': False}, + {'name': 'PREF', 'value': 'f7=4100&tz=Asia.Hong_Kong&f4=4000000', 'domain': '.youtube.com', 'path': '/', 'expires': 1767698936.819909, 'httpOnly': False, 'secure': True}, + {'name': 'HSID', 'value': 'AuvRBV-Q9GEReACoE', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.11147, 'httpOnly': True, 'secure': False}, + {'name': 'SSID', 'value': 'AHum7OsxEOAD-Ibp4', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111503, 'httpOnly': True, 'secure': True}, + {'name': 'APISID', 'value': 'FRZgwlTWYfVE-B2B/A7FrDbUAZCyMOb6ue', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111539, 'httpOnly': False, 'secure': False}, + {'name': 'SAPISID', 'value': 'mPwGiY6zyqe8IurQ/An35YGZHoL6Ged8z8', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111575, 'httpOnly': False, 'secure': True}, + {'name': '__Secure-1PAPISID', 'value': 'mPwGiY6zyqe8IurQ/An35YGZHoL6Ged8z8', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111613, 'httpOnly': False, 'secure': True}, + {'name': '__Secure-3PAPISID', 'value': 'mPwGiY6zyqe8IurQ/An35YGZHoL6Ged8z8', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111646, 'httpOnly': False, 'secure': True}, + {'name': 'LOGIN_INFO', 'value': 'AFmmF2swRgIhALZXJQRg7B6iILvfx41A-mHr8rh7RMGV3cNkppAPlxxvAiEA38fh68Ct3o4p-ywc1zHhWZxrJ5Dpcd0AcsMp4RZONUs:QUQ3MjNmeTdGelpVWXZuN1RTeUMzQkYwNEZhVXY1emtGT1pycWFmWC1LU0txanZReHBLaDRxVHJEZGRyOV8wajFIajdyLWYwcE1rSFZfRVlBM3BNaXZSQlMtLVlLR3RmSURpQjhKRlJaU0xJcHQySmZVNUp6eWFFak9rbE4yWDg5WGdjSkM4QjJhcFRYZTAwVEF6a3RPUzhsSzV0R05YWkVB', 'domain': '.youtube.com', 'path': '/', 'expires': 1766493681.602721, 'httpOnly': True, 'secure': True}, + {'name': 'SID', 'value': 'g.a000qQgZRYfZgm7ZwgtAwFbDQEBvZXpLRLlzTLW27vxVIiEvbgDsOzHaI33jRkuMY7qPd2ZFEAACgYKAUMSARYSFQHGX2Miunh4qpdUynkY2PXqPSvzGxoVAUF8yKqW-lS90SUYH-cprrTRIWDZ0076', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111199, 'httpOnly': False, 'secure': False}, + {'name': '__Secure-1PSID', 'value': 'g.a000qQgZRYfZgm7ZwgtAwFbDQEBvZXpLRLlzTLW27vxVIiEvbgDsSADU_B-86vJipiHTuQxp3gACgYKATgSARYSFQHGX2MinsKT7OE3L_H9SEzrOgndQRoVAUF8yKqLdTsMTC89NTlwps9-uRSu0076', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111393, 'httpOnly': True, 'secure': True}, + {'name': '__Secure-3PSID', 'value': 'g.a000qQgZRYfZgm7ZwgtAwFbDQEBvZXpLRLlzTLW27vxVIiEvbgDsvr3qlDZAuj7i33G332SpFwACgYKAekSARYSFQHGX2Mi9DYKrElENMuHNeaGctBCdBoVAUF8yKrmoWXREkhXzva2a6J3B2ps0076', 'domain': '.youtube.com', 'path': '/', 'expires': 1766544331.111431, 'httpOnly': True, 'secure': True}, + {'name': 'NID', 'value': '519=I7v3EQhK2PGhnXSOCZyz5QYVGbJ383LEPRVqShP2G15ip3zj5VjyDJWEAlJtS3ifC0qs-7cxlwk_vCTqntg_LWW_hfONxTqG6JVJ8JvpMEr2eM_Fqb9n8nVbc_YNrwSIUEorM5N5FUoZmW2u4Qksi_a0-ssHJWsOBEwdxpDONaY', 'domain': '.youtube.com', 'path': '/', 'expires': 1747984059.767459, 'httpOnly': True, 'secure': True}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%221928f27a2a80-04cb41970e6945-16525637-16a7f0-1928f27a2a920cc%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22utm_source%22%3A%20%22ythp%22%2C%22utm_medium%22%3A%20%22LeftNav%22%2C%22utm_campaign%22%3A%20%22ytgen%22%2C%22utm_content%22%3A%20%22txt%22%7D', 'domain': '.youtube.com', 'path': '/', 'expires': 1764674936, 'httpOnly': False, 'secure': False}, + {'name': 'ST-xuwub9', 'value': 'session_logininfo=AFmmF2swRgIhALZXJQRg7B6iILvfx41A-mHr8rh7RMGV3cNkppAPlxxvAiEA38fh68Ct3o4p-ywc1zHhWZxrJ5Dpcd0AcsMp4RZONUs%3AQUQ3MjNmeTdGelpVWXZuN1RTeUMzQkYwNEZhVXY1emtGT1pycWFmWC1LU0txanZReHBLaDRxVHJEZGRyOV8wajFIajdyLWYwcE1rSFZfRVlBM3BNaXZSQlMtLVlLR3RmSURpQjhKRlJaU0xJcHQySmZVNUp6eWFFak9rbE4yWDg5WGdjSkM4QjJhcFRYZTAwVEF6a3RPUzhsSzV0R05YWkVB', 'domain': '.youtube.com', 'path': '/', 'expires': 1733138942, 'httpOnly': False, 'secure': False}, + {'name': '__Secure-1PSIDTS', 'value': 'sidts-CjIBQT4rXxBz2VTDVx5cMn6A_YgFHPgo9Z-eWATlXeINT58ZUInn2_vTeUb4czgvWV-j5BAA', 'domain': '.youtube.com', 'path': '/', 'expires': 1764674938.226552, 'httpOnly': True, 'secure': True}, + {'name': '__Secure-3PSIDTS', 'value': 'sidts-CjIBQT4rXxBz2VTDVx5cMn6A_YgFHPgo9Z-eWATlXeINT58ZUInn2_vTeUb4czgvWV-j5BAA', 'domain': '.youtube.com', 'path': '/', 'expires': 1764674938.226624, 'httpOnly': True, 'secure': True}, + {'name': 'SIDCC', 'value': 'AKEyXzWXdf72zjmIboZNkzmg9VURwnmM1MpJVRgAxjRuMRib1b7iu5SkCZccexcK6jf2abrLEoQ', 'domain': '.youtube.com', 'path': '/', 'expires': 1764674940.01384, 'httpOnly': False, 'secure': False}, + {'name': '__Secure-1PSIDCC', 'value': 'AKEyXzXWzx9lRoJCEXrHvqZeWtAugc_tFou4ucmylPeSpc0nRX2EZ-t3QGTGqberRiTB3QIHjQ', 'domain': '.youtube.com', 'path': '/', 'expires': 1764674940.013891, 'httpOnly': True, 'secure': True}, + {'name': '__Secure-3PSIDCC', 'value': 'AKEyXzVyGt5J-awGqBrP5_hTnwTmCMsUu5oWISlljhXbP9P7vrGxlzOg05O_vwlgbuGOKRUQGYA', 'domain': '.youtube.com', 'path': '/', 'expires': 1764674940.013962, 'httpOnly': True, 'secure': True}, + {'name': 'MAID', 'value': '+O8mvi2rAtZrnJqF+2cRIQ==', 'domain': '.pnas.org', 'path': '/', 'expires': 1759078802.198648, 'httpOnly': True, 'secure': True}, + {'name': 'MACHINE_LAST_SEEN', 'value': '2024-12-02T09%3A00%3A01.960-08%3A00', 'domain': '.pnas.org', 'path': '/', 'expires': 1759078802.198711, 'httpOnly': True, 'secure': True}, + {'name': 'JSESSIONID', 'value': 'CEDD494D14F0052C199B1D7AE667EF42', 'domain': '.pnas.org', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': '__cf_bm', 'value': 'YJQBFxCTLG1d3d9R0fVmwlmAgP9kqVl3zwf02v.COMQ-1733158802-1.0.1.1-tLccs1jD809lM7_9Bhy35sLQdM1TaakBEYvhdDEi1w9cWJS9IGjovTwKGdYQtse6_rWkJNYt._LsHQI2WCwDUQ', 'domain': '.pnas.org', 'path': '/', 'expires': 1733160603.504839, 'httpOnly': True, 'secure': True}, + {'name': 'cookiePolicy', 'value': 'accept', 'domain': '.pnas.org', 'path': '/', 'expires': 1767718816.994233, 'httpOnly': True, 'secure': True}, + {'name': 'connect_auto_login', 'value': 'true', 'domain': '.pnas.org', 'path': '/', 'expires': 1735750875.510643, 'httpOnly': True, 'secure': True}, + {'name': 'PLUID', 'value': 'l8nplDdx7mN9Xh4lErbknypxfmo=', 'domain': '.pnas.org', 'path': '/', 'expires': 1759078875.9476, 'httpOnly': True, 'secure': True}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%221938850d07a6d2-0446945abb35c6-1e525636-16a7f0-1938850d07c132a%22%2C%22%24device_id%22%3A%20%221938850d07a6d2-0446945abb35c6-1e525636-16a7f0-1938850d07c132a%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.pnas.org', 'path': '/', 'expires': 1764694876, 'httpOnly': False, 'secure': False}, + {'name': 'donation-identifier', 'value': '8ed6af4cc08b88b68b36fffcb6dd7323', 'domain': '.archive.org', 'path': '/', 'expires': 1741773847.95608, 'httpOnly': False, 'secure': False}, + {'name': 'abtest-identifier', 'value': 'ca9982a6c4240d53598f01665a3c6100', 'domain': '.archive.org', 'path': '/', 'expires': 1741773847.956153, 'httpOnly': False, 'secure': False}, + {'name': 'test-cookie', 'value': '1', 'domain': '.archive.org', 'path': '/', 'expires': 1734348067.326946, 'httpOnly': False, 'secure': False}, + {'name': 'g_state', 'value': '{"i_l":0}', 'domain': 'archive.org', 'path': '/', 'expires': 1748690473, 'httpOnly': False, 'secure': False}, + {'name': 'logged-in-sig', 'value': '1764674476%201733138476%20Y3yQCmHjxUil%2FcGs%2FgYR6m%2FHA%2F%2FtAtShDsn25N2tNIzvkGr6EkwbEsYEwDTjZ6%2Bu4Iy65eDH5gZVrZayaRZzJEa6R91agNjLC1rmw%2F47W5OXyDVFN5kLX%2Ba2OxNOzEx6Ws%2BLVwFVr%2Bdnbzhdt1vqNTEpECwy14%2Fu4n9qXGANJ5IKEO7pfu4ONymTb0RWH%2B158Wphp0Gluy9bR1a3t3TSGM%2FyhBEa37FJ56ckJJDghwIVsANhhu%2FextDlCDLXDkPtxLrwdX%2FAlbBoNFIeQ5%2BzoJX21KKQVdJxVWzSRLb4LXyFQsvhkpL221qlJ%2FDQER53IrTAIkmxrDI4cfjumUnKTQ%3D%3D', 'domain': '.archive.org', 'path': '/', 'expires': 1764674476.838234, 'httpOnly': False, 'secure': False}, + {'name': 'logged-in-user', 'value': 'jiabintang77%40gmail.com', 'domain': '.archive.org', 'path': '/', 'expires': 1764674476.838343, 'httpOnly': False, 'secure': False}, + {'name': 'PHPSESSID', 'value': 'jteta3bg9mb3t8e6dkp7r6mcd4', 'domain': '.archive.org', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'donation', 'value': 'x', 'domain': '.archive.org', 'path': '/', 'expires': 1736767334, 'httpOnly': False, 'secure': False}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193871a38b622b1-030839df772cce-1e525636-1fa400-193871a38b71d9a%22%2C%22%24device_id%22%3A%20%22193871a38b622b1-030839df772cce-1e525636-1fa400-193871a38b71d9a%22%2C%22%24initial_referrer%22%3A%20%22%24direct%22%2C%22%24initial_referring_domain%22%3A%20%22%24direct%22%7D', 'domain': '.archive.org', 'path': '/', 'expires': 1764675133, 'httpOnly': False, 'secure': False}, + {'name': 'ncbi_sid', 'value': '015E11D6531E8483_1525SID', 'domain': '.nih.gov', 'path': '/', 'expires': 1764675079.027761, 'httpOnly': False, 'secure': False}, + {'name': 'pmc-frontend-csrftoken', 'value': 'L3uvd1o5Uu2efxgCXWDzwxfDTl5QIFDR', 'domain': 'www.ncbi.nlm.nih.gov', 'path': '/', 'expires': 1753769052.705813, 'httpOnly': False, 'secure': False, 'sameSite': 'Lax'}, + {'name': 'ncbi-login-route', 'value': 'google', 'domain': '.ncbi.nlm.nih.gov', 'path': '/', 'expires': 1741001395.405247, 'httpOnly': False, 'secure': False}, + {'name': 'PRESERVTARGET', 'value': '%2FtYTXpgzJne16bwfb4ZN2lGInyYoZNk58TVbSvhIR0njSJplCp65%2BiF2SZAktvmmznDxgJBJhBCH%0ANoo2je1cMk0RXykLSXa4UwW7u0%2B%2Fc1X7WzHdCi209NjSVDPLNfOmFzmtz50Uuh6EfD95OQ%2BYQ2B%2B%0Aq7BP3es9s8ArLlZd9XW7NS72Ulu8cigULF%2FZADnu%2FPZf8DmPLOXuV6xWf0fqcNlZXwWhiCjrPJiU%0AU594rDm20QBWFe5y0VjWXnJtzYm7uSPkWDQYJ8htbKyWwjn4aG0xcYfTBSBUTOi9A%2Bo1BnUPHLIi%0A8V9%2Fi7S2i2vLCCwVTCSGS0pctKKWZRmzEmP9NB4rA167%2FSMuyX6ezHZNUyztiKaga84g5monl5bT%0AjNlmWeBFQV90piriK2wjmey3mIoTu2eJyDi%2Bx%2FO7pwMTfeiU2WXZ5h3U4kRBxw%2FR6%2FrCMYtVrzXp%0A%2FexiuMJDHQmiDPowP8dxw97tgs353jnBRGe8jpoCPoPG2hywQnwXtxW8SjWp19yTypxVFl4KnD1e%0A5aoPyq%2F7tPDRPbW7UikYuihFvX0mD1TH7A0G9Bk%2B36y%2F7jL8oW7OArzEbESjcx2aVRL%2B3VqzX1Oc%0AZcFWXfVarYgckE8EeyNwFwhPDoASs2T4SVNAJAQ38A0bYzCAxc6mQLqADqesOuuveClDDgB8WITg%0A1QnE32rGsLz37nzAQ89V', 'domain': '.nih.gov', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'NIHSMPROFILE', 'value': '9i9xFyZxcZ3DeEBWJ1M%2B1ygJsb2LhWqfanAC3W20fjIpeXaMrRQ%2F9L3R6DUjYzq5%2FqUDVLhYywfn1%2BT0RJpzID8efN8zNczLDneXLM7waIbhTdfwbIh%2BCnmN0fucHtqYylLU1altZcOhSRTow47jYwyEUFsmJ6kz3T1%2BnZWx3Ol0zaFC8onzZCtl4YHbCxMJVbHYuMcGM4f4YxpiDefQvlDdwY1soBI8z9nvb%2BKMs1B3GgplTzyllWIbC1RHxGLvdlNaz8Zlzw6MU4B3piqrAiCWAvoMF3%2FSShIchSdP0utP%2BMROhcGaoWBU%2FKfkjjDc3lHCPfydE%2F895aasf6uvrL7uccokjb6HxdVs0FA%2FHxfBNJXURVRSpRl9%2BPOd9%2FOOlXQQqhBh1FyAZs6WIxDvLhegMvLITcLh7ahcahuJnoeImSla4b4kK0Ayy6736mJCa0hhXUzGjab4Yhht11PliHlAlh4wLEXj0Dp7X9pj7Ws1%2BdCx8QZyiTWrbauCQJtS1hNXn%2Blg4BoQ2sIq%2FxltuA%3D%3D', 'domain': '.nih.gov', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'NIHSMSESSION', 'value': 'ZITCB6DlveT31D7iO+eVBnrLBxVxrCJGSz/5hVXNSYwvjuPpvd0O7dD6XqsFf6LKdJXktiX+hhtPWtsNok2mgiSvcpYTBHJxHVefyBt+KiLTVm12lBwYTkx9Gb9OxZNQsMS/Hvoj45/ShvSKut3d7c8e2cEhtjA7DWjHEYHj0tuk3fEoQ4t0UAWkhj6bFt5Vo5tm6dyqen/1EH2o6cBZrVSLbk67LctlbRyV4pc5099Il2lTMPo6LqtyVI1AC/bcSioge+LqDbpDiuP4NOF3EPj/yFSWvBz76/bqQ0Hu5oRGCC1zVPhSGJ1iukio91F6IfYr5vTtMrN00vSuxHRnxj0BYCEuGKtCeNDohuGAZvQVkjhc1aI53oWFu8UNHZvYA+Xo2lpLZUOT+2pkjk1Z/WpAPzE8L+O6mRjwaq8/2b3rUS8gq4Y/2vQYhqmNxeHOBsM01BhpJdRF3Urp3dnIIYakYeCgzC/3Kww+p8ImsBTjvXDO1ivVEjKB4AdnCsRdLpGPszS9xF7439aXXCWHDItM4Wl458T12QWeV+DXiiwzD/kis1QQBWibzsZOzo9KDM3oapKa8I2ouKXb797Z7s+eLR1+Z10lyWZuNVLLZK5ckFT5riayLYeT8+IjFYVER/nfDzm3KpgVPnep/k4DANpDgAOK78iuTv3sBndNMoKrXz2qCZtfi3/gLGZTKcOy90meluFZy9+iLyb+M01VBWuDp/v0a2jSdsJPVmgUQqz7hLVvtc4KpMfiDhfxXGMQnaieP9jREFK3NutAiUrkjS96WS3v5eLK80o/aG1j5IsAvxU/0lMnEri3Yz6Qw1f0ymS6giKiFIUBRUWGXcm5S1qCjwL5GiU71r3nOcaC8T9T1pVLf1R558WqH6Ha95aJVqN6CnEHo8TsZl25lb5tlJgbgb2OFvLSrbUZwuM3R5mA9zP7ciQBywxNm7xFO8sX8QQk0bRhrhgk458KE72Ci/8lhZmvpYy5aqbI4OtaLkuFuu3lX3c7/LsGt+iTFkO6eDSS4CFEnFqg3W5Glvs7WZkTasVI7L0mN0q8DCPXaIDFVPlXEA0shxZuB6Iz+mx4MshQHwY9fMRSWB7gOF5cHjHYUBLfHT/gOwl35rkoJfVf9ikpcgT88mJyk9KTQpVM+CZAGUFDbgHsRqA0jPE19sBum3cqaA6fzh9AnWXfOlAY5KNDdTB4yip4UakCXWsiXVng0GfQ7KvxAguC59L7iZyFjdsIDESi7ZozcPHOpFZleeAU3yFTvMGHmO3G3RFrxyIGCwgWehus3YCqQxZPSE6+yLjXeXTqhqgk0kxcV/MlOFgzMcAhgKEYJS045sLZsmohsIVLV0ONY4uqogSxd3YUzc0WImi1mYdNbzYwbX5tPngah4SK61Nia8Z6xjZuKfXnxNFEkNneezPoPy97Hvd+9wzI+DkU5sa844DzGxeSY/ySE3DTtpowf440r5rX', 'domain': '.nih.gov', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True}, + {'name': 'MyNcbiSigninPreferences', 'value': 'O2dvb2dsZSY%3D', 'domain': '.nih.gov', 'path': '/', 'expires': 1740915025.611341, 'httpOnly': False, 'secure': False}, + {'name': 'ncbi_prevPHID', 'value': 'CE88342C74D8A32100000000003B0036', 'domain': '.ncbi.nlm.nih.gov', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'WebCubbyUser', 'value': '3GX25AI24DLUXL8LVDJFIVTH6LJRZBE1%3Blogged-in%3Dtrue%3Bmy-name%3Djiabintang77%2540gmail.com%3Bpersistent%3Dfalse%40015E11D6531E8483_1525SID', 'domain': '.nih.gov', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': False}, + {'name': 'WebEnv', 'value': '1D7wJH%40015E11D6531E8483_1525SID', 'domain': '.nlm.nih.gov', 'path': '/', 'expires': 1733167826.636953, 'httpOnly': True, 'secure': True}, + {'name': 'ncbi_pinger', 'value': 'N4IgDgTgpgbg+mAFgSwCYgFwgAwEYCsAorrgCIBs+AzLoQBwAsdAnLgOxU1XPZt354AygElSIAL5A===', 'domain': '.ncbi.nlm.nih.gov', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22193872246ca871-06560f33a3902-1e525636-1fa400-193872246cb267c%22%2C%22%24device_id%22%3A%20%22193872246ca871-06560f33a3902-1e525636-1fa400-193872246cb267c%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%7D', 'domain': '.nih.gov', 'path': '/', 'expires': 1764675078, 'httpOnly': False, 'secure': False}, + {'name': '_device_id', 'value': '49f9d6cfbd603c8509e73807be70a438', 'domain': 'github.com', 'path': '/', 'expires': 1764674868.858374, 'httpOnly': True, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'MicrosoftApplicationsTelemetryDeviceId', 'value': '3966ee53-78ca-4fa3-95d7-85e299cecee4', 'domain': 'github.com', 'path': '/', 'expires': 1763890136.033527, 'httpOnly': False, 'secure': True}, + {'name': '_octo', 'value': 'GH1.1.1313590405.1727940967', 'domain': '.github.com', 'path': '/', 'expires': 1759476967, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'GHCC', 'value': 'Required:1-Analytics:1-SocialMedia:1-Advertising:1', 'domain': '.github.com', 'path': '/', 'expires': 1745563377, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'MSFPC', 'value': 'GUID=3452f0b49fd14d349a6dbf8ddee26d60&HASH=3452&LV=202410&V=4&LU=1730011383391', 'domain': 'github.com', 'path': '/', 'expires': 1761547383.513164, 'httpOnly': False, 'secure': True}, + {'name': 'logged_in', 'value': 'yes', 'domain': '.github.com', 'path': '/', 'expires': 1762511337.053395, 'httpOnly': True, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'saved_user_sessions', 'value': '151511798%3A8an8gJwE3la35NvNIyacuRFRSHlup_9RBaQ5q4CThhvPV89o%7C152840453%3A2Quysh6Cns_a0IpeKcw-GAUZIt6ZndbJ7BoGdxx11qkZa9bi%7C151510669%3AMpYw2DQuFwt3NJiimm36OWLTQmoWFzVcSUbLuV8SBFRPqN8-%7C165454715%3AZSjwi4MUxVCr91r-m1ElvPL2L0DGDSoSo6uwV7pPpliml3js%7C148674909%3ALnLJclEDIxFjFcwX0eBlgOJzbDpsxKedtd6So7_EFs6HPtL7%7C56426168%3AmM073me2REE4yufnvYBKQ84KsG54oHKLBiTSs5tXYCoyQx4g', 'domain': 'github.com', 'path': '/', 'expires': 1739599354.295483, 'httpOnly': True, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'user_session', 'value': 'mM073me2REE4yufnvYBKQ84KsG54oHKLBiTSs5tXYCoyQx4g', 'domain': 'github.com', 'path': '/', 'expires': 1734348468.858989, 'httpOnly': True, 'secure': True, 'sameSite': 'Lax'}, + {'name': '__Host-user_session_same_site', 'value': 'mM073me2REE4yufnvYBKQ84KsG54oHKLBiTSs5tXYCoyQx4g', 'domain': 'github.com', 'path': '/', 'expires': 1734348468.859144, 'httpOnly': True, 'secure': True, 'sameSite': 'Strict'}, + {'name': 'dotcom_user', 'value': 'tjb-tech', 'domain': '.github.com', 'path': '/', 'expires': 1763647073.257243, 'httpOnly': True, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'color_mode', 'value': '%7B%22color_mode%22%3A%22auto%22%2C%22light_theme%22%3A%7B%22name%22%3A%22light%22%2C%22color_mode%22%3A%22light%22%7D%2C%22dark_theme%22%3A%7B%22name%22%3A%22dark%22%2C%22color_mode%22%3A%22dark%22%7D%7D', 'domain': '.github.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'cpu_bucket', 'value': 'xlg', 'domain': '.github.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'preferred_color_mode', 'value': 'light', 'domain': '.github.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'tz', 'value': 'Asia%2FHong_Kong', 'domain': '.github.com', 'path': '/', 'expires': -1, 'httpOnly': False, 'secure': True, 'sameSite': 'Lax'}, + {'name': 'mp_94085d51c4102efbb82a71d85705cdcf_mixpanel', 'value': '%7B%22distinct_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%2C%22%24device_id%22%3A%20%221928eb980316cc-050dbe3db24bd2-16525637-16a7f0-1928eb980321bb8%22%2C%22%24search_engine%22%3A%20%22google%22%2C%22%24initial_referrer%22%3A%20%22https%3A%2F%2Fwww.google.com%2F%22%2C%22%24initial_referring_domain%22%3A%20%22www.google.com%22%2C%22%24user_id%22%3A%20%22cm28oqri501xjtwbjzt1prdzm%22%7D', 'domain': '.github.com', 'path': '/', 'expires': 1764674869, 'httpOnly': False, 'secure': False}, + {'name': '_gh_sess', 'value': 'oUZyg0XEvo5fm%2FC18yV17FMePsGYB4hM9R5q8AgiwOAjTritHx1Ux4jNGjnm7Jaxz99%2FOxD4agIy05dUdG6cnSxRP62NJE7bZxIWFV2W64ekLVCwz7ge2oaRcvVlN4HjVhw5dsl2czpD8Irn%2BZG0Dmw16tH9GZZ4yhaFW5%2Fshmte3DBYsndzLNn4rGje9B3P1IFYyz9sYx23j71xRb9wRjwoLHPYGf4Yp3vRKVAzTp3X6nrjvgr4XGU2N%2BGPH3OYDZQYCIPLckTIEmRg7a0dd2KvU2mfcm%2F%2B9N9%2FNNBFTbKvUhPwWM8kIRpv5WTzU%2FI5Y0qBv71gX2B7nNm%2FtIkWjbWUhgizf%2BpxOHAuhs89sRaicpc9NjasSUISwfxRCoH5evWqVXEifhqQvSU42iR4wkhnRHs%3D--za2vZwPq%2FBJxevj3--tEOzEYASRs0gepJUCIv8Mg%3D%3D', 'domain': 'github.com', 'path': '/', 'expires': -1, 'httpOnly': True, 'secure': True, 'sameSite': 'Lax'}, + {'domain': 'www.nature.com', 'secure': False, 'expirationDate': 1733745572000, 'hostOnly': True, 'name': 'user.uuid.v2', 'httpOnly': False, 'session': False, 'storeId': None, 'value': '"765b07e9-028b-45d1-8abd-baa7b6c88125"', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'hostOnly': False, 'name': 'Hm_lpvt_d38bce82bcb44717ccc29a90c4b781ea', 'httpOnly': False, 'session': True, 'storeId': None, 'value': '1733140842', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1764676842000, 'hostOnly': False, 'name': 'ajs_anonymous_id', 'httpOnly': False, 'session': False, 'storeId': None, 'value': '7e4d00ab-3618-46a2-b0fb-c80b189a0584', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1764676842000, 'hostOnly': False, 'name': 'ajs_user_id', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'b1ae7862-b9d6-49c5-a7a5-ad96682ac6dc_SN', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1767700841000, 'hostOnly': False, 'name': '_ga_B3E4QL2TPR', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'GS1.1.1733140776.1.1.1733140841.60.0.0', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1767700841000, 'hostOnly': False, 'name': '_ga_ERRNTNZ807', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'GS1.1.1733140776.1.1.1733140841.60.0.467679787', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1767304843000, 'hostOnly': False, 'name': 'cto_bundle', 'httpOnly': False, 'session': False, 'storeId': None, 'value': '5685XF9lQVd1dU4zd2xWRE1uZ3BpQTk3SUVXNkx2bGslMkZwTkZodjRWJTJCcGoyd0JWdiUyQjVlcGkwMVoyWHc4aGxKQkM2N3hyeGI4aFlIRzBZRDNTUTJFb1JYZVhPJTJGMUIlMkZka252a0RPZFdlbld4OU1jaUFrMHN6VDVaREYzSSUyRmFDMEtnb0FoaQ', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': True, 'expirationDate': 1766836842000, 'hostOnly': False, 'name': '__gpi', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'UID=00000fa61060e41d:T=1733140842:RT=1733140842:S=ALNI_Mai2WWloG6liac6hEyJYOSjI3WtCg', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1766836841000, 'hostOnly': False, 'name': '_uetvid', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'e6d7f220b0a411efaac753cc9ddac552', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1764676841000, 'hostOnly': False, 'name': 'Hm_lvt_d38bce82bcb44717ccc29a90c4b781ea', 'httpOnly': False, 'session': False, 'storeId': None, 'value': '1733140777', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': True, 'expirationDate': 1748692774000, 'hostOnly': False, 'name': '__eoi', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'ID=1ced890879e93934:T=1733140774:RT=1733140774:S=AA-AfjauQ5O9wXrdBjufrcsmQ-EM', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': True, 'expirationDate': 1766836842000, 'hostOnly': False, 'name': '__gads', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'ID=edf25ef88638a1b3:T=1733140842:RT=1733140842:S=ALNI_MYUdW0s3LG6IOpCKgjBo4gbGPsI1Q', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1740916843000, 'hostOnly': False, 'name': '_fbp', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'fb.1.1733140776577.688163329394303800', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1767700841000, 'hostOnly': False, 'name': '_ga', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'GA1.1.2115119478.1733140776', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1733227241000, 'hostOnly': False, 'name': '_uetsid', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'e6d7f280b0a411efaed4a5384bcc5d88', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'hostOnly': False, 'name': 'HMACCOUNT', 'httpOnly': False, 'session': True, 'storeId': None, 'value': '7B6C1DFC72FE250C', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': True, 'expirationDate': 1767700841000, 'hostOnly': False, 'name': 'permutive-id', 'httpOnly': False, 'session': False, 'storeId': None, 'value': '7cbbccaf-2079-4e6d-99fc-186a9db51c90', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1767700841000, 'hostOnly': False, 'name': 'permutive-session', 'httpOnly': False, 'session': False, 'storeId': None, 'value': '%7B%22session_id%22%3A%221d3a9243-5c93-4975-ae30-63ca2047b7cf%22%2C%22last_updated%22%3A%222024-12-02T12%3A00%3A41.747Z%22%7D', 'path': '/', 'sameSite': 'Lax'}, + {'domain': '.nature.com', 'secure': False, 'expirationDate': 1764676775000, 'hostOnly': False, 'name': 'sncc', 'httpOnly': False, 'session': False, 'storeId': None, 'value': 'P%3D8%3AV%3D68.0.0%26C%3DC01%2CC02%2CC03%2CC04%26D%3Dtrue', 'path': '/', 'sameSite': 'Lax'}, +] diff --git a/metachain/environment/docker_container.py b/metachain/environment/docker_container.py new file mode 100644 index 0000000..364ccfe --- /dev/null +++ b/metachain/environment/docker_container.py @@ -0,0 +1,95 @@ +import os +import subprocess +from constant import GITHUB_AI_TOKEN, AI_USER, BASE_IMAGES +import time +from metachain.util import run_command_in_container + +def init_container(workplace_name, container_name, test_pull_name = 'test_pull_1010', task_name = 'test_task', git_clone = False, setup_package = 'setup_package'): + # get the current working directory's subfolder path + workplace = os.path.join(os.getcwd(), workplace_name) + + # check if the container exists + container_check_command = ["docker", "ps", "-a", "--filter", f"name={container_name}", "--format", "{{.Names}}"] + existing_container = subprocess.run(container_check_command, capture_output=True, text=True) + + os.makedirs(workplace, exist_ok=True) + # cp_command = ["cp", "tcp_server.py", workplace] + if not os.path.exists(os.path.join(workplace, 'tcp_server.py')): + unzip_command = ["tar", "-xzvf", f"packages/{setup_package}.tar.gz", "-C", workplace] + subprocess.run(unzip_command) + if git_clone: + if not os.path.exists(os.path.join(workplace, 'metachain')): + git_command = ["cd", workplace, "&&", "git", "clone", "-b", test_pull_name, f"https://{AI_USER}:{GITHUB_AI_TOKEN}@github.com/tjb-tech/metachain.git"] + git_command = " ".join(git_command) + + result = subprocess.run(git_command, shell=True) + if result.returncode != 0: + raise Exception(f"Failed to clone the repository. Please check your internet connection and try again.") + # create a new branch + new_branch_name = f"{test_pull_name}_{task_name}" + create_branch_command = f"cd {workplace}/metachain && git checkout -b {new_branch_name}" + result = subprocess.run(create_branch_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + print(Exception(f"Failed to create and switch to new branch. Error: {result.stderr}")) + switch_branch_command = f"cd {workplace}/metachain && git checkout {new_branch_name}" + result = subprocess.run(switch_branch_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"Failed to switch to new branch. Error: {result.stderr}") + else: + print(f"Successfully switched to new branch: {new_branch_name}") + else: + print(f"Successfully created and switched to new branch: {new_branch_name}") + + if existing_container.stdout.strip() == container_name: + # check if the container is running + running_check_command = ["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"] + running_container = subprocess.run(running_check_command, capture_output=True, text=True) + + if running_container.stdout.strip() == container_name: + print(f"Container '{container_name}' is already running. Skipping creation.") + return # container is already running, skip creation + else: + # container exists but is not running, start it + start_command = ["docker", "start", container_name] + subprocess.run(start_command) + print(f"Container '{container_name}' has been started.") + return + + # if the container does not exist, create and start a new container + docker_command = [ + "docker", "run", "-d", "--name", container_name, "--user", "root", + "-v", f"{workplace}:/{workplace_name}", + "-w", f"/{workplace_name}", "-p", "12345:12345", BASE_IMAGES, + "/bin/bash", "-c", + f"python3 /{workplace_name}/tcp_server.py --workplace {workplace_name}" + ] + # execute the docker command + result = subprocess.run(docker_command, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"Failed to start container: {result.stderr}") + if wait_for_container_ready(container_name, timeout=60): + print(f"Container '{container_name}' has been created and started.") + +def wait_for_container_ready(container_name, timeout=30): + """using subprocess to check if the container is running""" + start_time = time.time() + + while time.time() - start_time < timeout: + result = subprocess.run( + ["docker", "inspect", "--format", "{{.State.Running}}", container_name], + capture_output=True, + text=True + ) + + if result.returncode == 0 and "true" in result.stdout.lower(): + # 额外检查 tcp_server 是否运行 + try: + result = run_command_in_container('ps aux') + if "tcp_server.py" in result['result']: + return True + except Exception as e: + pass + + time.sleep(1) + + raise TimeoutError(f"Container {container_name} failed to start within {timeout} seconds") \ No newline at end of file diff --git a/metachain/environment/docker_env.py b/metachain/environment/docker_env.py new file mode 100644 index 0000000..a5d39bd --- /dev/null +++ b/metachain/environment/docker_env.py @@ -0,0 +1,275 @@ +import os +import os.path as osp +import subprocess +from constant import BASE_IMAGES, AI_USER, GITHUB_AI_TOKEN +import time +import socket +import json +from pathlib import Path +import shutil +wd = Path(__file__).parent.resolve() +from dataclasses import dataclass, field +from typing import Optional, Union, Dict +from functools import update_wrapper +from inspect import signature +@dataclass +class DockerConfig: + container_name: str + workplace_name: str + communication_port: int # 12345 + conda_path: str # /root/miniconda3 + test_pull_name: str = field(default='main') + task_name: Optional[str] = field(default=None) + git_clone: bool = field(default=False) + setup_package: Optional[str] = field(default=None) + local_root: str = field(default=os.getcwd()) + + +class DockerEnv: + def __init__(self, config: Union[DockerConfig, Dict]): + if isinstance(config, Dict): + config = DockerConfig(**config) + self.workplace_name = config.workplace_name + self.local_workplace = osp.join(config.local_root, config.workplace_name) + self.docker_workplace = f"/{config.workplace_name}" + self.container_name = config.container_name + self.test_pull_name = config.test_pull_name + self.task_name = config.task_name + self.git_clone = config.git_clone + self.setup_package = config.setup_package + self.communication_port = config.communication_port + self.conda_path = config.conda_path + + def init_container(self): + container_check_command = ["docker", "ps", "-a", "--filter", f"name={self.container_name}", "--format", "{{.Names}}"] + existing_container = subprocess.run(container_check_command, capture_output=True, text=True) + os.makedirs(self.local_workplace, exist_ok=True) + + if not osp.exists(osp.join(self.local_workplace, 'tcp_server.py')): + shutil.copy(osp.join(wd, 'tcp_server.py'), self.local_workplace) + assert osp.exists(osp.join(self.local_workplace, 'tcp_server.py')), "Failed to copy tcp_server.py to the local workplace" + if self.setup_package is not None: + unzip_command = ["tar", "-xzvf", f"packages/{self.setup_package}.tar.gz", "-C", self.local_workplace] + subprocess.run(unzip_command) + if self.git_clone: + if not os.path.exists(os.path.join(self.local_workplace, 'metachain')): + git_command = ["cd", self.local_workplace, "&&", "git", "clone", "-b", self.test_pull_name, f"https://{AI_USER}:{GITHUB_AI_TOKEN}@github.com/tjb-tech/metachain.git"] + git_command = " ".join(git_command) + + result = subprocess.run(git_command, shell=True) + if result.returncode != 0: + raise Exception(f"Failed to clone the repository. Please check your internet connection and try again.") + copy_env_command = f"cp .env {self.local_workplace}/metachain" + result = subprocess.run(copy_env_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"Failed to copy .env file to the metachain directory. Error: {result.stderr}") + # create a new branch + new_branch_name = f"{self.test_pull_name}_{self.task_name}" + create_branch_command = f"cd {self.local_workplace}/metachain && git checkout -b {new_branch_name}" + result = subprocess.run(create_branch_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + print(Exception(f"Failed to create and switch to new branch. Error: {result.stderr}")) + switch_branch_command = f"cd {self.local_workplace}/metachain && git checkout {new_branch_name}" + result = subprocess.run(switch_branch_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"Failed to switch to new branch. Error: {result.stderr}") + else: + print(f"Successfully switched to new branch: {new_branch_name}") + else: + print(f"Successfully created and switched to new branch: {new_branch_name}") + + if existing_container.stdout.strip() == self.container_name: + # check if the container is running + running_check_command = ["docker", "ps", "--filter", f"name={self.container_name}", "--format", "{{.Names}}"] + running_container = subprocess.run(running_check_command, capture_output=True, text=True) + + if running_container.stdout.strip() == self.container_name: + print(f"Container '{self.container_name}' is already running. Skipping creation.") + return # container is already running, skip creation + else: + # container exists but is not running, start it + start_command = ["docker", "start", self.container_name] + subprocess.run(start_command) + print(f"Container '{self.container_name}' has been started.") + return + + # if the container does not exist, create and start a new container + docker_command = [ + "docker", "run", "-d", "--name", self.container_name, "--user", "root", + "-v", f"{self.local_workplace}:{self.docker_workplace}", + "-w", f"{self.docker_workplace}", "-p", f"{self.communication_port}:{self.communication_port}", BASE_IMAGES, + "/bin/bash", "-c", + f"python3 {self.docker_workplace}/tcp_server.py --workplace {self.workplace_name} --conda_path {self.conda_path} --port {self.communication_port}" + ] + # execute the docker command + result = subprocess.run(docker_command, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"Failed to start container: {result.stderr}") + if self.wait_for_container_ready(timeout=60): + print(f"Container '{self.container_name}' has been created and started.") + def wait_for_container_ready(self, timeout=30): + """using subprocess to check if the container is running""" + start_time = time.time() + + while time.time() - start_time < timeout: + result = subprocess.run( + ["docker", "inspect", "--format", "{{.State.Running}}", self.container_name], + capture_output=True, + text=True + ) + + if result.returncode == 0 and "true" in result.stdout.lower(): + # 额外检查 tcp_server 是否运行 + try: + port_info = check_container_ports(self.container_name) + assert port_info and (port_info[0] == port_info[1]) + available_port = port_info[0] + self.communication_port = available_port + result = self.run_command('ps aux') + if "tcp_server.py" in result['result']: + return True + except Exception as e: + pass + + time.sleep(1) + + raise TimeoutError(f"Container {self.container_name} failed to start within {timeout} seconds") + def stop_container(self): + stop_command = ["docker", "stop", self.container_name] + result = subprocess.run(stop_command, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"Failed to stop container: {result.stderr}") + + def run_command(self, command, stream_callback=None): + """ + communicate with docker container and execute command, support stream output + + Args: + command: the command to execute + stream_callback: optional callback function, for handling stream output + the function signature should be callback(text: str) + + Returns: + dict: the complete JSON result returned by the docker container + """ + hostname = 'localhost' + port = self.communication_port + buffer_size = 4096 + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((hostname, port)) + s.sendall(command.encode()) + + partial_line = "" + while True: + chunk = s.recv(buffer_size) + if not chunk: + break + + # add new received data to the unfinished data + data = partial_line + chunk.decode('utf-8') + lines = data.split('\n') + + # except the last line, process all complete lines + for line in lines[:-1]: + if line: + try: + response = json.loads(line) + if response['type'] == 'chunk': + # process stream output + if stream_callback: + stream_callback(response['data']) + elif response['type'] == 'final': + # return the final result + return { + 'status': response['status'], + 'result': response['result'] + } + except json.JSONDecodeError: + print(f"Invalid JSON: {line}") + + # save the possibly unfinished last line + partial_line = lines[-1] + + # if the loop ends normally without receiving a final response + return { + 'status': -1, + 'result': 'Connection closed without final response' + } + +def with_env(env: DockerEnv): + """将env注入到工具函数中的装饰器""" + def decorator(func): + def wrapped(*args, **kwargs): + return func(env=env, *args, **kwargs) + + # 保留原始函数的所有属性 + update_wrapper(wrapped, func) + # 修改signature,移除env参数 + wrapped.__signature__ = signature(func).replace( + parameters=[p for p in signature(func).parameters.values() if p.name != 'env'] + ) + if func.__doc__: + try: + if '{docker_workplace}' in func.__doc__: + wrapped.__doc__ = func.__doc__.format(docker_workplace=env.docker_workplace) + else: + wrapped.__doc__ = func.__doc__ + if '{local_workplace}' in func.__doc__: + wrapped.__doc__ = func.__doc__.format(local_workplace=env.local_workplace) + else: + wrapped.__doc__ = func.__doc__ + except (KeyError, IndexError, ValueError): + # 如果格式化失败(没有占位符),保持原始文档 + wrapped.__doc__ = func.__doc__ + return wrapped + return decorator + +def check_container_ports(container_name: str): + """ + check if the container has port mapping + return format: + - if the container exists and has port mapping: '0.0.0.0:12345->12345/tcp' + - if the container does not exist or does not have port mapping: None + """ + # use docker ps to check the container and get the port information + container_check_command = [ + "docker", "ps", "-a", + "--filter", f"name={container_name}", + "--format", "{{.Ports}}" + ] + + result = subprocess.run(container_check_command, capture_output=True, text=True) + ports_info = result.stdout.strip() + + if not ports_info: + return None + + # only process the mapped ports + for mapping in ports_info.split(','): + mapping = mapping.strip() + if '->' in mapping: + # parse '0.0.0.0:12345->12345/tcp' to (12345, 12345) + host_part, container_part = mapping.split('->') + host_port = host_part.split(':')[1] # get '12345' from '0.0.0.0:12345' + container_port = container_part.split('/')[0] # get '12345' from '12345/tcp' + return (int(host_port), int(container_port)) # convert to integers + return None + +def check_container_exist(container_name: str): + container_check_command = [ + "docker", "ps", "-a", + "--filter", f"name={container_name}", + "--format", "{{.Names}}" + ] + result = subprocess.run(container_check_command, capture_output=True, text=True) + return container_name in result.stdout.strip() + +def check_container_running(container_name: str): + container_check_command = [ + "docker", "ps", + "--filter", f"name={container_name}", + "--format", "{{.Names}}" + ] + result = subprocess.run(container_check_command, capture_output=True, text=True) + return container_name in result.stdout.strip() diff --git a/metachain/environment/download_helper.py b/metachain/environment/download_helper.py new file mode 100644 index 0000000..d9c9a17 --- /dev/null +++ b/metachain/environment/download_helper.py @@ -0,0 +1,76 @@ +import os +import shutil +import time +from seleniumbase.config import settings +from seleniumbase.fixtures import constants + +# The "downloads_folder" is a folder for saving downloaded files. +# Works for downloads initiated by Chromium and Firefox WebDriver clicks. +# Browser type doesn't matter if using self.download_file(file_url) +# or self.save_file_as(file_url, new_file_name) +# The "downloads_folder" is cleaned out at the start of each pytest run, +# but there is an option to save existing files in "archived_files". +DOWNLOADS_DIR = constants.Files.DOWNLOADS_FOLDER +abs_path = os.path.abspath("./examples") +downloads_path = os.path.join(abs_path, DOWNLOADS_DIR) + + +def get_downloads_folder(): + return downloads_path + + +def reset_downloads_folder(): + """Clears the downloads folder. + If settings.ARCHIVE_EXISTING_DOWNLOADS is set to True, archives it.""" + downloads_dir = constants.Files.DOWNLOADS_FOLDER + archive_dir = constants.Files.ARCHIVED_DOWNLOADS_FOLDER + if downloads_dir.endswith("/"): + downloads_dir = downloads_dir[:-1] + if downloads_dir.startswith("/"): + downloads_dir = downloads_dir[1:] + if archive_dir.endswith("/"): + archive_dir = archive_dir[:-1] + if archive_dir.startswith("/"): + archive_dir = archive_dir[1:] + if len(downloads_dir) < 10 or len(archive_dir) < 10: + return # Prevent accidental deletions if constants are renamed + archived_downloads_folder = os.path.join(os.getcwd(), archive_dir) + os.sep + if os.path.exists(downloads_path) and not os.listdir(downloads_path) == []: + reset_downloads_folder_assistant(archived_downloads_folder) + if os.path.exists(downloads_path) and os.listdir(downloads_path) == []: + try: + os.rmdir(downloads_path) + except OSError: + pass + if ( + os.path.exists(archived_downloads_folder) + and os.listdir(archived_downloads_folder) == [] + ): + try: + os.rmdir(archived_downloads_folder) + except OSError: + pass + + +def reset_downloads_folder_assistant(archived_downloads_folder): + if not os.path.exists(archived_downloads_folder): + try: + os.makedirs(archived_downloads_folder, exist_ok=True) + except Exception: + pass # Should only be reachable during multi-threaded test runs + new_archived_downloads_sub_folder = "%s/downloads_%s" % ( + archived_downloads_folder, + int(time.time()), + ) + if os.path.exists(downloads_path): + if not os.listdir(downloads_path) == []: + try: + shutil.move(downloads_path, new_archived_downloads_sub_folder) + os.makedirs(downloads_path, exist_ok=True) + except Exception: + pass + if not settings.ARCHIVE_EXISTING_DOWNLOADS: + try: + shutil.rmtree(new_archived_downloads_sub_folder) + except OSError: + pass \ No newline at end of file diff --git a/metachain/environment/local_env.py b/metachain/environment/local_env.py new file mode 100644 index 0000000..3f3bc2a --- /dev/null +++ b/metachain/environment/local_env.py @@ -0,0 +1,90 @@ +import subprocess +import json +import os +from pathlib import Path +import platform +class LocalEnv: + def __init__(self): + self.docker_workplace = os.getcwd() + if self.docker_workplace.endswith("metachain"): + self.docker_workplace = os.path.dirname(self.docker_workplace) + self.local_workplace = self.docker_workplace + self.conda_sh = self._find_conda_sh() + def _find_conda_sh(self) -> str: + """ + Find conda.sh file location across different environments + """ + + # 1. Try common locations based on OS + possible_paths = [] + home = str(Path.home()) + + if platform.system() == "Windows": + possible_paths.extend([ + Path(home) / "Anaconda3" / "etc" / "profile.d" / "conda.sh", + Path(home) / "miniconda3" / "etc" / "profile.d" / "conda.sh", + Path(home) / "micromamba" / "etc" / "profile.d" / "conda.sh", + ]) + else: # Linux and MacOS + possible_paths.extend([ + Path(home) / "anaconda3" / "etc" / "profile.d" / "conda.sh", + Path(home) / "miniconda3" / "etc" / "profile.d" / "conda.sh", + Path(home) / "micromamba" / "etc" / "profile.d" / "conda.sh", + Path("/opt/conda/etc/profile.d/conda.sh"), # Docker containers + Path("/usr/local/conda/etc/profile.d/conda.sh"), + ]) + + # For Linux, also check root installations + if platform.system() == "Linux": + possible_paths.extend([ + Path("/opt/anaconda3/etc/profile.d/conda.sh"), + Path("/opt/miniconda3/etc/profile.d/conda.sh"), + Path("/opt/micromamba/etc/profile.d/conda.sh"), + ]) + + # Check all possible paths + for path in possible_paths: + if path.exists(): + return str(path) + + # 2. Try to find using conda info command + try: + result = subprocess.run(['conda', 'info', '--base'], + capture_output=True, + text=True) + if result.returncode == 0: + base_path = result.stdout.strip() + conda_sh = Path(base_path) / "etc" / "profile.d" / "conda.sh" + if conda_sh.exists(): + return str(conda_sh) + except: + pass + + # 3. If all fails, return None and handle in run_command + return None + def run_command(self, command, stream_callback=None): + assert self.conda_sh is not None, "Conda.sh not found" + modified_command = f"/bin/bash -c 'source {self.conda_sh} && conda activate browser && cd {self.docker_workplace} && {command}'" + process = subprocess.Popen(modified_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + output = '' + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + output += line + # 立即发送每一行输出 + + # 发送最终的完整响应 + response = { + "status": process.poll(), + "result": output + } + return response + def _convert_local_to_docker(self, path): + return path + + def _convert_docker_to_local(self, path): + return path + +if __name__ == "__main__": + print(str(Path.home())) \ No newline at end of file diff --git a/metachain/environment/markdown_browser/__init__.py b/metachain/environment/markdown_browser/__init__.py new file mode 100644 index 0000000..5b6880e --- /dev/null +++ b/metachain/environment/markdown_browser/__init__.py @@ -0,0 +1,22 @@ +from .abstract_markdown_browser import AbstractMarkdownBrowser +from .markdown_search import AbstractMarkdownSearch, BingMarkdownSearch + +# TODO: Fix mdconvert +from .mdconvert import ( # type: ignore + DocumentConverterResult, + FileConversionException, + MarkdownConverter, + UnsupportedFormatException, +) +from .requests_markdown_browser import RequestsMarkdownBrowser + +__all__ = ( + "AbstractMarkdownBrowser", + "RequestsMarkdownBrowser", + "AbstractMarkdownSearch", + "BingMarkdownSearch", + "MarkdownConverter", + "UnsupportedFormatException", + "FileConversionException", + "DocumentConverterResult", +) diff --git a/metachain/environment/markdown_browser/abstract_markdown_browser.py b/metachain/environment/markdown_browser/abstract_markdown_browser.py new file mode 100644 index 0000000..70417a6 --- /dev/null +++ b/metachain/environment/markdown_browser/abstract_markdown_browser.py @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod +from typing import Union + + +class AbstractMarkdownBrowser(ABC): + """ + An abstract class for a Markdown web browser. + + All MarkdownBrowers work by: + + (1) fetching a web page by URL (via requests, Selenium, Playwright, etc.) + (2) converting the page's HTML or DOM to Markdown + (3) operating on the Markdown + + Such browsers are simple, and suitable for read-only agentic use. + They cannot be used to interact with complex web applications. + """ + + @abstractmethod + def __init__(self) -> None: + pass + + @property + @abstractmethod + def address(self) -> str: + pass + + @abstractmethod + def set_address(self, uri_or_path: str) -> None: + pass + + @property + @abstractmethod + def viewport(self) -> str: + pass + + @property + @abstractmethod + def page_content(self) -> str: + pass + + @abstractmethod + def page_down(self) -> None: + pass + + @abstractmethod + def page_up(self) -> None: + pass + + @abstractmethod + def visit_page(self, path_or_uri: str) -> str: + pass + + @abstractmethod + def open_local_file(self, local_path: str) -> str: + pass + + @abstractmethod + def find_on_page(self, query: str) -> Union[str, None]: + pass + + @abstractmethod + def find_next(self) -> Union[str, None]: + pass diff --git a/metachain/environment/markdown_browser/markdown_search.py b/metachain/environment/markdown_browser/markdown_search.py new file mode 100644 index 0000000..1ce56f5 --- /dev/null +++ b/metachain/environment/markdown_browser/markdown_search.py @@ -0,0 +1,282 @@ +import logging +import os +import re +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, cast +from urllib.parse import quote, quote_plus, unquote, urlparse, urlunparse + +import requests + +# TODO: Fix these types +from .mdconvert import MarkdownConverter # type: ignore + +logger = logging.getLogger(__name__) + + +class AbstractMarkdownSearch(ABC): + """ + An abstract class for providing search capabilities to a Markdown browser. + """ + + @abstractmethod + def search(self, query: str) -> str: + pass + + +class BingMarkdownSearch(AbstractMarkdownSearch): + """ + Provides Bing web search capabilities to Markdown browsers. + """ + + def __init__(self, bing_api_key: Optional[str] = None, interleave_results: bool = True): + """ + Perform a Bing web search, and return the results formatted in Markdown. + + Args: + bing_api_key: key for the Bing search API. If omitted, an attempt is made to read the key from the BING_API_KEY environment variable. If no key is found, BingMarkdownSearch will print a warning, and will fall back to visiting and scraping the live Bing results page. Scraping is objectively worse than using the API, and thus is not recommended. + interleave_results: When using the Bing API, results are returned based on category (web, news, videos, etc.), along with instructions for how they should be interleaved on the page. When `interleave` is set to True, these interleaving instructions are followed, and a single results list is returned by BingMarkdownSearch. When `interleave` is set to false, results are separated by category, and no interleaving is done. + """ + self._mdconvert = MarkdownConverter() + self._interleave_results = interleave_results + + if bing_api_key is None or bing_api_key.strip() == "": + self._bing_api_key = os.environ.get("BING_API_KEY") + else: + self._bing_api_key = bing_api_key + + if self._bing_api_key is None: + if not self._interleave_results: + raise ValueError( + "No Bing API key was provided. This is incompatible with setting `interleave_results` to False. Please provide a key, or set `interleave_results` to True." + ) + + # logger.warning( + # "Warning: No Bing API key provided. BingMarkdownSearch will submit an HTTP request to the Bing landing page, but results may be missing or low quality. To resolve this warning, provide a Bing API key by setting the BING_API_KEY environment variable, or using the 'bing_api_key' parameter in by BingMarkdownSearch's constructor. Bing API keys can be obtained via https://www.microsoft.com/en-us/bing/apis/bing-web-search-api\n" + # ) + + def search(self, query: str) -> str: + """Search Bing and return the results formatted in Markdown. If a Bing API key is available, the API is used to perform the search. If no API key is available, the search is performed by submitting an HTTPs GET request directly to Bing. Searches performed with the API are much higher quality, and are more reliable. + + Args: + query: The search query to issue + + Returns: + A Markdown rendering of the search results. + """ + + if self._bing_api_key is None: + return self._fallback_search(query) + else: + return self._api_search(query) + + def _api_search(self, query: str) -> str: + """Search Bing using the API, and return the results formatted in Markdown. + + Args: + query: The search query to issue + + Returns: + A Markdown rendering of the search results. + """ + results = self._bing_api_call(query) + + snippets: Dict[str, List[str]] = dict() + + def _processFacts(elm: List[Dict[str, Any]]) -> str: + facts: List[str] = list() + for e in elm: + k = e["label"]["text"] + v = " ".join(item["text"] for item in e["items"]) + facts.append(f"{k}: {v}") + return "\n".join(facts) + + # Web pages + # __POS__ is a placeholder for the final ranking position, added at the end + web_snippets: List[str] = list() + if "webPages" in results: + for page in results["webPages"]["value"]: + snippet = f"__POS__. {self._markdown_link(page['name'], page['url'])}\n{page['snippet']}" + + if "richFacts" in page: + snippet += "\n" + _processFacts(page["richFacts"]) + + if "mentions" in page: + snippet += "\nMentions: " + ", ".join(e["name"] for e in page["mentions"]) + + if page["id"] not in snippets: + snippets[page["id"]] = list() + snippets[page["id"]].append(snippet) + web_snippets.append(snippet) + + if "deepLinks" in page: + for dl in page["deepLinks"]: + deep_snippet = f"__POS__. {self._markdown_link(dl['name'], dl['url'])}\n{dl['snippet'] if 'snippet' in dl else ''}" + snippets[page["id"]].append(deep_snippet) + web_snippets.append(deep_snippet) + + # News results + news_snippets: List[str] = list() + if "news" in results: + for page in results["news"]["value"]: + snippet = ( + f"__POS__. {self._markdown_link(page['name'], page['url'])}\n{page.get('description', '')}".strip() + ) + + if "datePublished" in page: + snippet += "\nDate published: " + page["datePublished"].split("T")[0] + + if "richFacts" in page: + snippet += "\n" + _processFacts(page["richFacts"]) + + if "mentions" in page: + snippet += "\nMentions: " + ", ".join(e["name"] for e in page["mentions"]) + + news_snippets.append(snippet) + + if len(news_snippets) > 0: + snippets[results["news"]["id"]] = news_snippets + + # Videos + video_snippets: List[str] = list() + if "videos" in results: + for page in results["videos"]["value"]: + if not page["contentUrl"].startswith("https://www.youtube.com/watch?v="): + continue + + snippet = f"__POS__. {self._markdown_link(page['name'], page['contentUrl'])}\n{page.get('description', '')}".strip() + + if "datePublished" in page: + snippet += "\nDate published: " + page["datePublished"].split("T")[0] + + if "richFacts" in page: + snippet += "\n" + _processFacts(page["richFacts"]) + + if "mentions" in page: + snippet += "\nMentions: " + ", ".join(e["name"] for e in page["mentions"]) + + video_snippets.append(snippet) + + if len(video_snippets) > 0: + snippets[results["videos"]["id"]] = video_snippets + + # Related searches + related_searches = "" + if "relatedSearches" in results: + related_searches = "## Related Searches:\n" + for s in results["relatedSearches"]["value"]: + related_searches += "- " + s["text"] + "\n" + snippets[results["relatedSearches"]["id"]] = [related_searches.strip()] + + idx = 0 + content = "" + if self._interleave_results: + # Interleaved + for item in results["rankingResponse"]["mainline"]["items"]: + _id = item["value"]["id"] + if _id in snippets: + for s in snippets[_id]: + if "__POS__" in s: + idx += 1 + content += s.replace("__POS__", str(idx)) + "\n\n" + else: + content += s + "\n\n" + else: + # Categorized + if len(web_snippets) > 0: + content += "## Web Results\n\n" + for s in web_snippets: + if "__POS__" in s: + idx += 1 + content += s.replace("__POS__", str(idx)) + "\n\n" + else: + content += s + "\n\n" + if len(news_snippets) > 0: + content += "## News Results\n\n" + for s in news_snippets: + if "__POS__" in s: + idx += 1 + content += s.replace("__POS__", str(idx)) + "\n\n" + else: + content += s + "\n\n" + if len(video_snippets) > 0: + content += "## Video Results\n\n" + for s in video_snippets: + if "__POS__" in s: + idx += 1 + content += s.replace("__POS__", str(idx)) + "\n\n" + else: + content += s + "\n\n" + if len(related_searches) > 0: + content += related_searches + + return f"## A Bing search for '{query}' found {idx} results:\n\n" + content.strip() + + def _bing_api_call(self, query: str) -> Dict[str, Any]: + """Make a Bing API call, and return a Python representation of the JSON response." + + Args: + query: The search query to issue + + Returns: + A Python representation of the Bing API's JSON response (as parsed by `json.loads()`). + """ + # Make sure the key was set + if not self._bing_api_key: + raise ValueError("Missing Bing API key.") + + # Prepare the request parameters + request_kwargs: Dict[str, Any] = {} + request_kwargs["headers"] = {} + request_kwargs["headers"]["Ocp-Apim-Subscription-Key"] = self._bing_api_key + + request_kwargs["params"] = {} + request_kwargs["params"]["q"] = query + request_kwargs["params"]["textDecorations"] = False + request_kwargs["params"]["textFormat"] = "raw" + + request_kwargs["stream"] = False + + # Make the request + response = requests.get("https://api.bing.microsoft.com/v7.0/search", **request_kwargs) + response.raise_for_status() + results = response.json() + + return cast(Dict[str, Any], results) + + def _fallback_search(self, query: str) -> str: + """When no Bing API key is provided, we issue a simple HTTPs GET call to the Bing landing page and convert it to Markdown. + + Args: + query: The search query to issue + + Returns: + The Bing search results page, converted to Markdown. + """ + user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0" + headers = {"User-Agent": user_agent} + + url = f"https://www.bing.com/search?q={quote_plus(query)}&FORM=QBLH" + response = requests.get(url, headers=headers) + response.raise_for_status() + # TODO: Fix the types + return self._mdconvert.convert_response(response).text_content # type: ignore + + def _markdown_link(self, anchor: str, href: str) -> str: + """Create a Markdown hyperlink, escaping the URLs as appropriate. + + Args: + anchor: The anchor text of the hyperlink + href: The href destination of the hyperlink + + Returns: + A correctly-formatted Markdown hyperlink + """ + try: + parsed_url = urlparse(href) + # URLs provided by Bing are sometimes only partially quoted, leaving in characters + # the conflict with Markdown. We unquote the URL, and then re-quote more completely + href = urlunparse(parsed_url._replace(path=quote(unquote(parsed_url.path)))) + anchor = re.sub(r"[\[\]]", " ", anchor) + return f"[{anchor}]({href})" + except ValueError: # It's not clear if this ever gets thrown + return f"[{anchor}]({href})" diff --git a/metachain/environment/markdown_browser/mdconvert.py b/metachain/environment/markdown_browser/mdconvert.py new file mode 100644 index 0000000..196c452 --- /dev/null +++ b/metachain/environment/markdown_browser/mdconvert.py @@ -0,0 +1,1033 @@ +# type: ignore +import base64 +import binascii +import copy +import html +import json +import mimetypes +import os +import re +import shutil +import subprocess +import sys +import tempfile +from typing import Any, Dict, List, Optional, Union +from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse + +import mammoth +import markdownify +import pandas as pd +import pdfminer +import pdfminer.high_level +import pptx +from docling.document_converter import DocumentConverter as DocLingDocumentConverter + +# File-format detection +import puremagic +import requests +from bs4 import BeautifulSoup + +# Optional Transcription support +try: + import pydub + import speech_recognition as sr + + IS_AUDIO_TRANSCRIPTION_CAPABLE = True +except ModuleNotFoundError: + pass + +# Optional YouTube transcription support +try: + from youtube_transcript_api import YouTubeTranscriptApi + + IS_YOUTUBE_TRANSCRIPT_CAPABLE = True +except ModuleNotFoundError: + pass + + +class _CustomMarkdownify(markdownify.MarkdownConverter): + """ + A custom version of markdownify's MarkdownConverter. Changes include: + + - Altering the default heading style to use '#', '##', etc. + - Removing javascript hyperlinks. + - Truncating images with large data:uri sources. + - Ensuring URIs are properly escaped, and do not conflict with Markdown syntax + """ + + def __init__(self, **options: Any): + options["heading_style"] = options.get("heading_style", markdownify.ATX) + # Explicitly cast options to the expected type if necessary + super().__init__(**options) + + def convert_hn(self, n: int, el: Any, text: str, convert_as_inline: bool) -> str: + """Same as usual, but be sure to start with a new line""" + if not convert_as_inline: + if not re.search(r"^\n", text): + return "\n" + super().convert_hn(n, el, text, convert_as_inline) # type: ignore + + return super().convert_hn(n, el, text, convert_as_inline) # type: ignore + + def convert_a(self, el: Any, text: str, convert_as_inline: bool): + """Same as usual converter, but removes Javascript links and escapes URIs.""" + prefix, suffix, text = markdownify.chomp(text) # type: ignore + if not text: + return "" + href = el.get("href") + title = el.get("title") + + # Escape URIs and skip non-http or file schemes + if href: + try: + parsed_url = urlparse(href) # type: ignore + if parsed_url.scheme and parsed_url.scheme.lower() not in ["http", "https", "file"]: # type: ignore + return "%s%s%s" % (prefix, text, suffix) + href = urlunparse(parsed_url._replace(path=quote(unquote(parsed_url.path)))) # type: ignore + except ValueError: # It's not clear if this ever gets thrown + return "%s%s%s" % (prefix, text, suffix) + + # For the replacement see #29: text nodes underscores are escaped + if ( + self.options["autolinks"] + and text.replace(r"\_", "_") == href + and not title + and not self.options["default_title"] + ): + # Shortcut syntax + return "<%s>" % href + if self.options["default_title"] and not title: + title = href + title_part = ' "%s"' % title.replace('"', r"\"") if title else "" + return "%s[%s](%s%s)%s" % (prefix, text, href, title_part, suffix) if href else text + + def convert_img(self, el: Any, text: str, convert_as_inline: bool) -> str: + """Same as usual converter, but removes data URIs""" + + alt = el.attrs.get("alt", None) or "" + src = el.attrs.get("src", None) or "" + title = el.attrs.get("title", None) or "" + title_part = ' "%s"' % title.replace('"', r"\"") if title else "" + if convert_as_inline and el.parent.name not in self.options["keep_inline_images_in"]: + return alt + + # Remove dataURIs + if src.startswith("data:"): + src = src.split(",")[0] + "..." + + return "![%s](%s%s)" % (alt, src, title_part) + + def convert_soup(self, soup: Any) -> str: + return super().convert_soup(soup) # type: ignore + + +class DocumentConverterResult: + """The result of converting a document to text.""" + + def __init__(self, title: Union[str, None] = None, text_content: str = ""): + self.title: Union[str, None] = title + self.text_content: str = text_content + + +class DocumentConverter: + """Abstract superclass of all DocumentConverters.""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + raise NotImplementedError() + + +class PlainTextConverter(DocumentConverter): + """Anything with content type text/plain""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Guess the content type from any file extension that might be around + content_type, _ = mimetypes.guess_type("__placeholder" + kwargs.get("file_extension", "")) + + # Only accept text files + if content_type is None: + return None + elif "text/" not in content_type.lower(): + return None + + text_content = "" + with open(local_path, "rt", encoding="utf-8") as fh: + text_content = fh.read() + return DocumentConverterResult( + title=None, + text_content=text_content, + ) + + +class HtmlConverter(DocumentConverter): + """Anything with content type text/html""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Bail if not html + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + + result = None + with open(local_path, "rt", encoding="utf-8") as fh: + result = self._convert(fh.read()) + + return result + + def _convert(self, html_content: str) -> Union[None, DocumentConverterResult]: + """Helper function that converts and HTML string.""" + + # Parse the string + soup = BeautifulSoup(html_content, "html.parser") + + # Remove javascript and style blocks + for script in soup(["script", "style"]): + script.extract() + + # Print only the main content + body_elm = soup.find("body") + webpage_text = "" + if body_elm: + webpage_text = _CustomMarkdownify().convert_soup(body_elm) + else: + webpage_text = _CustomMarkdownify().convert_soup(soup) + + assert isinstance(webpage_text, str) + + return DocumentConverterResult( + title=None if soup.title is None else soup.title.string, text_content=webpage_text + ) + + +class WikipediaConverter(DocumentConverter): + """Handle Wikipedia pages separately, focusing only on the main document content.""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Bail if not Wikipedia + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + url = kwargs.get("url", "") + if not re.search(r"^https?:\/\/[a-zA-Z]{2,3}\.wikipedia.org\/", url): + return None + + # Parse the file + soup = None + with open(local_path, "rt", encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + + # Remove javascript and style blocks + for script in soup(["script", "style"]): + script.extract() + + # Print only the main content + body_elm = soup.find("div", {"id": "mw-content-text"}) + title_elm = soup.find("span", {"class": "mw-page-title-main"}) + + webpage_text = "" + main_title = None if soup.title is None else soup.title.string + + if body_elm: + # What's the title + if title_elm and len(title_elm) > 0: + main_title = title_elm.string # type: ignore + assert isinstance(main_title, str) + + # Convert the page + webpage_text = f"# {main_title}\n\n" + _CustomMarkdownify().convert_soup(body_elm) + else: + webpage_text = _CustomMarkdownify().convert_soup(soup) + + return DocumentConverterResult( + title=main_title, + text_content=webpage_text, + ) + + +class YouTubeConverter(DocumentConverter): + """Handle YouTube specially, focusing on the video title, description, and transcript.""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Bail if not YouTube + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + url = kwargs.get("url", "") + if not url.startswith("https://www.youtube.com/watch?"): + return None + + # Parse the file + soup = None + with open(local_path, "rt", encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + + # Read the meta tags + assert soup.title is not None and soup.title.string is not None + metadata: Dict[str, str] = {"title": soup.title.string} + for meta in soup(["meta"]): + for a in meta.attrs: + if a in ["itemprop", "property", "name"]: + metadata[meta[a]] = meta.get("content", "") + break + + # We can also try to read the full description. This is more prone to breaking, since it reaches into the page implementation + try: + for script in soup(["script"]): + content = script.text + if "ytInitialData" in content: + lines = re.split(r"\r?\n", content) + obj_start = lines[0].find("{") + obj_end = lines[0].rfind("}") + if obj_start >= 0 and obj_end >= 0: + data = json.loads(lines[0][obj_start : obj_end + 1]) + attrdesc = self._findKey(data, "attributedDescriptionBodyText") # type: ignore + if attrdesc: + metadata["description"] = str(attrdesc["content"]) + break + except Exception: + pass + + # Start preparing the page + webpage_text = "# YouTube\n" + + title = self._get(metadata, ["title", "og:title", "name"]) # type: ignore + assert isinstance(title, str) + + if title: + webpage_text += f"\n## {title}\n" + + stats = "" + views = self._get(metadata, ["interactionCount"]) # type: ignore + if views: + stats += f"- **Views:** {views}\n" + + keywords = self._get(metadata, ["keywords"]) # type: ignore + if keywords: + stats += f"- **Keywords:** {keywords}\n" + + runtime = self._get(metadata, ["duration"]) # type: ignore + if runtime: + stats += f"- **Runtime:** {runtime}\n" + + if len(stats) > 0: + webpage_text += f"\n### Video Metadata\n{stats}\n" + + description = self._get(metadata, ["description", "og:description"]) # type: ignore + if description: + webpage_text += f"\n### Description\n{description}\n" + + if IS_YOUTUBE_TRANSCRIPT_CAPABLE: + transcript_text = "" + parsed_url = urlparse(url) # type: ignore + params = parse_qs(parsed_url.query) # type: ignore + if "v" in params: + assert isinstance(params["v"][0], str) + video_id = str(params["v"][0]) + try: + # Must be a single transcript. + transcript = YouTubeTranscriptApi.get_transcript(video_id) # type: ignore + transcript_text = " ".join([part["text"] for part in transcript]) # type: ignore + # Alternative formatting: + # formatter = TextFormatter() + # formatter.format_transcript(transcript) + except Exception: + pass + if transcript_text: + webpage_text += f"\n### Transcript\n{transcript_text}\n" + + title = title if title else soup.title.string + assert isinstance(title, str) + + return DocumentConverterResult( + title=title, + text_content=webpage_text, + ) + + def _get(self, metadata: Dict[str, str], keys: List[str], default: Union[str, None] = None) -> Union[str, None]: + for k in keys: + if k in metadata: + return metadata[k] + return default + + def _findKey(self, json: Any, key: str) -> Union[str, None]: # TODO: Fix json type + if isinstance(json, list): + for elm in json: + ret = self._findKey(elm, key) + if ret is not None: + return ret + elif isinstance(json, dict): + for k in json: + if k == key: + return json[k] + else: + ret = self._findKey(json[k], key) + if ret is not None: + return ret + return None + + +class BingSerpConverter(DocumentConverter): + """ + Handle Bing results pages (only the organic search results). + NOTE: It is better to use the Bing API + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a Bing SERP + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + url = kwargs.get("url", "") + if not re.search(r"^https://www\.bing\.com/search\?q=", url): + return None + + # Parse the query parameters + parsed_params = parse_qs(urlparse(url).query) + query = parsed_params.get("q", [""])[0] + + # Parse the file + soup = None + with open(local_path, "rt", encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + + # Clean up some formatting + for tptt in soup.find_all(class_="tptt"): + if hasattr(tptt, "string") and tptt.string: + tptt.string += " " + for slug in soup.find_all(class_="algoSlug_icon"): + slug.extract() + + # Parse the algorithmic results + _markdownify = _CustomMarkdownify() + results = list() + for result in soup.find_all(class_="b_algo"): + # Rewrite redirect urls + for a in result.find_all("a", href=True): + parsed_href = urlparse(a["href"]) + qs = parse_qs(parsed_href.query) + + # The destination is contained in the u parameter, + # but appears to be base64 encoded, with some prefix + if "u" in qs: + u = qs["u"][0][2:].strip() + "==" # Python 3 doesn't care about extra padding + + try: + # RFC 4648 / Base64URL" variant, which uses "-" and "_" + a["href"] = base64.b64decode(u, altchars="-_").decode("utf-8") + except UnicodeDecodeError: + pass + except binascii.Error: + pass + + # Convert to markdown + md_result = _markdownify.convert_soup(result).strip() + lines = [line.strip() for line in re.split(r"\n+", md_result)] + results.append("\n".join([line for line in lines if len(line) > 0])) + + webpage_text = f"## A Bing search for '{query}' found the following results:\n\n" + "\n\n".join(results) + + return DocumentConverterResult( + title=None if soup.title is None else soup.title.string, + text_content=webpage_text, + ) + + +class PdfConverter(DocumentConverter): + """ + Converts PDFs to Markdown. Most style information is ignored, so the results are essentially plain-text. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a PDF + extension = kwargs.get("file_extension", "") + if extension.lower() != ".pdf": + return None + converter = DocLingDocumentConverter() + result = converter.convert(local_path) + text_content = result.document.export_to_markdown() + return DocumentConverterResult( + title=None, + # text_content=pdfminer.high_level.extract_text(local_path), + text_content=text_content + ) + + +class DocxConverter(HtmlConverter): + """ + Converts DOCX files to Markdown. Style information (e.g.m headings) and tables are preserved where possible. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a DOCX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".docx": + return None + + result = None + with open(local_path, "rb") as docx_file: + result = mammoth.convert_to_html(docx_file) + html_content = result.value + result = self._convert(html_content) + + return result + + +class XlsxConverter(HtmlConverter): + """ + Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a XLSX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".xlsx": + return None + + sheets = pd.read_excel(local_path, sheet_name=None) + md_content = "" + for s in sheets: + md_content += f"## {s}\n" + html_content = sheets[s].to_html(index=False) + md_content += self._convert(html_content).text_content.strip() + "\n\n" + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + +class PptxConverter(HtmlConverter): + """ + Converts PPTX files to Markdown. Supports heading, tables and images with alt text. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a PPTX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".pptx": + return None + + md_content = "" + + presentation = pptx.Presentation(local_path) + slide_num = 0 + for slide in presentation.slides: + slide_num += 1 + + md_content += f"\n\n\n" + + title = slide.shapes.title + for shape in slide.shapes: + # Pictures + if self._is_picture(shape): + # https://github.com/scanny/python-pptx/pull/512#issuecomment-1713100069 + alt_text = "" + try: + alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "") + except Exception: + pass + + # A placeholder name + filename = re.sub(r"\W", "", shape.name) + ".jpg" + md_content += "\n![" + (alt_text if alt_text else shape.name) + "](" + filename + ")\n" + + # Tables + if self._is_table(shape): + html_table = "" + first_row = True + for row in shape.table.rows: + html_table += "" + for cell in row.cells: + if first_row: + html_table += "" + else: + html_table += "" + html_table += "" + first_row = False + html_table += "
" + html.escape(cell.text) + "" + html.escape(cell.text) + "
" + md_content += "\n" + self._convert(html_table).text_content.strip() + "\n" + + # Text areas + elif shape.has_text_frame: + if shape == title: + md_content += "# " + shape.text.lstrip() + "\n" + else: + md_content += shape.text + "\n" + + md_content = md_content.strip() + + if slide.has_notes_slide: + md_content += "\n\n### Notes:\n" + notes_frame = slide.notes_slide.notes_text_frame + if notes_frame is not None: + md_content += notes_frame.text + md_content = md_content.strip() + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + def _is_picture(self, shape): + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE: + return True + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PLACEHOLDER: + if hasattr(shape, "image"): + return True + return False + + def _is_table(self, shape): + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE: + return True + return False + + +class MediaConverter(DocumentConverter): + """ + Abstract class for multi-modal media (e.g., images and audio) + """ + + def _get_metadata(self, local_path): + exiftool = shutil.which("exiftool") + if not exiftool: + return None + else: + try: + result = subprocess.run([exiftool, "-json", local_path], capture_output=True, text=True).stdout + return json.loads(result)[0] + except Exception: + return None + + +class WavConverter(MediaConverter): + """ + Converts WAV files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` is installed). + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a XLSX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".wav": + return None + + md_content = "" + + # Add metadata + metadata = self._get_metadata(local_path) + if metadata: + for f in [ + "Title", + "Artist", + "Author", + "Band", + "Album", + "Genre", + "Track", + "DateTimeOriginal", + "CreateDate", + "Duration", + ]: + if f in metadata: + md_content += f"{f}: {metadata[f]}\n" + + # Transcribe + if IS_AUDIO_TRANSCRIPTION_CAPABLE: + try: + transcript = self._transcribe_audio(local_path) + md_content += "\n\n### Audio Transcript:\n" + ( + "[No speech detected]" if transcript == "" else transcript + ) + except Exception: + md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + def _transcribe_audio(self, local_path) -> str: + recognizer = sr.Recognizer() + with sr.AudioFile(local_path) as source: + audio = recognizer.record(source) + return recognizer.recognize_google(audio).strip() + + +class Mp3Converter(WavConverter): + """ + Converts MP3 files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` AND `pydub` are installed). + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a MP3 + extension = kwargs.get("file_extension", "") + if extension.lower() != ".mp3": + return None + + md_content = "" + + # Add metadata + metadata = self._get_metadata(local_path) + if metadata: + for f in [ + "Title", + "Artist", + "Author", + "Band", + "Album", + "Genre", + "Track", + "DateTimeOriginal", + "CreateDate", + "Duration", + ]: + if f in metadata: + md_content += f"{f}: {metadata[f]}\n" + + # Transcribe + if IS_AUDIO_TRANSCRIPTION_CAPABLE: + handle, temp_path = tempfile.mkstemp(suffix=".wav") + os.close(handle) + try: + sound = pydub.AudioSegment.from_mp3(local_path) + sound.export(temp_path, format="wav") + + _args = dict() + _args.update(kwargs) + _args["file_extension"] = ".wav" + + try: + transcript = super()._transcribe_audio(temp_path).strip() + md_content += "\n\n### Audio Transcript:\n" + ( + "[No speech detected]" if transcript == "" else transcript + ) + except Exception: + md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + + finally: + os.unlink(temp_path) + + # Return the result + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + +class ImageConverter(MediaConverter): + """ + Converts images to markdown via extraction of metadata (if `exiftool` is installed), OCR (if `easyocr` is installed), and description via a multimodal LLM (if an mlm_client is configured). + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a XLSX + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".jpg", ".jpeg", ".png"]: + return None + + md_content = "" + + # Add metadata + metadata = self._get_metadata(local_path) + if metadata: + for f in [ + "ImageSize", + "Title", + "Caption", + "Description", + "Keywords", + "Artist", + "Author", + "DateTimeOriginal", + "CreateDate", + "GPSPosition", + ]: + if f in metadata: + md_content += f"{f}: {metadata[f]}\n" + + # Try describing the image with GPTV + mlm_client = kwargs.get("mlm_client") + mlm_model = kwargs.get("mlm_model") + if mlm_client is not None and mlm_model is not None: + md_content += ( + "\n# Description:\n" + + self._get_mlm_description( + local_path, extension, mlm_client, mlm_model, prompt=kwargs.get("mlm_prompt") + ).strip() + + "\n" + ) + + return DocumentConverterResult( + title=None, + text_content=md_content, + ) + + def _get_mlm_description(self, local_path, extension, client, model, prompt=None): + if prompt is None or prompt.strip() == "": + prompt = "Write a detailed caption for this image." + + sys.stderr.write(f"MLM Prompt:\n{prompt}\n") + + data_uri = "" + with open(local_path, "rb") as image_file: + content_type, encoding = mimetypes.guess_type("_dummy" + extension) + if content_type is None: + content_type = "image/jpeg" + image_base64 = base64.b64encode(image_file.read()).decode("utf-8") + data_uri = f"data:{content_type};base64,{image_base64}" + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": { + "url": data_uri, + }, + }, + ], + } + ] + + response = client.chat.completions.create(model=model, messages=messages) + return response.choices[0].message.content + + +class FileConversionException(BaseException): + pass + + +class UnsupportedFormatException(BaseException): + pass + + +class MarkdownConverter: + """(In preview) An extremely simple text-based document reader, suitable for LLM use. + This reader will convert common file-types or webpages to Markdown.""" + + def __init__( + self, + requests_session: Optional[requests.Session] = None, + mlm_client: Optional[Any] = None, + mlm_model: Optional[Any] = None, + ): + if requests_session is None: + self._requests_session = requests.Session() + else: + self._requests_session = requests_session + + self._mlm_client = mlm_client + self._mlm_model = mlm_model + + self._page_converters: List[DocumentConverter] = [] + + # Register converters for successful browsing operations + # Later registrations are tried first / take higher priority than earlier registrations + # To this end, the most specific converters should appear below the most generic converters + self.register_page_converter(PlainTextConverter()) + self.register_page_converter(HtmlConverter()) + self.register_page_converter(WikipediaConverter()) + self.register_page_converter(YouTubeConverter()) + self.register_page_converter(BingSerpConverter()) + self.register_page_converter(DocxConverter()) + self.register_page_converter(XlsxConverter()) + self.register_page_converter(PptxConverter()) + self.register_page_converter(WavConverter()) + self.register_page_converter(Mp3Converter()) + self.register_page_converter(ImageConverter()) + self.register_page_converter(PdfConverter()) + + def convert( + self, source: Union[str, requests.Response], **kwargs: Any + ) -> DocumentConverterResult: # TODO: deal with kwargs + """ + Args: + - source: can be a string representing a path or url, or a requests.response object + - extension: specifies the file extension to use when interpreting the file. If None, infer from source (path, uri, content-type, etc.) + """ + + # Local path or url + if isinstance(source, str): + if source.startswith("http://") or source.startswith("https://") or source.startswith("file://"): + return self.convert_url(source, **kwargs) + else: + return self.convert_local(source, **kwargs) + # Request response + elif isinstance(source, requests.Response): + return self.convert_response(source, **kwargs) + + def convert_local(self, path: str, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs + # Prepare a list of extensions to try (in order of priority) + ext = kwargs.get("file_extension") + extensions = [ext] if ext is not None else [] + + # Get extension alternatives from the path and puremagic + base, ext = os.path.splitext(path) + self._append_ext(extensions, ext) + self._append_ext(extensions, self._guess_ext_magic(path)) + + # Convert + return self._convert(path, extensions, **kwargs) + + # TODO what should stream's type be? + def convert_stream(self, stream: Any, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs + # Prepare a list of extensions to try (in order of priority) + ext = kwargs.get("file_extension") + extensions = [ext] if ext is not None else [] + + # Save the file locally to a temporary file. It will be deleted before this method exits + handle, temp_path = tempfile.mkstemp() + fh = os.fdopen(handle, "wb") + result = None + try: + # Write to the temporary file + content = stream.read() + if isinstance(content, str): + fh.write(content.encode("utf-8")) + else: + fh.write(content) + fh.close() + + # Use puremagic to check for more extension options + self._append_ext(extensions, self._guess_ext_magic(temp_path)) + + # Convert + result = self._convert(temp_path, extensions, **kwargs) + # Clean up + finally: + try: + fh.close() + except Exception: + pass + os.unlink(temp_path) + + return result + + def convert_url(self, url: str, **kwargs: Any) -> DocumentConverterResult: # TODO: fix kwargs type + # Send a HTTP request to the URL + response = self._requests_session.get(url, stream=True) + response.raise_for_status() + return self.convert_response(response, **kwargs) + + def convert_response( + self, response: requests.Response, **kwargs: Any + ) -> DocumentConverterResult: # TODO fix kwargs type + # Prepare a list of extensions to try (in order of priority) + ext = kwargs.get("file_extension") + extensions = [ext] if ext is not None else [] + + # Guess from the mimetype + content_type = response.headers.get("content-type", "").split(";")[0] + self._append_ext(extensions, mimetypes.guess_extension(content_type)) + + # Read the content disposition if there is one + content_disposition = response.headers.get("content-disposition", "") + m = re.search(r"filename=([^;]+)", content_disposition) + if m: + base, ext = os.path.splitext(m.group(1).strip("\"'")) + self._append_ext(extensions, ext) + + # Read from the extension from the path + base, ext = os.path.splitext(urlparse(response.url).path) + self._append_ext(extensions, ext) + + # Save the file locally to a temporary file. It will be deleted before this method exits + handle, temp_path = tempfile.mkstemp() + fh = os.fdopen(handle, "wb") + result = None + try: + # Download the file + for chunk in response.iter_content(chunk_size=512): + fh.write(chunk) + fh.close() + + # Use puremagic to check for more extension options + self._append_ext(extensions, self._guess_ext_magic(temp_path)) + + # Convert + result = self._convert(temp_path, extensions, url=response.url) + # Clean up + finally: + try: + fh.close() + except Exception: + pass + os.unlink(temp_path) + + return result + + def _convert(self, local_path: str, extensions: List[Union[str, None]], **kwargs) -> DocumentConverterResult: + error_trace = "" + for ext in extensions + [None]: # Try last with no extension + for converter in self._page_converters: + _kwargs = copy.deepcopy(kwargs) + + # Overwrite file_extension appropriately + if ext is None: + if "file_extension" in _kwargs: + del _kwargs["file_extension"] + else: + _kwargs.update({"file_extension": ext}) + + # Copy any additional global options + if "mlm_client" not in _kwargs and self._mlm_client is not None: + _kwargs["mlm_client"] = self._mlm_client + + if "mlm_model" not in _kwargs and self._mlm_model is not None: + _kwargs["mlm_model"] = self._mlm_model + + # If we hit an error log it and keep trying + # try: + res = converter.convert(local_path, **_kwargs) + # except Exception: + # error_trace = ("\n\n" + traceback.format_exc()).strip() + + if res is not None: + # Normalize the content + res.text_content = "\n".join([line.rstrip() for line in re.split(r"\r?\n", res.text_content)]) + res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content) + + # Todo + return res + + # If we got this far without success, report any exceptions + if len(error_trace) > 0: + raise FileConversionException( + f"Could not convert '{local_path}' to Markdown. File type was recognized as {extensions}. While converting the file, the following error was encountered:\n\n{error_trace}" + ) + + # Nothing can handle it! + raise UnsupportedFormatException( + f"Could not convert '{local_path}' to Markdown. The formats {extensions} are not supported." + ) + + def _append_ext(self, extensions, ext): + """Append a unique non-None, non-empty extension to a list of extensions.""" + if ext is None: + return + ext = ext.strip() + if ext == "": + return + # if ext not in extensions: + if True: + extensions.append(ext) + + def _guess_ext_magic(self, path): + """Use puremagic (a Python implementation of libmagic) to guess a file's extension based on the first few bytes.""" + # Use puremagic to guess + try: + guesses = puremagic.magic_file(path) + if len(guesses) > 0: + ext = guesses[0].extension.strip() + if len(ext) > 0: + return ext + except FileNotFoundError: + pass + except IsADirectoryError: + pass + except PermissionError: + pass + return None + + def register_page_converter(self, converter: DocumentConverter) -> None: + """Register a page text converter.""" + self._page_converters.insert(0, converter) diff --git a/metachain/environment/markdown_browser/requests_markdown_browser.py b/metachain/environment/markdown_browser/requests_markdown_browser.py new file mode 100644 index 0000000..b8b17c6 --- /dev/null +++ b/metachain/environment/markdown_browser/requests_markdown_browser.py @@ -0,0 +1,454 @@ +# ruff: noqa: E722 +import datetime +import html +import io +import mimetypes +import os +import pathlib +import re +import time +import traceback +import uuid +from typing import Any, Dict, List, Optional, Tuple, Union +from urllib.parse import unquote, urljoin, urlparse + +import pathvalidate +import requests + +from .abstract_markdown_browser import AbstractMarkdownBrowser +from .markdown_search import AbstractMarkdownSearch, BingMarkdownSearch + +# TODO: Fix unfollowed import +from .mdconvert import FileConversionException, MarkdownConverter, UnsupportedFormatException # type: ignore + + +class RequestsMarkdownBrowser(AbstractMarkdownBrowser): + """ + (In preview) An extremely simple Python requests-powered Markdown web browser. + This browser cannot run JavaScript, compute CSS, etc. It simply fetches the HTML document, and converts it to Markdown. + See AbstractMarkdownBrowser for more details. + """ + + # TODO: Fix unfollowed import + def __init__( # type: ignore + self, + local_root: str, + workplace_name: str, + start_page: Union[str, None] = None, + viewport_size: Union[int, None] = 1024 * 8, + downloads_folder: Union[str, None] = None, + search_engine: Union[AbstractMarkdownSearch, None] = None, + markdown_converter: Union[MarkdownConverter, None] = None, + requests_session: Union[requests.Session, None] = None, + requests_get_kwargs: Union[Dict[str, Any], None] = None, + ): + """ + Instantiate a new RequestsMarkdownBrowser. + + Arguments: + start_page: The page on which the browser starts (default: "about:blank") + viewport_size: Approximately how many *characters* fit in the viewport. Viewport dimensions are adjusted dynamically to avoid cutting off words (default: 8192). + downloads_folder: Path to where downloads are saved. If None, downloads are disabled. (default: None) + search_engine: An instance of MarkdownSearch, which handles web searches performed by this browser (default: a new `BingMarkdownSearch()` with default parameters) + markdown_converted: An instance of a MarkdownConverter used to convert HTML pages and downloads to Markdown (default: a new `MarkdownConerter()` with default parameters) + request_session: The session from which to issue requests (default: a new `requests.Session()` instance with default parameters) + request_get_kwargs: Extra parameters passed to evert `.get()` call made to requests. + """ + self.local_workplace = os.path.join(local_root, workplace_name) + self.docker_workplace = f"/{workplace_name}" + self.start_page: str = start_page if start_page else "about:blank" + self.viewport_size = viewport_size # Applies only to the standard uri types + self.downloads_folder = downloads_folder + self.history: List[Tuple[str, float]] = list() + self.page_title: Optional[str] = None + self.viewport_current_page = 0 + self.viewport_pages: List[Tuple[int, int]] = list() + self.set_address(self.start_page) + self._page_content: str = "" + + if search_engine is None: + self._search_engine: AbstractMarkdownSearch = BingMarkdownSearch() + else: + self._search_engine = search_engine + + if markdown_converter is None: + self._markdown_converter = MarkdownConverter() + else: + self._markdown_converter = markdown_converter + + if requests_session is None: + self._requests_session = requests.Session() + else: + self._requests_session = requests_session + + if requests_get_kwargs is None: + self._requests_get_kwargs = {} + else: + self._requests_get_kwargs = requests_get_kwargs + + self._find_on_page_query: Union[str, None] = None + self._find_on_page_last_result: Union[int, None] = None # Location of the last result + + @property + def address(self) -> str: + """Return the address of the current page.""" + return self.history[-1][0] + def _convert_docker_to_local(self, path: str) -> str: + assert self.docker_workplace in path, f"The path must be a absolute path from `{self.docker_workplace}/` directory" + local_path = path.replace(self.docker_workplace, self.local_workplace) + return local_path + def _convert_local_to_docker(self, path: str) -> str: + assert self.local_workplace in path, f"The path must be a absolute path from `{self.local_workplace}/` directory" + docker_path = path.replace(self.local_workplace, self.docker_workplace) + return docker_path + + def set_address(self, uri_or_path: str) -> None: + """Sets the address of the current page. + This will result in the page being fetched via the underlying requests session. + + Arguments: + uri_or_path: The fully-qualified URI to fetch, or the path to fetch from the current location. If the URI protocol is `search:`, the remainder of the URI is interpreted as a search query, and a web search is performed. If the URI protocol is `file://`, the remainder of the URI is interpreted as a local absolute file path. + """ + # TODO: Handle anchors + self.history.append((uri_or_path, time.time())) + + # Handle special URIs + if uri_or_path == "about:blank": + self._set_page_content("") + elif uri_or_path.startswith("search:"): + query = uri_or_path[len("search:") :].strip() + results = self._search_engine.search(query) + self.page_title = f"{query} - Search" + self._set_page_content(results, split_pages=False) + else: + if ( + not uri_or_path.startswith("http:") + and not uri_or_path.startswith("https:") + and not uri_or_path.startswith("file:") + ): + if len(self.history) > 1: + prior_address = self.history[-2][0] + uri_or_path = urljoin(prior_address, uri_or_path) + # Update the address with the fully-qualified path + self.history[-1] = (uri_or_path, self.history[-1][1]) + self._fetch_page(uri_or_path) + + self.viewport_current_page = 0 + self.find_on_page_query = None + self.find_on_page_viewport = None + + @property + def viewport(self) -> str: + """Return the content of the current viewport.""" + bounds = self.viewport_pages[self.viewport_current_page] + return self.page_content[bounds[0] : bounds[1]] + + @property + def page_content(self) -> str: + """Return the full contents of the current page.""" + return self._page_content + + def _set_page_content(self, content: str, split_pages: bool = True) -> None: + """Sets the text content of the current page.""" + self._page_content = content + + if split_pages: + self._split_pages() + else: + self.viewport_pages = [(0, len(self._page_content))] + + if self.viewport_current_page >= len(self.viewport_pages): + self.viewport_current_page = len(self.viewport_pages) - 1 + + def page_down(self) -> None: + """Move the viewport down one page, if possible.""" + self.viewport_current_page = min(self.viewport_current_page + 1, len(self.viewport_pages) - 1) + + def page_up(self) -> None: + """Move the viewport up one page, if possible.""" + self.viewport_current_page = max(self.viewport_current_page - 1, 0) + + def page_to(self, page_idx: int) -> None: + """Move the viewport to the specified page index.""" + self.viewport_current_page = min(max(page_idx, 0), len(self.viewport_pages) - 1) + + def find_on_page(self, query: str) -> Union[str, None]: + """Searches for the query from the current viewport forward, looping back to the start if necessary.""" + + # Did we get here via a previous find_on_page search with the same query? + # If so, map to find_next + if query == self._find_on_page_query and self.viewport_current_page == self._find_on_page_last_result: + return self.find_next() + + # Ok it's a new search start from the current viewport + self._find_on_page_query = query + viewport_match = self._find_next_viewport(query, self.viewport_current_page) + if viewport_match is None: + self._find_on_page_last_result = None + return None + else: + self.viewport_current_page = viewport_match + self._find_on_page_last_result = viewport_match + return self.viewport + + def find_next(self) -> Union[str, None]: + """Scroll to the next viewport that matches the query""" + + if self._find_on_page_query is None: + return None + + starting_viewport = self._find_on_page_last_result + if starting_viewport is None: + starting_viewport = 0 + else: + starting_viewport += 1 + if starting_viewport >= len(self.viewport_pages): + starting_viewport = 0 + + viewport_match = self._find_next_viewport(self._find_on_page_query, starting_viewport) + if viewport_match is None: + self._find_on_page_last_result = None + return None + else: + self.viewport_current_page = viewport_match + self._find_on_page_last_result = viewport_match + return self.viewport + + def _find_next_viewport(self, query: Optional[str], starting_viewport: int) -> Union[int, None]: + """Search for matches between the starting viewport looping when reaching the end.""" + + if query is None: + return None + + # Normalize the query, and convert to a regular expression + nquery = re.sub(r"\*", "__STAR__", query) + nquery = " " + (" ".join(re.split(r"\W+", nquery))).strip() + " " + nquery = nquery.replace(" __STAR__ ", "__STAR__ ") # Merge isolated stars with prior word + nquery = nquery.replace("__STAR__", ".*").lower() + + if nquery.strip() == "": + return None + + idxs: List[int] = list() + idxs.extend(range(starting_viewport, len(self.viewport_pages))) + idxs.extend(range(0, starting_viewport)) + + for i in idxs: + bounds = self.viewport_pages[i] + content = self.page_content[bounds[0] : bounds[1]] + + # TODO: Remove markdown links and images + ncontent = " " + (" ".join(re.split(r"\W+", content))).strip().lower() + " " + if re.search(nquery, ncontent): + return i + + return None + + def visit_page(self, path_or_uri: str) -> str: + """Update the address, visit the page, and return the content of the viewport.""" + self.set_address(path_or_uri) + return self.viewport + + def open_local_file(self, local_path: str) -> str: + """Convert a local file path to a file:/// URI, update the address, visit the page, and return the contents of the viewport.""" + full_path = os.path.abspath(os.path.expanduser(local_path)) + self.set_address(pathlib.Path(full_path).as_uri()) + return self.viewport + + def _split_pages(self) -> None: + """Split the page contents into pages that are approximately the viewport size. Small deviations are permitted to ensure words are not broken.""" + # Handle empty pages + if len(self._page_content) == 0: + self.viewport_pages = [(0, 0)] + return + + # Break the viewport into pages + self.viewport_pages = [] + start_idx = 0 + while start_idx < len(self._page_content): + end_idx = min(start_idx + self.viewport_size, len(self._page_content)) # type: ignore[operator] + # Adjust to end on a space + while end_idx < len(self._page_content) and self._page_content[end_idx - 1] not in [" ", "\t", "\r", "\n"]: + end_idx += 1 + self.viewport_pages.append((start_idx, end_idx)) + start_idx = end_idx + + def _fetch_page( + self, + url: str, + session: Optional[requests.Session] = None, + requests_get_kwargs: Union[Dict[str, Any], None] = None, + ) -> None: + """Fetch a page using the requests library. Then convert it to Markdown, and set `page_content` (which splits the content into pages as necessary. + + Arguments: + url: The fully-qualified URL to fetch. + session: Used to override the session used for this request. If None, use `self._requests_session` as usual. + requests_get_kwargs: Extra arguments passes to `requests.Session.get`. + """ + download_path: str = "" + response: Union[requests.Response, None] = None + # print(url) + try: + if url.startswith("file://"): + download_path = os.path.normcase(os.path.normpath(unquote(url[7:]))) + if os.path.isdir(download_path): # TODO: Fix markdown_converter types + res = self._markdown_converter.convert_stream( # type: ignore + io.StringIO(self._fetch_local_dir(download_path)), file_extension=".html" + ) + self.page_title = res.title + self._set_page_content( + res.text_content, split_pages=False + ) # Like search results, don't split directory listings + else: + res = self._markdown_converter.convert_local(download_path) + self.page_title = res.title + self._set_page_content(res.text_content) + else: + # Send a HTTP request to the URL + if session is None: + session = self._requests_session + + _get_kwargs: Dict[str, Any] = {} # TODO: Deal with kwargs + _get_kwargs.update(self._requests_get_kwargs) + if requests_get_kwargs is not None: + _get_kwargs.update(requests_get_kwargs) + _get_kwargs["stream"] = True + + response = session.get(url, **_get_kwargs) + response.raise_for_status() + + # If the HTTP request was successful + content_type = response.headers.get("content-type", "") + + # Text or HTML + if "text/" in content_type.lower(): + res = self._markdown_converter.convert_response(response) + self.page_title = res.title + self._set_page_content(res.text_content) + # A download + else: + # Was a downloads folder configured? + if self.downloads_folder is None: + self.page_title = "Error 400" + self._set_page_content("## Error 400\n\nClient does not support downloads") + return + + assert self.downloads_folder is not None + + # Try producing a safe filename + fname: str = "" + try: + fname = pathvalidate.sanitize_filename(os.path.basename(urlparse(url).path)).strip() + download_path = os.path.abspath(os.path.join(self.downloads_folder, fname)) + + suffix = 0 + while os.path.exists(download_path) and suffix < 1000: + suffix += 1 + base, ext = os.path.splitext(fname) + new_fname = f"{base}__{suffix}{ext}" + download_path = os.path.abspath(os.path.join(self.downloads_folder, new_fname)) + + except NameError: + pass + + # No suitable name, so make one + if fname == "": + extension = mimetypes.guess_extension(content_type) + if extension is None: + extension = ".download" + fname = str(uuid.uuid4()) + extension + download_path = os.path.abspath(os.path.join(self.downloads_folder, fname)) + + # Open a file for writing + with open(download_path, "wb") as fh: + for chunk in response.iter_content(chunk_size=512): + fh.write(chunk) + + # Render it + local_uri = pathlib.Path(download_path).as_uri() + self.set_address(local_uri) + + except UnsupportedFormatException: + self.page_title = "Download complete." + self._set_page_content(f"# Download complete\n\nSaved file to '{download_path}'") + except FileConversionException: + self.page_title = "Download complete." + self._set_page_content(f"# Download complete\n\nSaved file to '{download_path}'") + except FileNotFoundError: + self.page_title = "Error 404" + self._set_page_content(f"## Error 404\n\nFile not found: {download_path}") + except requests.exceptions.RequestException: + if response is None: + self.page_title = "Request Exception" + self._set_page_content("## Unhandled Request Exception:\n\n" + traceback.format_exc()) + else: + self.page_title = f"Error {response.status_code}" + + # If the error was rendered in HTML we might as well render it + content_type = response.headers.get("content-type", "") + if "text/html" in content_type.lower(): + res = self._markdown_converter.convert(response) + self.page_title = f"Error {response.status_code}" + self._set_page_content(f"## Error {response.status_code}\n\n{res.text_content}") + else: + text = "" + for chunk in response.iter_content(chunk_size=512, decode_unicode=True): + text += chunk + self.page_title = f"Error {response.status_code}" + self._set_page_content(f"## Error {response.status_code}\n\n{text}") + + def _fetch_local_dir(self, local_path: str) -> str: + """Render a local directory listing in HTML to assist with local file browsing via the "file://" protocol. + Through rendered in HTML, later parts of the pipeline will convert the listing to Markdown. + + Arguments: + local_path: A path to the local directory whose contents are to be listed. + + Returns: + A directory listing, rendered in HTML. + """ + pardir = os.path.normpath(os.path.join(local_path, os.pardir)) + pardir_uri = pathlib.Path(pardir).as_uri() + listing = f""" + + + + Index of {html.escape(local_path)} + + +

Index of {html.escape(local_path)}

+ + .. (parent directory) + + + + + +""" + + for entry in os.listdir(local_path): + full_path = os.path.normpath(os.path.join(local_path, entry)) + full_path_uri = pathlib.Path(full_path).as_uri() + size = "" + mtime = datetime.datetime.fromtimestamp(os.path.getmtime(full_path)).strftime("%Y-%m-%d %H:%M") + + if os.path.isdir(full_path): + entry = entry + os.path.sep + else: + size = str(os.path.getsize(full_path)) + + listing += ( + "\n" + + f'' + + f"" + + f"" + + "" + ) + + listing += """ +
NameSizeDate modified
{html.escape(entry)}{html.escape(size)}{html.escape(mtime)}
+ + +""" + return listing diff --git a/metachain/environment/mdconvert.py b/metachain/environment/mdconvert.py new file mode 100644 index 0000000..33bc23e --- /dev/null +++ b/metachain/environment/mdconvert.py @@ -0,0 +1,1233 @@ + +from browsergym.core.action.functions import goto, page +from metachain.environment.markdown_browser import MarkdownConverter +def _get_page_markdown(): + """ + Get the markdown content of the current page + + Examples: + _get_page_markdown() + """ + # # type: ignore + import io + import base64 + import binascii + import copy + import html + import json + import mimetypes + import os + import re + import shutil + import subprocess + import sys + import tempfile + from typing import Any, Dict, List, Optional, Union + from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse + + import mammoth + import markdownify + import pandas as pd + import pdfminer + import pdfminer.high_level + import pptx + + # File-format detection + import puremagic + import requests + from bs4 import BeautifulSoup + + # Optional Transcription support + try: + import pydub + import speech_recognition as sr + + IS_AUDIO_TRANSCRIPTION_CAPABLE = True + except ModuleNotFoundError: + pass + + # Optional YouTube transcription support + try: + from youtube_transcript_api import YouTubeTranscriptApi + + IS_YOUTUBE_TRANSCRIPT_CAPABLE = True + except ModuleNotFoundError: + pass + + + class _CustomMarkdownify(markdownify.MarkdownConverter): + """ + A custom version of markdownify's MarkdownConverter. Changes include: + + - Altering the default heading style to use '#', '##', etc. + - Removing javascript hyperlinks. + - Truncating images with large data:uri sources. + - Ensuring URIs are properly escaped, and do not conflict with Markdown syntax + """ + + def __init__(self, **options: Any): + options["heading_style"] = options.get("heading_style", markdownify.ATX) + # Explicitly cast options to the expected type if necessary + super().__init__(**options) + + def convert_hn(self, n: int, el: Any, text: str, convert_as_inline: bool) -> str: + """Same as usual, but be sure to start with a new line""" + if not convert_as_inline: + if not re.search(r"^\n", text): + return "\n" + super().convert_hn(n, el, text, convert_as_inline) # type: ignore + + return super().convert_hn(n, el, text, convert_as_inline) # type: ignore + + def convert_a(self, el: Any, text: str, convert_as_inline: bool): + """Same as usual converter, but removes Javascript links and escapes URIs.""" + prefix, suffix, text = markdownify.chomp(text) # type: ignore + if not text: + return "" + href = el.get("href") + title = el.get("title") + + # Escape URIs and skip non-http or file schemes + if href: + try: + parsed_url = urlparse(href) # type: ignore + if parsed_url.scheme and parsed_url.scheme.lower() not in ["http", "https", "file"]: # type: ignore + return "%s%s%s" % (prefix, text, suffix) + href = urlunparse(parsed_url._replace(path=quote(unquote(parsed_url.path)))) # type: ignore + except ValueError: # It's not clear if this ever gets thrown + return "%s%s%s" % (prefix, text, suffix) + + # For the replacement see #29: text nodes underscores are escaped + if ( + self.options["autolinks"] + and text.replace(r"\_", "_") == href + and not title + and not self.options["default_title"] + ): + # Shortcut syntax + return "<%s>" % href + if self.options["default_title"] and not title: + title = href + title_part = ' "%s"' % title.replace('"', r"\"") if title else "" + return "%s[%s](%s%s)%s" % (prefix, text, href, title_part, suffix) if href else text + + def convert_img(self, el: Any, text: str, convert_as_inline: bool) -> str: + """Same as usual converter, but removes data URIs""" + + alt = el.attrs.get("alt", None) or "" + src = el.attrs.get("src", None) or "" + title = el.attrs.get("title", None) or "" + title_part = ' "%s"' % title.replace('"', r"\"") if title else "" + if convert_as_inline and el.parent.name not in self.options["keep_inline_images_in"]: + return alt + + # Remove dataURIs + if src.startswith("data:"): + src = src.split(",")[0] + "..." + + return "![%s](%s%s)" % (alt, src, title_part) + + def convert_soup(self, soup: Any) -> str: + return super().convert_soup(soup) # type: ignore + + + class DocumentConverterResult: + """The result of converting a document to text.""" + + def __init__(self, title: Union[str, None] = None, text_content: str = ""): + self.title: Union[str, None] = title + self.text_content: str = text_content + + + class DocumentConverter: + """Abstract superclass of all DocumentConverters.""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + raise NotImplementedError() + + + class PlainTextConverter(DocumentConverter): + """Anything with content type text/plain""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Guess the content type from any file extension that might be around + content_type, _ = mimetypes.guess_type("__placeholder" + kwargs.get("file_extension", "")) + + # Only accept text files + if content_type is None: + return None + elif "text/" not in content_type.lower(): + return None + + text_content = "" + with open(local_path, "rt", encoding="utf-8") as fh: + text_content = fh.read() + return DocumentConverterResult( + title=None, + text_content=text_content, + ) + + + class HtmlConverter(DocumentConverter): + """Anything with content type text/html""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Bail if not html + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + + result = None + with open(local_path, "rt", encoding="utf-8") as fh: + result = self._convert(fh.read()) + + return result + + def _convert(self, html_content: str) -> Union[None, DocumentConverterResult]: + """Helper function that converts and HTML string.""" + + # Parse the string + soup = BeautifulSoup(html_content, "html.parser") + + # Remove javascript and style blocks + for script in soup(["script", "style"]): + script.extract() + + # Print only the main content + body_elm = soup.find("body") + webpage_text = "" + if body_elm: + webpage_text = _CustomMarkdownify().convert_soup(body_elm) + else: + webpage_text = _CustomMarkdownify().convert_soup(soup) + + assert isinstance(webpage_text, str) + + return DocumentConverterResult( + title=None if soup.title is None else soup.title.string, text_content=webpage_text + ) + + + class WikipediaConverter(DocumentConverter): + """Handle Wikipedia pages separately, focusing only on the main document content.""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Bail if not Wikipedia + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + url = kwargs.get("url", "") + if not re.search(r"^https?:\/\/[a-zA-Z]{2,3}\.wikipedia.org\/", url): + return None + + # Parse the file + soup = None + with open(local_path, "rt", encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + + # Remove javascript and style blocks + for script in soup(["script", "style"]): + script.extract() + + # Print only the main content + body_elm = soup.find("div", {"id": "mw-content-text"}) + title_elm = soup.find("span", {"class": "mw-page-title-main"}) + + webpage_text = "" + main_title = None if soup.title is None else soup.title.string + + if body_elm: + # What's the title + if title_elm and len(title_elm) > 0: + main_title = title_elm.string # type: ignore + assert isinstance(main_title, str) + + # Convert the page + webpage_text = f"# {main_title}\n\n" + _CustomMarkdownify().convert_soup(body_elm) + else: + webpage_text = _CustomMarkdownify().convert_soup(soup) + + return DocumentConverterResult( + title=main_title, + text_content=webpage_text, + ) + + + class YouTubeConverter(DocumentConverter): + """Handle YouTube specially, focusing on the video title, description, and transcript.""" + + def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + # Bail if not YouTube + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + url = kwargs.get("url", "") + if not url.startswith("https://www.youtube.com/watch?"): + return None + + # Parse the file + soup = None + with open(local_path, "rt", encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + + # Read the meta tags + assert soup.title is not None and soup.title.string is not None + metadata: Dict[str, str] = {"title": soup.title.string} + for meta in soup(["meta"]): + for a in meta.attrs: + if a in ["itemprop", "property", "name"]: + metadata[meta[a]] = meta.get("content", "") + break + + # We can also try to read the full description. This is more prone to breaking, since it reaches into the page implementation + try: + for script in soup(["script"]): + content = script.text + if "ytInitialData" in content: + lines = re.split(r"\r?\n", content) + obj_start = lines[0].find("{") + obj_end = lines[0].rfind("}") + if obj_start >= 0 and obj_end >= 0: + data = json.loads(lines[0][obj_start : obj_end + 1]) + attrdesc = self._findKey(data, "attributedDescriptionBodyText") # type: ignore + if attrdesc: + metadata["description"] = str(attrdesc["content"]) + break + except Exception: + pass + + # Start preparing the page + webpage_text = "# YouTube\n" + + title = self._get(metadata, ["title", "og:title", "name"]) # type: ignore + assert isinstance(title, str) + + if title: + webpage_text += f"\n## {title}\n" + + stats = "" + views = self._get(metadata, ["interactionCount"]) # type: ignore + if views: + stats += f"- **Views:** {views}\n" + + keywords = self._get(metadata, ["keywords"]) # type: ignore + if keywords: + stats += f"- **Keywords:** {keywords}\n" + + runtime = self._get(metadata, ["duration"]) # type: ignore + if runtime: + stats += f"- **Runtime:** {runtime}\n" + + if len(stats) > 0: + webpage_text += f"\n### Video Metadata\n{stats}\n" + + description = self._get(metadata, ["description", "og:description"]) # type: ignore + if description: + webpage_text += f"\n### Description\n{description}\n" + + if IS_YOUTUBE_TRANSCRIPT_CAPABLE: + transcript_text = "" + parsed_url = urlparse(url) # type: ignore + params = parse_qs(parsed_url.query) # type: ignore + if "v" in params: + assert isinstance(params["v"][0], str) + video_id = str(params["v"][0]) + try: + # Must be a single transcript. + transcript = YouTubeTranscriptApi.get_transcript(video_id) # type: ignore + transcript_text = " ".join([part["text"] for part in transcript]) # type: ignore + # Alternative formatting: + # formatter = TextFormatter() + # formatter.format_transcript(transcript) + except Exception: + pass + if transcript_text: + webpage_text += f"\n### Transcript\n{transcript_text}\n" + + title = title if title else soup.title.string + assert isinstance(title, str) + + return DocumentConverterResult( + title=title, + text_content=webpage_text, + ) + + def _get(self, metadata: Dict[str, str], keys: List[str], default: Union[str, None] = None) -> Union[str, None]: + for k in keys: + if k in metadata: + return metadata[k] + return default + + def _findKey(self, json: Any, key: str) -> Union[str, None]: # TODO: Fix json type + if isinstance(json, list): + for elm in json: + ret = self._findKey(elm, key) + if ret is not None: + return ret + elif isinstance(json, dict): + for k in json: + if k == key: + return json[k] + else: + ret = self._findKey(json[k], key) + if ret is not None: + return ret + return None + + + class BingSerpConverter(DocumentConverter): + """ + Handle Bing results pages (only the organic search results). + NOTE: It is better to use the Bing API + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a Bing SERP + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".html", ".htm"]: + return None + url = kwargs.get("url", "") + if not re.search(r"^https://www\.bing\.com/search\?q=", url): + return None + + # Parse the query parameters + parsed_params = parse_qs(urlparse(url).query) + query = parsed_params.get("q", [""])[0] + + # Parse the file + soup = None + with open(local_path, "rt", encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + + # Clean up some formatting + for tptt in soup.find_all(class_="tptt"): + if hasattr(tptt, "string") and tptt.string: + tptt.string += " " + for slug in soup.find_all(class_="algoSlug_icon"): + slug.extract() + + # Parse the algorithmic results + _markdownify = _CustomMarkdownify() + results = list() + for result in soup.find_all(class_="b_algo"): + # Rewrite redirect urls + for a in result.find_all("a", href=True): + parsed_href = urlparse(a["href"]) + qs = parse_qs(parsed_href.query) + + # The destination is contained in the u parameter, + # but appears to be base64 encoded, with some prefix + if "u" in qs: + u = qs["u"][0][2:].strip() + "==" # Python 3 doesn't care about extra padding + + try: + # RFC 4648 / Base64URL" variant, which uses "-" and "_" + a["href"] = base64.b64decode(u, altchars="-_").decode("utf-8") + except UnicodeDecodeError: + pass + except binascii.Error: + pass + + # Convert to markdown + md_result = _markdownify.convert_soup(result).strip() + lines = [line.strip() for line in re.split(r"\n+", md_result)] + results.append("\n".join([line for line in lines if len(line) > 0])) + + webpage_text = f"## A Bing search for '{query}' found the following results:\n\n" + "\n\n".join(results) + + return DocumentConverterResult( + title=None if soup.title is None else soup.title.string, + text_content=webpage_text, + ) + + + class PdfConverter(DocumentConverter): + """ + Converts PDFs to Markdown. Most style information is ignored, so the results are essentially plain-text. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a PDF + extension = kwargs.get("file_extension", "") + if extension.lower() != ".pdf": + return None + + return DocumentConverterResult( + title=None, + text_content=pdfminer.high_level.extract_text(local_path), + ) + + + class DocxConverter(HtmlConverter): + """ + Converts DOCX files to Markdown. Style information (e.g.m headings) and tables are preserved where possible. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a DOCX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".docx": + return None + + result = None + with open(local_path, "rb") as docx_file: + result = mammoth.convert_to_html(docx_file) + html_content = result.value + result = self._convert(html_content) + + return result + + + class XlsxConverter(HtmlConverter): + """ + Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a XLSX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".xlsx": + return None + + sheets = pd.read_excel(local_path, sheet_name=None) + md_content = "" + for s in sheets: + md_content += f"## {s}\n" + html_content = sheets[s].to_html(index=False) + md_content += self._convert(html_content).text_content.strip() + "\n\n" + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + + class PptxConverter(HtmlConverter): + """ + Converts PPTX files to Markdown. Supports heading, tables and images with alt text. + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a PPTX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".pptx": + return None + + md_content = "" + + presentation = pptx.Presentation(local_path) + slide_num = 0 + for slide in presentation.slides: + slide_num += 1 + + md_content += f"\n\n\n" + + title = slide.shapes.title + for shape in slide.shapes: + # Pictures + if self._is_picture(shape): + # https://github.com/scanny/python-pptx/pull/512#issuecomment-1713100069 + alt_text = "" + try: + alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "") + except Exception: + pass + + # A placeholder name + filename = re.sub(r"\W", "", shape.name) + ".jpg" + md_content += "\n![" + (alt_text if alt_text else shape.name) + "](" + filename + ")\n" + + # Tables + if self._is_table(shape): + html_table = "" + first_row = True + for row in shape.table.rows: + html_table += "" + for cell in row.cells: + if first_row: + html_table += "" + else: + html_table += "" + html_table += "" + first_row = False + html_table += "
" + html.escape(cell.text) + "" + html.escape(cell.text) + "
" + md_content += "\n" + self._convert(html_table).text_content.strip() + "\n" + + # Text areas + elif shape.has_text_frame: + if shape == title: + md_content += "# " + shape.text.lstrip() + "\n" + else: + md_content += shape.text + "\n" + + md_content = md_content.strip() + + if slide.has_notes_slide: + md_content += "\n\n### Notes:\n" + notes_frame = slide.notes_slide.notes_text_frame + if notes_frame is not None: + md_content += notes_frame.text + md_content = md_content.strip() + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + def _is_picture(self, shape): + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE: + return True + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PLACEHOLDER: + if hasattr(shape, "image"): + return True + return False + + def _is_table(self, shape): + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE: + return True + return False + + + class MediaConverter(DocumentConverter): + """ + Abstract class for multi-modal media (e.g., images and audio) + """ + + def _get_metadata(self, local_path): + exiftool = shutil.which("exiftool") + if not exiftool: + return None + else: + try: + result = subprocess.run([exiftool, "-json", local_path], capture_output=True, text=True).stdout + return json.loads(result)[0] + except Exception: + return None + + + class WavConverter(MediaConverter): + """ + Converts WAV files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` is installed). + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a XLSX + extension = kwargs.get("file_extension", "") + if extension.lower() != ".wav": + return None + + md_content = "" + + # Add metadata + metadata = self._get_metadata(local_path) + if metadata: + for f in [ + "Title", + "Artist", + "Author", + "Band", + "Album", + "Genre", + "Track", + "DateTimeOriginal", + "CreateDate", + "Duration", + ]: + if f in metadata: + md_content += f"{f}: {metadata[f]}\n" + + # Transcribe + if IS_AUDIO_TRANSCRIPTION_CAPABLE: + try: + transcript = self._transcribe_audio(local_path) + md_content += "\n\n### Audio Transcript:\n" + ( + "[No speech detected]" if transcript == "" else transcript + ) + except Exception: + md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + def _transcribe_audio(self, local_path) -> str: + recognizer = sr.Recognizer() + with sr.AudioFile(local_path) as source: + audio = recognizer.record(source) + return recognizer.recognize_google(audio).strip() + + + class Mp3Converter(WavConverter): + """ + Converts MP3 files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` AND `pydub` are installed). + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a MP3 + extension = kwargs.get("file_extension", "") + if extension.lower() != ".mp3": + return None + + md_content = "" + + # Add metadata + metadata = self._get_metadata(local_path) + if metadata: + for f in [ + "Title", + "Artist", + "Author", + "Band", + "Album", + "Genre", + "Track", + "DateTimeOriginal", + "CreateDate", + "Duration", + ]: + if f in metadata: + md_content += f"{f}: {metadata[f]}\n" + + # Transcribe + if IS_AUDIO_TRANSCRIPTION_CAPABLE: + handle, temp_path = tempfile.mkstemp(suffix=".wav") + os.close(handle) + try: + sound = pydub.AudioSegment.from_mp3(local_path) + sound.export(temp_path, format="wav") + + _args = dict() + _args.update(kwargs) + _args["file_extension"] = ".wav" + + try: + transcript = super()._transcribe_audio(temp_path).strip() + md_content += "\n\n### Audio Transcript:\n" + ( + "[No speech detected]" if transcript == "" else transcript + ) + except Exception: + md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + + finally: + os.unlink(temp_path) + + # Return the result + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + + + class ImageConverter(MediaConverter): + """ + Converts images to markdown via extraction of metadata (if `exiftool` is installed), OCR (if `easyocr` is installed), and description via a multimodal LLM (if an mlm_client is configured). + """ + + def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: + # Bail if not a XLSX + extension = kwargs.get("file_extension", "") + if extension.lower() not in [".jpg", ".jpeg", ".png"]: + return None + + md_content = "" + + # Add metadata + metadata = self._get_metadata(local_path) + if metadata: + for f in [ + "ImageSize", + "Title", + "Caption", + "Description", + "Keywords", + "Artist", + "Author", + "DateTimeOriginal", + "CreateDate", + "GPSPosition", + ]: + if f in metadata: + md_content += f"{f}: {metadata[f]}\n" + + # Try describing the image with GPTV + mlm_client = kwargs.get("mlm_client") + mlm_model = kwargs.get("mlm_model") + if mlm_client is not None and mlm_model is not None: + md_content += ( + "\n# Description:\n" + + self._get_mlm_description( + local_path, extension, mlm_client, mlm_model, prompt=kwargs.get("mlm_prompt") + ).strip() + + "\n" + ) + + return DocumentConverterResult( + title=None, + text_content=md_content, + ) + + def _get_mlm_description(self, local_path, extension, client, model, prompt=None): + if prompt is None or prompt.strip() == "": + prompt = "Write a detailed caption for this image." + + sys.stderr.write(f"MLM Prompt:\n{prompt}\n") + + data_uri = "" + with open(local_path, "rb") as image_file: + content_type, encoding = mimetypes.guess_type("_dummy" + extension) + if content_type is None: + content_type = "image/jpeg" + image_base64 = base64.b64encode(image_file.read()).decode("utf-8") + data_uri = f"data:{content_type};base64,{image_base64}" + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": { + "url": data_uri, + }, + }, + ], + } + ] + + response = client.chat.completions.create(model=model, messages=messages) + return response.choices[0].message.content + + + class FileConversionException(BaseException): + pass + + + class UnsupportedFormatException(BaseException): + pass + + + class MarkdownConverter: + """(In preview) An extremely simple text-based document reader, suitable for LLM use. + This reader will convert common file-types or webpages to Markdown.""" + + def __init__( + self, + requests_session: Optional[requests.Session] = None, + mlm_client: Optional[Any] = None, + mlm_model: Optional[Any] = None, + ): + if requests_session is None: + self._requests_session = requests.Session() + else: + self._requests_session = requests_session + + self._mlm_client = mlm_client + self._mlm_model = mlm_model + + self._page_converters: List[DocumentConverter] = [] + + # Register converters for successful browsing operations + # Later registrations are tried first / take higher priority than earlier registrations + # To this end, the most specific converters should appear below the most generic converters + self.register_page_converter(PlainTextConverter()) + self.register_page_converter(HtmlConverter()) + self.register_page_converter(WikipediaConverter()) + self.register_page_converter(YouTubeConverter()) + # self.register_page_converter(BingSerpConverter()) + self.register_page_converter(DocxConverter()) + self.register_page_converter(XlsxConverter()) + self.register_page_converter(PptxConverter()) + self.register_page_converter(WavConverter()) + self.register_page_converter(Mp3Converter()) + self.register_page_converter(PdfConverter()) + + def convert( + self, source: Union[str, requests.Response], **kwargs: Any + ) -> DocumentConverterResult: # TODO: deal with kwargs + """ + Args: + - source: can be a string representing a path or url, or a requests.response object + - extension: specifies the file extension to use when interpreting the file. If None, infer from source (path, uri, content-type, etc.) + """ + + # Local path or url + if isinstance(source, str): + if source.startswith("http://") or source.startswith("https://") or source.startswith("file://"): + return self.convert_url(source, **kwargs) + else: + return self.convert_local(source, **kwargs) + # Request response + elif isinstance(source, requests.Response): + return self.convert_response(source, **kwargs) + + def convert_local(self, path: str, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs + # Prepare a list of extensions to try (in order of priority) + ext = kwargs.get("file_extension") + extensions = [ext] if ext is not None else [] + + # Get extension alternatives from the path and puremagic + base, ext = os.path.splitext(path) + self._append_ext(extensions, ext) + self._append_ext(extensions, self._guess_ext_magic(path)) + + # Convert + return self._convert(path, extensions, **kwargs) + + # TODO what should stream's type be? + def convert_stream(self, stream: Any, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs + # Prepare a list of extensions to try (in order of priority) + ext = kwargs.get("file_extension") + extensions = [ext] if ext is not None else [] + + # Save the file locally to a temporary file. It will be deleted before this method exits + handle, temp_path = tempfile.mkstemp() + fh = os.fdopen(handle, "wb") + result = None + try: + # Write to the temporary file + content = stream.read() + if isinstance(content, str): + fh.write(content.encode("utf-8")) + else: + fh.write(content) + fh.close() + + # Use puremagic to check for more extension options + self._append_ext(extensions, self._guess_ext_magic(temp_path)) + + # Convert + result = self._convert(temp_path, extensions, **kwargs) + # Clean up + finally: + try: + fh.close() + except Exception: + pass + os.unlink(temp_path) + + return result + + def convert_url(self, url: str, **kwargs: Any) -> DocumentConverterResult: # TODO: fix kwargs type + # Send a HTTP request to the URL + response = self._requests_session.get(url, stream=True) + response.raise_for_status() + return self.convert_response(response, **kwargs) + + def convert_response( + self, response: requests.Response, **kwargs: Any + ) -> DocumentConverterResult: # TODO fix kwargs type + # Prepare a list of extensions to try (in order of priority) + ext = kwargs.get("file_extension") + extensions = [ext] if ext is not None else [] + + # Guess from the mimetype + content_type = response.headers.get("content-type", "").split(";")[0] + self._append_ext(extensions, mimetypes.guess_extension(content_type)) + + # Read the content disposition if there is one + content_disposition = response.headers.get("content-disposition", "") + m = re.search(r"filename=([^;]+)", content_disposition) + if m: + base, ext = os.path.splitext(m.group(1).strip("\"'")) + self._append_ext(extensions, ext) + + # Read from the extension from the path + base, ext = os.path.splitext(urlparse(response.url).path) + self._append_ext(extensions, ext) + + # Save the file locally to a temporary file. It will be deleted before this method exits + handle, temp_path = tempfile.mkstemp() + fh = os.fdopen(handle, "wb") + result = None + try: + # Download the file + for chunk in response.iter_content(chunk_size=512): + fh.write(chunk) + fh.close() + + # Use puremagic to check for more extension options + self._append_ext(extensions, self._guess_ext_magic(temp_path)) + + # Convert + result = self._convert(temp_path, extensions, url=response.url) + # Clean up + finally: + try: + fh.close() + except Exception: + pass + os.unlink(temp_path) + + return result + + def _convert(self, local_path: str, extensions: List[Union[str, None]], **kwargs) -> DocumentConverterResult: + error_trace = "" + for ext in extensions + [None]: # Try last with no extension + for converter in self._page_converters: + _kwargs = copy.deepcopy(kwargs) + + # Overwrite file_extension appropriately + if ext is None: + if "file_extension" in _kwargs: + del _kwargs["file_extension"] + else: + _kwargs.update({"file_extension": ext}) + + # Copy any additional global options + if "mlm_client" not in _kwargs and self._mlm_client is not None: + _kwargs["mlm_client"] = self._mlm_client + + if "mlm_model" not in _kwargs and self._mlm_model is not None: + _kwargs["mlm_model"] = self._mlm_model + + # If we hit an error log it and keep trying + # try: + res = converter.convert(local_path, **_kwargs) + # except Exception: + # error_trace = ("\n\n" + traceback.format_exc()).strip() + + if res is not None: + # Normalize the content + res.text_content = "\n".join([line.rstrip() for line in re.split(r"\r?\n", res.text_content)]) + res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content) + + # Todo + return res + + # If we got this far without success, report any exceptions + if len(error_trace) > 0: + raise FileConversionException( + f"Could not convert '{local_path}' to Markdown. File type was recognized as {extensions}. While converting the file, the following error was encountered:\n\n{error_trace}" + ) + + # Nothing can handle it! + raise UnsupportedFormatException( + f"Could not convert '{local_path}' to Markdown. The formats {extensions} are not supported." + ) + + def _append_ext(self, extensions, ext): + """Append a unique non-None, non-empty extension to a list of extensions.""" + if ext is None: + return + ext = ext.strip() + if ext == "": + return + # if ext not in extensions: + if True: + extensions.append(ext) + + def _guess_ext_magic(self, path): + """Use puremagic (a Python implementation of libmagic) to guess a file's extension based on the first few bytes.""" + # Use puremagic to guess + try: + guesses = puremagic.magic_file(path) + if len(guesses) > 0: + ext = guesses[0].extension.strip() + if len(ext) > 0: + return ext + except FileNotFoundError: + pass + except IsADirectoryError: + pass + except PermissionError: + pass + return None + + def register_page_converter(self, converter: DocumentConverter) -> None: + """Register a page text converter.""" + self._page_converters.insert(0, converter) + import base64 + import io + try: + global page + phtml = page.evaluate("document.documentElement.outerHTML;") + mdconvert = MarkdownConverter() + if page.url == "about:blank": + raise Exception("You cannot convert the content of the blank page. It's meaningless. Make sure you have visited a valid page before converting.") + res = mdconvert.convert_stream(io.StringIO(phtml), file_extension=".html", url=page.url) + + clean_md = f"""# {res.title}\n\n{res.text_content}\n\nIf you have not yet got the answer and want to back to the previous page, please use `visit_url(url={repr(page.url)})`""" + + # 将markdown内容转换为简单的HTML结构 + # 将markdown内容按固定长度分块 + chunk_size = 10000 # 每块大约1000字符 + content = res.text_content + chunks = [content[i:i+chunk_size] for i in range(0, len(content), chunk_size)] + + formatted_content = '' + if len(chunks) > 1: + notes = f"The converted markdown text is divided into {len(chunks)} chunks, you can use `page_down()` and `page_up()` to navigate through the text." + else: + notes = "" + for i, chunk in enumerate(chunks): + formatted_content += f''' +
+ {chunk} +
+ ''' + + html_content = f""" + + + {res.title} + + + +
+ {formatted_content} +
+ If you have not yet got the answer and want to back to the previous page, please use `visit_url(url={repr(page.url)})` +
+
+ + + """ + + # 使用base64编码并通过goto显示 + goto( + "data:text/html;base64," + + base64.b64encode(html_content.encode("utf-8")).decode("utf-8") + ) + + # 触发pageshow事件 + page.evaluate(""" + const event = new Event('pageshow', { + bubbles: true, + cancelable: false + }); + window.dispatchEvent(event); + """) + + + # global page + # from playwright.sync_api import sync_playwright + # import io + # with sync_playwright() as p: + # # 启动浏览器 + # url = page.url + # new_browser = p.chromium.launch(headless=True) + # new_page = new_browser.new_page() + # new_page.context.add_cookies([ + # { + # "domain": ".youtube.com", + # "expirationDate": 1718884961, + # "hostOnly": False, + # "httpOnly": False, + # "name": "ST-xuwub9", + # "path": "/", + # "sameSite": "None", + # "secure": False, + # "session": False, + # "storeId": None, + # "value": "session_logininfo=AFmmF2swRAIgf4gadACOuWOcipI1anW-dakEjtidNLkufnOC8uml7EECIDh2YisqWELDBJPTGUysCucJ3I0wjXxYjVHro1LHrdW0%3AQUQ3MjNmd2Jiajl3OWZYRnpFNnZlWWV5ZGJWZ0hpcmp4LVVPU280bk4zOS03Z0ozZG9fOFhWZ0dXaVo3NG1wTEg1b3hGaG10TFBlaFBnTlJfbER5bEp0aFhoNS1OLVhYNFRZT2F6ajgzOFpDbGhlUjZpMWRETlFFRjFfTTRiM0RnNTROSkdmMTFMVjFic1VuZ2trbGp4aktDa0JJUC1BWDh3" + # }, + # ]) + + # # 访问 YouTube 视频 + + # new_page.goto(url, wait_until="networkidle") + + # # 获取页面 HTML + # html = new_page.evaluate("document.documentElement.outerHTML;") + + # # 使用 MarkdownConverter 转换 + # mdconvert = MarkdownConverter() + # res = mdconvert.convert_stream(io.StringIO(html), file_extension=".html", url=url) + + # clean_md = f"""# {res.title}\n\n{res.text_content}""" + + # # 将markdown内容转换为简单的HTML结构 + # html_content = f""" + # + # + # {res.title} + # + # + # + #
{clean_md}
+ # + # + # """ + + # # 使用base64编码并通过goto显示 + # goto( + # "data:text/html;base64," + + # base64.b64encode(html_content.encode("utf-8")).decode("utf-8") + # ) + + # # 触发pageshow事件 + # page.evaluate(""" + # const event = new Event('pageshow', { + # bubbles: true, + # cancelable: false + # }); + # window.dispatchEvent(event); + # """) + + # # 关闭浏览器 + # new_browser.close() + except Exception as e: + raise Exception(f"Get page markdown error: {str(e)}") +if __name__ == "__main__": + from playwright.sync_api import sync_playwright + import io + with sync_playwright() as p: + # 启动浏览器 + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + # 访问 YouTube 视频 + url = "https://www.researchgate.net/publication/232696279_The_influence_of_social_environment_on_sex_determination_in_harlequin_shrimp_Hymenocera_picta_Decapoda_Gnathophyllidae" + page.goto(url, wait_until="networkidle") + + # 获取页面 HTML + html = page.evaluate("document.documentElement.outerHTML;") + + # 使用 MarkdownConverter 转换 + mdconvert = MarkdownConverter() + res = mdconvert.convert_stream(io.StringIO(html), file_extension=".html", url=url) + + print('标题:', res.title) + print('\n内容:') + print(res.text_content) + + # 关闭浏览器 + browser.close() + # mdconvert = MarkdownConverter() + # res = mdconvert.convert_local("/Users/tangjiabin/Documents/reasoning/metachain/eval_data/GAIA/2023/validation/2b3ef98c-cc05-450b-a719-711aee40ac65.mp3") + # print('title:', res.title) + # print('content:', res.text_content) \ No newline at end of file diff --git a/metachain/environment/new.py b/metachain/environment/new.py new file mode 100644 index 0000000..1ccb6f0 --- /dev/null +++ b/metachain/environment/new.py @@ -0,0 +1,568 @@ +import base64 +import io +import logging +import pkgutil +import re +from typing import Literal + +import numpy as np +import PIL.Image +import playwright.sync_api + +from .constants import BROWSERGYM_ID_ATTRIBUTE as BID_ATTR +from .constants import BROWSERGYM_SETOFMARKS_ATTRIBUTE as SOM_ATTR +from .constants import BROWSERGYM_VISIBILITY_ATTRIBUTE as VIS_ATTR + +MARK_FRAMES_MAX_TRIES = 3 + + +logger = logging.getLogger(__name__) + + +class MarkingError(Exception): + pass + + +def _pre_extract( + page: playwright.sync_api.Page, + tags_to_mark: Literal["all", "standard_html"] = "standard_html", + lenient: bool = False, +): + """ + pre-extraction routine, marks dom elements (set bid and dynamic attributes like value and checked) + """ + js_frame_mark_elements = pkgutil.get_data(__name__, "javascript/frame_mark_elements.js").decode( + "utf-8" + ) + + # we can't run this loop in JS due to Same-Origin Policy + # (can't access the content of an iframe from a another one) + def mark_frames_recursive(frame, frame_bid: str): + assert frame_bid == "" or re.match(r"^[a-z][a-zA-Z]*$", frame_bid) + logger.debug(f"Marking frame {repr(frame_bid)}") + + # mark all DOM elements in the frame (it will use the parent frame element's bid as a prefix) + warning_msgs = frame.evaluate( + js_frame_mark_elements, + [frame_bid, BID_ATTR, tags_to_mark], + ) + # print warning messages if any + for msg in warning_msgs: + logger.warning(msg) + + # recursively mark all descendant frames + for child_frame in frame.child_frames: + # deal with detached frames + if child_frame.is_detached(): + continue + # deal with weird frames (pdf viewer in ) + child_frame_elem = child_frame.frame_element() + if not child_frame_elem.content_frame() == child_frame: + logger.warning( + f"Skipping frame '{child_frame.name}' for marking, seems problematic." + ) + continue + # deal with sandboxed frames with blocked script execution + sandbox_attr = child_frame_elem.get_attribute("sandbox") + if sandbox_attr is not None and "allow-scripts" not in sandbox_attr.split(): + continue + child_frame_bid = child_frame_elem.get_attribute(BID_ATTR) + if child_frame_bid is None: + if lenient: + logger.warning("Cannot mark a child frame without a bid. Skipping frame.") + continue + else: + raise MarkingError("Cannot mark a child frame without a bid.") + mark_frames_recursive(child_frame, frame_bid=child_frame_bid) + + # mark all frames recursively + mark_frames_recursive(page.main_frame, frame_bid="") + + +def _post_extract(page: playwright.sync_api.Page): + js_frame_unmark_elements = pkgutil.get_data( + __name__, "javascript/frame_unmark_elements.js" + ).decode("utf-8") + + # we can't run this loop in JS due to Same-Origin Policy + # (can't access the content of an iframe from a another one) + for frame in page.frames: + try: + if not frame == page.main_frame: + # deal with weird frames (pdf viewer in ) + if not frame.frame_element().content_frame() == frame: + logger.warning( + f"Skipping frame '{frame.name}' for unmarking, seems problematic." + ) + continue + # deal with sandboxed frames with blocked script execution + sandbox_attr = frame.frame_element().get_attribute("sandbox") + if sandbox_attr is not None and "allow-scripts" not in sandbox_attr.split(): + continue + # deal with frames without a BID + bid = frame.frame_element().get_attribute(BID_ATTR) + if bid is None: + continue + + frame.evaluate(js_frame_unmark_elements) + except playwright.sync_api.Error as e: + if any(msg in str(e) for msg in ("Frame was detached", "Frame has been detached")): + pass + else: + raise e + + +def extract_screenshot(page: playwright.sync_api.Page): + """ + Extracts the screenshot image of a Playwright page using Chrome DevTools Protocol. + + Args: + page: the playwright page of which to extract the screenshot. + + Returns: + A screenshot of the page, in the form of a 3D array (height, width, rgb). + + """ + + cdp = page.context.new_cdp_session(page) + cdp_answer = cdp.send( + "Page.captureScreenshot", + { + "format": "png", + }, + ) + cdp.detach() + + # bytes of a png file + png_base64 = cdp_answer["data"] + png_bytes = base64.b64decode(png_base64) + with io.BytesIO(png_bytes) as f: + # load png as a PIL image + img = PIL.Image.open(f) + # convert to RGB (3 channels) + img = img.convert(mode="RGB") + # convert to a numpy array + img = np.array(img) + + return img + + +# we could handle more data items here if needed +__BID_EXPR = r"([a-zA-Z0-9]+)" +__DATA_REGEXP = re.compile(r"^browsergym_id_" + __BID_EXPR + r"\s?" + r"(.*)") + + +def extract_data_items_from_aria(string: str, log_level: int = logging.NOTSET): + """ + Utility function to extract temporary data stored in the ARIA attributes of a node + """ + + match = __DATA_REGEXP.fullmatch(string) + if not match: + logger.log( + level=log_level, + msg=f"Failed to extract BrowserGym data from ARIA string: {repr(string)}", + ) + return [], string + + groups = match.groups() + data_items = groups[:-1] + original_aria = groups[-1] + return data_items, original_aria + + +def extract_dom_snapshot( + page: playwright.sync_api.Page, + computed_styles=[], + include_dom_rects: bool = True, + include_paint_order: bool = True, + temp_data_cleanup: bool = True, +): + """ + Extracts the DOM snapshot of a Playwright page using Chrome DevTools Protocol. + + Args: + page: the playwright page of which to extract the screenshot. + computed_styles: whitelist of computed styles to return. + include_dom_rects: whether to include DOM rectangles (offsetRects, clientRects, scrollRects) in the snapshot. + include_paint_order: whether to include paint orders in the snapshot. + temp_data_cleanup: whether to clean up the temporary data stored in the ARIA attributes. + + Returns: + A document snapshot, including the full DOM tree of the root node (including iframes, + template contents, and imported documents) in a flattened array, as well as layout + and white-listed computed style information for the nodes. Shadow DOM in the returned + DOM tree is flattened. + + """ + cdp = page.context.new_cdp_session(page) + dom_snapshot = cdp.send( + "DOMSnapshot.captureSnapshot", + { + "computedStyles": computed_styles, + "includeDOMRects": include_dom_rects, + "includePaintOrder": include_paint_order, + }, + ) + cdp.detach() + + # if requested, remove temporary data stored in the ARIA attributes of each node + if temp_data_cleanup: + pop_bids_from_attribute(dom_snapshot, "aria-roledescription") + pop_bids_from_attribute(dom_snapshot, "aria-description") + + return dom_snapshot + + +def pop_bids_from_attribute(dom_snapshot, attr: str): + try: + target_attr_name_id = dom_snapshot["strings"].index(attr) + except ValueError: + target_attr_name_id = -1 + # run the cleanup only if the target attribute string is present + if target_attr_name_id > -1: + processed_string_ids = set() + for document in dom_snapshot["documents"]: + for node_attributes in document["nodes"]["attributes"]: + i = 0 + # find the target attribute, if any + for i in range(0, len(node_attributes), 2): + attr_name_id = node_attributes[i] + attr_value_id = node_attributes[i + 1] + if attr_name_id == target_attr_name_id: + attr_value = dom_snapshot["strings"][attr_value_id] + # remove any data stored in the target attribute + if attr_value_id not in processed_string_ids: + _, new_attr_value = extract_data_items_from_aria(attr_value) + dom_snapshot["strings"][ + attr_value_id + ] = new_attr_value # update the string in the metadata + processed_string_ids.add( + attr_value_id + ) # mark string as processed (in case several nodes share the same target attribute string value) + attr_value = new_attr_value + # remove target attribute (name and value) if empty + if attr_value == "": + del node_attributes[i : i + 2] + # once target attribute is found, exit the search + break + + +def extract_dom_extra_properties(dom_snapshot): + def to_string(idx): + if idx == -1: + return None + else: + return dom_snapshot["strings"][idx] + + # pre-locate important string ids + try: + bid_string_id = dom_snapshot["strings"].index(BID_ATTR) + except ValueError: + bid_string_id = -1 + try: + vis_string_id = dom_snapshot["strings"].index(VIS_ATTR) + except ValueError: + vis_string_id = -1 + try: + som_string_id = dom_snapshot["strings"].index(SOM_ATTR) + except ValueError: + som_string_id = -1 + + # build the iframe tree (DFS from the first frame) + doc_properties = { + 0: { + "parent": None, + } + } + + docs_to_process = [0] + while docs_to_process: + doc = docs_to_process.pop(-1) # DFS + + children = dom_snapshot["documents"][doc]["nodes"]["contentDocumentIndex"] + for node, child_doc in zip(children["index"], children["value"]): + doc_properties[child_doc] = { + "parent": { + "doc": doc, # parent frame index + "node": node, # node index within the parent frame + } + } + docs_to_process.append(child_doc) + + # recover the absolute x and y position of the frame node in the parent (if any) + parent = doc_properties[doc]["parent"] + if parent: + parent_doc = parent["doc"] + parent_node = parent["node"] + try: + node_layout_idx = dom_snapshot["documents"][parent_doc]["layout"][ + "nodeIndex" + ].index(parent_node) + except ValueError: + node_layout_idx = -1 + if node_layout_idx >= 0: + node_bounds = dom_snapshot["documents"][parent_doc]["layout"]["bounds"][ + node_layout_idx + ] # can be empty? + # absolute position of parent + relative position of frame node within parent + parent_node_abs_x = doc_properties[parent_doc]["abs_pos"]["x"] + node_bounds[0] + parent_node_abs_y = doc_properties[parent_doc]["abs_pos"]["y"] + node_bounds[1] + else: + parent_node_abs_x = 0 + parent_node_abs_y = 0 + else: + parent_node_abs_x = 0 + parent_node_abs_y = 0 + + # get the frame's absolute position, by adding any scrolling offset if any + doc_properties[doc]["abs_pos"] = { + "x": parent_node_abs_x - dom_snapshot["documents"][doc]["scrollOffsetX"], + "y": parent_node_abs_y - dom_snapshot["documents"][doc]["scrollOffsetY"], + } + + document = dom_snapshot["documents"][doc] + doc_properties[doc]["nodes"] = [ + { + "bid": None, # default value, to be filled (str) + "visibility": None, # default value, to be filled (float) + "bbox": None, # default value, to be filled (list) + "clickable": False, # default value, to be filled (bool) + "set_of_marks": None, # default value, to be filled (bool) + } + for _ in enumerate(document["nodes"]["parentIndex"]) + ] # all nodes in document + + # extract clickable property + for node_idx in document["nodes"]["isClickable"]["index"]: + doc_properties[doc]["nodes"][node_idx]["clickable"] = True + + # extract bid and visibility properties (attribute-based) + for node_idx, node_attrs in enumerate(document["nodes"]["attributes"]): + i = 0 + # loop over all attributes + for i in range(0, len(node_attrs), 2): + name_string_id = node_attrs[i] + value_string_id = node_attrs[i + 1] + if name_string_id == bid_string_id: + doc_properties[doc]["nodes"][node_idx]["bid"] = to_string(value_string_id) + if name_string_id == vis_string_id: + doc_properties[doc]["nodes"][node_idx]["visibility"] = float( + to_string(value_string_id) + ) + if name_string_id == som_string_id: + doc_properties[doc]["nodes"][node_idx]["set_of_marks"] = ( + to_string(value_string_id) == "1" + ) + + # extract bbox property (in absolute coordinates) + for node_idx, bounds, client_rect in zip( + document["layout"]["nodeIndex"], + document["layout"]["bounds"], + document["layout"]["clientRects"], + ): + # empty clientRect means element is not actually rendered + if not client_rect: + doc_properties[doc]["nodes"][node_idx]["bbox"] = None + else: + # bounds gives the relative position within the document + doc_properties[doc]["nodes"][node_idx]["bbox"] = bounds.copy() + # adjust for absolute document position + doc_properties[doc]["nodes"][node_idx]["bbox"][0] += doc_properties[doc]["abs_pos"][ + "x" + ] + doc_properties[doc]["nodes"][node_idx]["bbox"][1] += doc_properties[doc]["abs_pos"][ + "y" + ] + + # Note: other interesting fields + # document["nodes"]["parentIndex"] # parent node + # document["nodes"]["nodeType"] + # document["nodes"]["nodeName"] + # document["nodes"]["nodeValue"] + # document["nodes"]["textValue"] + # document["nodes"]["inputValue"] + # document["nodes"]["inputChecked"] + # document["nodes"]["optionSelected"] + # document["nodes"]["pseudoType"] + # document["nodes"]["pseudoIdentifier"] + # document["nodes"]["isClickable"] + # document["textBoxes"] + # document["layout"]["nodeIndex"] + # document["layout"]["bounds"] + # document["layout"]["offsetRects"] + # document["layout"]["scrollRects"] + # document["layout"]["clientRects"] + # document["layout"]["paintOrders"] + + # collect the extra properties of all nodes with a browsergym_id attribute + extra_properties = {} + for doc in doc_properties.keys(): + for node in doc_properties[doc]["nodes"]: + bid = node["bid"] + if bid: + if bid in extra_properties: + logger.warning(f"duplicate {BID_ATTR}={repr(bid)} attribute detected") + extra_properties[bid] = { + extra_prop: node[extra_prop] + for extra_prop in ("visibility", "bbox", "clickable", "set_of_marks") + } + + return extra_properties + + +def extract_all_frame_axtrees(page: playwright.sync_api.Page): + """ + Extracts the AXTree of all frames (main document and iframes) of a Playwright page using Chrome DevTools Protocol. + + Args: + page: the playwright page of which to extract the frame AXTrees. + + Returns: + A dictionnary of AXTrees (as returned by Chrome DevTools Protocol) indexed by frame IDs. + + """ + cdp = page.context.new_cdp_session(page) + + # extract the frame tree + frame_tree = cdp.send( + "Page.getFrameTree", + {}, + ) + + # extract all frame IDs into a list + # (breadth-first-search through the frame tree) + frame_ids = [] + root_frame = frame_tree["frameTree"] + frames_to_process = [root_frame] + while frames_to_process: + frame = frames_to_process.pop() + frames_to_process.extend(frame.get("childFrames", [])) + # extract the frame ID + frame_id = frame["frame"]["id"] + frame_ids.append(frame_id) + + # extract the AXTree of each frame + frame_axtrees = { + frame_id: cdp.send( + "Accessibility.getFullAXTree", + {"frameId": frame_id}, + ) + for frame_id in frame_ids + } + + cdp.detach() + + # extract browsergym data from ARIA attributes + for ax_tree in frame_axtrees.values(): + for node in ax_tree["nodes"]: + data_items = [] + # look for data in the node's "roledescription" property + if "properties" in node: + for i, prop in enumerate(node["properties"]): + if prop["name"] == "roledescription": + data_items, new_value = extract_data_items_from_aria(prop["value"]["value"]) + prop["value"]["value"] = new_value + # remove the "description" property if empty + if new_value == "": + del node["properties"][i] + break + # look for data in the node's "description" (fallback plan) + if "description" in node: + data_items_bis, new_value = extract_data_items_from_aria( + node["description"]["value"] + ) + node["description"]["value"] = new_value + if new_value == "": + del node["description"] + if not data_items: + data_items = data_items_bis + # add the extracted "browsergym" data to the AXTree + if data_items: + (browsergym_id,) = data_items + node["browsergym_id"] = browsergym_id + return frame_axtrees + + +def extract_merged_axtree(page: playwright.sync_api.Page): + """ + Extracts the merged AXTree of a Playwright page (main document and iframes AXTrees merged) using Chrome DevTools Protocol. + + Args: + page: the playwright page of which to extract the merged AXTree. + + Returns: + A merged AXTree (same format as those returned by Chrome DevTools Protocol). + + """ + frame_axtrees = extract_all_frame_axtrees(page) + + cdp = page.context.new_cdp_session(page) + + # merge all AXTrees into one + merged_axtree = {"nodes": []} + for ax_tree in frame_axtrees.values(): + merged_axtree["nodes"].extend(ax_tree["nodes"]) + # connect each iframe node to the corresponding AXTree root node + for node in ax_tree["nodes"]: + if node["role"]["value"] == "Iframe": + frame_id = ( + cdp.send("DOM.describeNode", {"backendNodeId": node["backendDOMNodeId"]}) + .get("node", {}) + .get("frameId", None) + ) + if not frame_id: + logger.warning( + f"AXTree merging: unable to recover frameId of node with backendDOMNodeId {repr(node['backendDOMNodeId'])}, skipping" + ) + # it seems Page.getFrameTree() from CDP omits certain Frames (empty frames?) + # if a frame is not found in the extracted AXTrees, we just ignore it + elif frame_id in frame_axtrees: + # root node should always be the first node in the AXTree + frame_root_node = frame_axtrees[frame_id]["nodes"][0] + assert frame_root_node["frameId"] == frame_id + node["childIds"].append(frame_root_node["nodeId"]) + else: + logger.warning( + f"AXTree merging: extracted AXTree does not contain frameId '{frame_id}', skipping" + ) + + cdp.detach() + + return merged_axtree + + +def extract_focused_element_bid(page: playwright.sync_api.Page): + # this JS code will dive through ShadowDOMs + extract_focused_element_with_bid_script = """\ +() => { + // This recursive function traverses shadow DOMs + function getActiveElement(root) { + const active_element = root.activeElement; + + if (!active_element) { + return null; + } + + if (active_element.shadowRoot) { + return getActiveElement(active_element.shadowRoot); + } else { + return active_element; + } + } + return getActiveElement(document); +}""" + # this playwright code will dive through iFrames + frame = page + focused_bid = "" + while frame: + focused_element = frame.evaluate_handle( + extract_focused_element_with_bid_script, BID_ATTR + ).as_element() + if focused_element: + frame = focused_element.content_frame() + focused_bid = focused_element.get_attribute(BID_ATTR) + else: + frame = None + + return focused_bid \ No newline at end of file diff --git a/metachain/environment/seleniumbase_obs.py b/metachain/environment/seleniumbase_obs.py new file mode 100644 index 0000000..3d9e808 --- /dev/null +++ b/metachain/environment/seleniumbase_obs.py @@ -0,0 +1,491 @@ +import base64 +import io +import logging +import re +from typing import Dict, List, Optional + +import numpy as np +import PIL.Image + +logger = logging.getLogger(__name__) + +BID_ATTR = "browsergym_id" +VIS_ATTR = "browsergym_visibility" +SOM_ATTR = "browsergym_set_of_marks" + +class MarkingError(Exception): + pass + +def ensure_cdp_activated(browser): + """确保CDP模式已激活""" + if not hasattr(browser, 'cdp') or browser.cdp is None: + current_url = browser.get_current_url() + browser.activate_cdp_mode(current_url if current_url else "about:blank") + browser.sleep(1) # 等待CDP模式激活 + +def _pre_extract(browser): + """标记DOM元素""" + try: + ensure_cdp_activated(browser) + + # 定义并注入标记函数 + browser.cdp.evaluate(""" + window.markElements = function(frameBid='') { + function markElementsInDocument(doc, bid_prefix='') { + const elements = doc.getElementsByTagName('*'); + for (let element of elements) { + if (!element.hasAttribute('browsergym_id')) { + const bid = bid_prefix + element.tagName.toLowerCase() + '_' + + Math.random().toString(36).substr(2, 9); + element.setAttribute('browsergym_id', bid); + } + } + + // 递归处理所有iframe + const iframes = doc.getElementsByTagName('iframe'); + for (let iframe of iframes) { + try { + const frameDoc = iframe.contentDocument; + if (frameDoc) { + const frameBid = iframe.getAttribute('browsergym_id') || ''; + const sandbox = iframe.getAttribute('sandbox'); + if (!sandbox || sandbox.includes('allow-scripts')) { + markElementsInDocument(frameDoc, frameBid); + } + } + } catch (e) { + // 跨域iframe会抛出错误,忽略即可 + console.log('Cannot access iframe:', e); + } + } + } + + // 从当前文档开始标记 + markElementsInDocument(document, frameBid); + + return true; + }; + """) + + # 执行标记 + success = browser.cdp.evaluate("window.markElements()") + if not success: + raise MarkingError("Failed to mark elements") + + except Exception as e: + raise MarkingError(f"Error marking elements: {str(e)}") + +def extract_dom_snapshot(browser): + """获取DOM快照""" + try: + ensure_cdp_activated(browser) + + # 定义函数 + browser.cdp.evaluate(""" + window.getDOMSnapshot = function() { + const strings = new Map(); + let stringId = 0; + + function getStringId(str) { + if (str === null || str === undefined) return -1; + if (!strings.has(str)) { + strings.set(str, stringId++); + } + return strings.get(str); + } + + function processDocument(doc) { + function processNode(node, parentIndex) { + const nodeData = { + nodeType: [], + nodeName: [], + nodeValue: [], + parentIndex: [], + attributes: [], + contentDocumentIndex: { + index: [], + value: [] + } + }; + + nodeData.nodeType.push(node.nodeType); + nodeData.nodeName.push(getStringId(node.nodeName)); + nodeData.nodeValue.push(getStringId(node.nodeValue)); + nodeData.parentIndex.push(parentIndex); + + const attrs = []; + if (node.attributes) { + for (let attr of node.attributes) { + attrs.push(getStringId(attr.name)); + attrs.push(getStringId(attr.value)); + } + } + nodeData.attributes.push(attrs); + + if (node.nodeType === 1) { // Element node + const iframes = node.getElementsByTagName('iframe'); + for (let i = 0; i < iframes.length; i++) { + try { + const frameDoc = iframes[i].contentDocument; + if (frameDoc) { + nodeData.contentDocumentIndex.index.push(nodeData.nodeType.length - 1); + nodeData.contentDocumentIndex.value.push(1); // Assuming single document for now + } + } catch (e) { + console.log('Cannot access iframe:', e); + } + } + } + + for (let child of node.childNodes) { + const childData = processNode(child, nodeData.nodeType.length - 1); + + nodeData.nodeType.push(...childData.nodeType); + nodeData.nodeName.push(...childData.nodeName); + nodeData.nodeValue.push(...childData.nodeValue); + nodeData.parentIndex.push(...childData.parentIndex); + nodeData.attributes.push(...childData.attributes); + nodeData.contentDocumentIndex.index.push(...childData.contentDocumentIndex.index); + nodeData.contentDocumentIndex.value.push(...childData.contentDocumentIndex.value); + } + + return nodeData; + } + + return processNode(doc.documentElement, -1); + } + + const rootData = processDocument(document); + const stringsArray = Array.from(strings.keys()); + + return { + documents: [{ + nodes: rootData + }], + strings: stringsArray + }; + }; + """) + + # 执行函数 + dom_snapshot = browser.cdp.evaluate("window.getDOMSnapshot()") + + return dom_snapshot + + except Exception as e: + logger.error(f"Error capturing DOM snapshot: {str(e)}") + return {"documents": [], "strings": []} + +def extract_dom_extra_properties(browser) -> Dict: + """获取DOM元素的有意义的额外属性""" + try: + ensure_cdp_activated(browser) + + browser.cdp.evaluate(""" + window.getExtraProperties = function() { + const BID_ATTR = 'browsergym_id'; + const VIS_ATTR = 'browsergym_visibility'; + const SOM_ATTR = 'browsergym_set_of_marks'; + + // 定义重要的标签和属性 + const IMPORTANT_TAGS = new Set([ + 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'FORM', + 'IMG', 'VIDEO', 'AUDIO', 'IFRAME', 'LABEL', 'H1', 'H2', + 'H3', 'H4', 'H5', 'H6' + ]); + + const IMPORTANT_ROLES = new Set([ + 'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', + 'listbox', 'menu', 'menuitem', 'tab', 'tabpanel', 'tree', + 'treeitem', 'dialog', 'alert', 'alertdialog', 'tooltip' + ]); + + function isElementVisible(element) { + const style = window.getComputedStyle(element); + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + element.offsetWidth > 0 && + element.offsetHeight > 0; + } + + function isElementInteractive(element) { + // 检查是否可交互 + return element.onclick !== null || + element.onmousedown !== null || + element.onmouseup !== null || + element.onkeydown !== null || + element.onkeyup !== null || + element.onchange !== null || + element.onfocus !== null || + element.onblur !== null; + } + + function isElementMeaningful(element) { + // 检查标签是否重要 + if (IMPORTANT_TAGS.has(element.tagName)) return true; + + // 检查角色是否重要 + const role = element.getAttribute('role'); + if (role && IMPORTANT_ROLES.has(role)) return true; + + // 检查是否有重要的ARIA属性 + if (element.hasAttribute('aria-label')) return true; + if (element.hasAttribute('aria-description')) return true; + + // 检查是否可交互 + if (isElementInteractive(element)) return true; + + // 检查是否有有意义的文本内容 + const text = element.textContent.trim(); + if (text && text.length > 1 && !/^[\s\d.,]+$/.test(text)) return true; + + // 检查是否有有意义的图片 + if (element.tagName === 'IMG' && element.alt) return true; + + return false; + } + + function getDocumentProperties(doc, parentFrame = null) { + const properties = {}; + const frameOffset = { + x: 0, + y: 0 + }; + + if (parentFrame) { + const frameRect = parentFrame.getBoundingClientRect(); + frameOffset.x = frameRect.x + window.pageXOffset; + frameOffset.y = frameRect.y + window.pageYOffset; + } + + const elements = doc.querySelectorAll(`[${BID_ATTR}]`); + + elements.forEach(element => { + // 只处理有意义的元素 + if (!isElementMeaningful(element)) return; + + // 只处理可见元素 + if (!isElementVisible(element)) return; + + const bid = element.getAttribute(BID_ATTR); + if (!bid) return; + + let visibility = element.getAttribute(VIS_ATTR); + visibility = visibility ? parseFloat(visibility) : 1.0; + + const rect = element.getBoundingClientRect(); + const bbox = rect ? [ + rect.x + window.pageXOffset + frameOffset.x, + rect.y + window.pageYOffset + frameOffset.y, + rect.width, + rect.height + ] : null; + + // 更精确的可点击检测 + const isClickable = ( + element.tagName === 'BUTTON' || + element.tagName === 'A' || + (element.tagName === 'INPUT' && + ['button', 'submit', 'reset', 'radio', 'checkbox'].includes(element.type)) || + element.getAttribute('role') === 'button' || + isElementInteractive(element) || + window.getComputedStyle(element).cursor === 'pointer' + ); + + const setOfMarks = element.getAttribute(SOM_ATTR) === '1'; + + // 添加额外的有用信息 + const extraInfo = { + tag: element.tagName.toLowerCase(), + type: element.type || null, + role: element.getAttribute('role') || null, + text: element.textContent.trim() || null, + ariaLabel: element.getAttribute('aria-label') || null + }; + + properties[bid] = { + visibility: visibility, + bbox: bbox, + clickable: isClickable, + set_of_marks: setOfMarks, + ...extraInfo + }; + }); + + // 递归处理iframe + const iframes = doc.getElementsByTagName('iframe'); + for (let iframe of iframes) { + try { + const frameDoc = iframe.contentDocument; + if (frameDoc) { + const frameProperties = getDocumentProperties(frameDoc, iframe); + Object.assign(properties, frameProperties); + } + } catch (e) { + console.log('Cannot access iframe:', e); + } + } + + return properties; + } + + return getDocumentProperties(document); + }; + """) + + extra_properties = browser.cdp.evaluate("window.getExtraProperties()") + return extra_properties + + except Exception as e: + logger.error(f"Error extracting extra properties: {str(e)}") + return {} + +def extract_merged_axtree(browser): + """获取更清晰的Accessibility Tree""" + try: + ensure_cdp_activated(browser) + + browser.cdp.evaluate(""" + window.getAccessibilityTree = function() { + let nodeId = 1; + + // 需要忽略的角色 + const IGNORED_ROLES = new Set([ + 'generic', + 'presentation', + 'none', + 'ScrollBar', + 'background' + ]); + + // 需要保留的HTML标签 + const IMPORTANT_TAGS = new Set([ + 'a', 'button', 'input', 'select', 'textarea', 'header', + 'nav', 'main', 'footer', 'form', 'table', 'iframe', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' + ]); + + function getElementRole(element) { + // 优先使用aria角色 + const ariaRole = element.getAttribute('role'); + if (ariaRole) return ariaRole; + + // 特殊元素的默认角色 + const tagName = element.tagName.toLowerCase(); + switch (tagName) { + case 'a': return 'link'; + case 'button': return 'button'; + case 'input': + const type = element.type; + if (type === 'checkbox') return 'checkbox'; + if (type === 'radio') return 'radio'; + if (type === 'submit') return 'button'; + return 'textbox'; + case 'select': return 'combobox'; + case 'textarea': return 'textbox'; + case 'img': return 'img'; + case 'table': return 'table'; + default: return tagName; + } + } + + function getElementName(element) { + // 按优先级获取元素名称 + return element.getAttribute('aria-label') || + element.getAttribute('title') || + element.getAttribute('alt') || + element.getAttribute('name') || + element.value || + element.textContent.trim(); + } + + function shouldIncludeElement(element) { + const tagName = element.tagName.toLowerCase(); + const role = getElementRole(element); + + // 检查是否是重要标签 + if (IMPORTANT_TAGS.has(tagName)) return true; + + // 检查是否有重要属性 + if (element.getAttribute('aria-label')) return true; + if (element.getAttribute('role')) return true; + if (element.onclick) return true; + + // 检查是否可交互 + const style = window.getComputedStyle(element); + if (style.cursor === 'pointer') return true; + + // 忽略无用角色 + if (IGNORED_ROLES.has(role)) return false; + + // 忽略空文本节点 + const text = element.textContent.trim(); + if (!text) return false; + + return true; + } + + function processNode(element) { + if (!shouldIncludeElement(element)) return null; + + const role = getElementRole(element); + const name = getElementName(element); + + // 如果既没有有效的角色也没有名称,则跳过 + if ((!role || IGNORED_ROLES.has(role)) && !name) return null; + + const node = { + nodeId: nodeId++, + role: { value: role }, + name: { value: name }, + properties: [], + childIds: [], + backendDOMNodeId: element.getAttribute('browsergym_id') || null, + frameId: element.ownerDocument?.defaultView?.frameElement?.getAttribute('browsergym_id') || null + }; + + // 收集重要的ARIA属性 + for (let attr of element.attributes) { + if (attr.name.startsWith('aria-')) { + node.properties.push({ + name: { value: attr.name }, + value: { value: attr.value } + }); + } + } + + // 递归处理子元素 + for (let child of element.children) { + const childNode = processNode(child); + if (childNode) { + node.childIds.push(childNode.nodeId); + } + } + + return node; + } + + const nodes = []; + function traverse(element) { + const node = processNode(element); + if (node) { + nodes.push(node); + for (let child of element.children) { + traverse(child); + } + } + } + + traverse(document.documentElement); + + return { nodes: nodes }; + }; + """) + + axtree = browser.cdp.evaluate("window.getAccessibilityTree()") + return axtree + + except Exception as e: + logger.error(f"Error getting accessibility tree: {str(e)}") + return {"nodes": []} \ No newline at end of file diff --git a/metachain/environment/shutdown_listener.py b/metachain/environment/shutdown_listener.py new file mode 100644 index 0000000..3aedd26 --- /dev/null +++ b/metachain/environment/shutdown_listener.py @@ -0,0 +1,65 @@ +""" +This module monitors the app for shutdown signals +""" + +import asyncio +import signal +import threading +import time +from types import FrameType + +from uvicorn.server import HANDLED_SIGNALS + +_should_exit = None + + +def _register_signal_handler(sig: signal.Signals): + original_handler = None + + def handler(sig_: int, frame: FrameType | None): + global _should_exit + _should_exit = True + if original_handler: + original_handler(sig_, frame) # type: ignore[unreachable] + + original_handler = signal.signal(sig, handler) + + +def _register_signal_handlers(): + global _should_exit + if _should_exit is not None: + return + _should_exit = False + + # Check if we're in the main thread of the main interpreter + if threading.current_thread() is threading.main_thread(): + for sig in HANDLED_SIGNALS: + _register_signal_handler(sig) + + +def should_exit() -> bool: + _register_signal_handlers() + return bool(_should_exit) + + +def should_continue() -> bool: + _register_signal_handlers() + return not _should_exit + + +def sleep_if_should_continue(timeout: float): + if timeout <= 1: + time.sleep(timeout) + return + start_time = time.time() + while (time.time() - start_time) < timeout and should_continue(): + time.sleep(1) + + +async def async_sleep_if_should_continue(timeout: float): + if timeout <= 1: + await asyncio.sleep(timeout) + return + start_time = time.time() + while time.time() - start_time < timeout and should_continue(): + await asyncio.sleep(1) diff --git a/metachain/environment/tcp_server.py b/metachain/environment/tcp_server.py new file mode 100644 index 0000000..9ad41c8 --- /dev/null +++ b/metachain/environment/tcp_server.py @@ -0,0 +1,80 @@ +import socket +import subprocess +import json +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("--workplace", type=str, default=None) +parser.add_argument("--conda_path", type=str, default=None) +parser.add_argument("--port", type=int, default=None) +args = parser.parse_args() + +if __name__ == "__main__": + assert args.workplace is not None, "Workplace is not specified" + assert args.conda_path is not None, "Conda path is not specified" + assert args.port is not None, "Port is not specified" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("0.0.0.0", args.port)) + server.listen(1) + + print(f"Listening on port {args.port}...") + def receive_all(conn, buffer_size=4096): + data = b"" + while True: + part = conn.recv(buffer_size) + data += part + if len(part) < buffer_size: + # 如果接收的数据小于缓冲区大小,可能已经接收完毕 + break + return data.decode() + + while True: + conn, addr = server.accept() + print(f"Connection from {addr}") + while True: + # command = conn.recv(1024).decode() + command = receive_all(conn) + if not command: + break + + # Execute the command + try: + modified_command = f"/bin/bash -c 'source {args.conda_path}/etc/profile.d/conda.sh && conda activate autogpt && cd /{args.workplace} && {command}'" + process = subprocess.Popen(modified_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + output = '' + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + output += line + # 立即发送每一行输出 + chunk_response = { + "type": "chunk", + "data": line + } + conn.send(json.dumps(chunk_response).encode() + b"\n") # 添加换行符作为分隔符 + + # 发送最终的完整响应 + final_response = { + "type": "final", + "status": process.poll(), + "result": output + } + conn.send(json.dumps(final_response).encode() + b"\n") + except Exception as e: + error_response = { + "type": "final", + "status": -1, + "result": f"Error running command: {str(e)}" + } + conn.send(json.dumps(error_response).encode() + b"\n") + + # Create a JSON response + # response = { + # "status": exit_code, + # "result": output + # } + + # # Send the JSON response + # conn.send(json.dumps(response).encode()) + conn.close() \ No newline at end of file diff --git a/metachain/environment/tenacity_stop.py b/metachain/environment/tenacity_stop.py new file mode 100644 index 0000000..c1edabd --- /dev/null +++ b/metachain/environment/tenacity_stop.py @@ -0,0 +1,11 @@ +from tenacity import RetryCallState +from tenacity.stop import stop_base + +from .shutdown_listener import should_exit + + +class stop_if_should_exit(stop_base): + """Stop if the should_exit flag is set.""" + + def __call__(self, retry_state: 'RetryCallState') -> bool: + return should_exit() diff --git a/metachain/environment/utils.py b/metachain/environment/utils.py new file mode 100644 index 0000000..f94389e --- /dev/null +++ b/metachain/environment/utils.py @@ -0,0 +1,16 @@ +from metachain.util import run_command_in_container +from .docker_env import DockerEnv +from metachain.io_utils import print_stream +def setup_metachain(workplace_name: str, env: DockerEnv): + cmd = "pip list | grep metachain" + response = env.run_command(cmd, print_stream) + if response['status'] == 0: + print("Metachain is already installed.") + return + cmd = f"cd /{workplace_name}/metachain && pip install -e ." + response = env.run_command(cmd, print_stream) + if response['status'] == 0: + print("Metachain is installed.") + return + else: + raise Exception(f"Failed to install metachain. {response['result']}") diff --git a/metachain/flow/__init__.py b/metachain/flow/__init__.py new file mode 100644 index 0000000..dd74af2 --- /dev/null +++ b/metachain/flow/__init__.py @@ -0,0 +1,4 @@ +from .core import EventEngineCls +from .types import EventInput, ReturnBehavior + +default_drive = EventEngineCls() diff --git a/metachain/flow/broker.py b/metachain/flow/broker.py new file mode 100644 index 0000000..7abf1d5 --- /dev/null +++ b/metachain/flow/broker.py @@ -0,0 +1,11 @@ +from typing import Any +from .types import BaseEvent, EventInput, Task, GroupEventReturns +from .utils import generate_uuid + + +class BaseBroker: + async def append(self, event: BaseEvent, event_input: EventInput) -> Task: + raise NotImplementedError() + + async def callback_after_run_done(self) -> tuple[BaseEvent, Any]: + raise NotImplementedError() diff --git a/metachain/flow/core.py b/metachain/flow/core.py new file mode 100644 index 0000000..1382885 --- /dev/null +++ b/metachain/flow/core.py @@ -0,0 +1,175 @@ +import inspect +import asyncio +from typing import Callable, Optional, Union, Any, Tuple, Literal +from .types import ( + BaseEvent, + EventFunction, + EventGroup, + EventInput, + _SpecialEventReturn, + ReturnBehavior, + InvokeInterCache, +) +from .broker import BaseBroker +from .utils import logger, string_to_md5_hash, generate_uuid + + +class EventEngineCls: + def __init__(self, name="default", broker: Optional[BaseBroker] = None): + self.name = name + self.broker = broker or BaseBroker() + self.__event_maps: dict[str, BaseEvent] = {} + self.__max_group_size = 0 + + def reset(self): + self.__event_maps = {} + + def get_event_from_id(self, event_id: str) -> Optional[BaseEvent]: + return self.__event_maps.get(event_id) + + def make_event(self, func: Union[EventFunction, BaseEvent]) -> BaseEvent: + if isinstance(func, BaseEvent): + self.__event_maps[func.id] = func + return func + assert inspect.iscoroutinefunction( + func + ), "Event function must be a coroutine function" + event = BaseEvent(func) + self.__event_maps[event.id] = event + return event + + def listen_group( + self, + group_markers: list[BaseEvent], + group_name: Optional[str] = None, + retrigger_type: Literal["all", "any"] = "all", + ) -> Callable[[BaseEvent], BaseEvent]: + assert all( + [isinstance(m, BaseEvent) for m in group_markers] + ), "group_markers must be a list of BaseEvent" + assert all( + [m.id in self.__event_maps for m in group_markers] + ), f"group_markers must be registered in the same event engine, current event engine is {self.name}" + group_markers_in_dict = {event.id: event for event in group_markers} + + def decorator(func: BaseEvent) -> BaseEvent: + if not isinstance(func, BaseEvent): + func = self.make_event(func) + assert ( + func.id in self.__event_maps + ), f"Event function must be registered in the same event engine, current event engine is {self.name}" + this_group_name = group_name or f"{len(func.parent_groups)}" + this_group_hash = string_to_md5_hash(":".join(group_markers_in_dict.keys())) + new_group = EventGroup( + this_group_name, + this_group_hash, + group_markers_in_dict, + retrigger_type=retrigger_type, + ) + self.__max_group_size = max( + self.__max_group_size, len(group_markers_in_dict) + ) + if new_group.hash() in func.parent_groups: + logger.warning(f"Group {group_markers} already listened by {func}") + return func + func.parent_groups[new_group.hash()] = new_group + return func + + return decorator + + def goto(self, group_markers: list[BaseEvent], *args): + raise NotImplementedError() + + async def invoke_event( + self, + event: BaseEvent, + event_input: Optional[EventInput] = None, + global_ctx: Any = None, + max_async_events: Optional[int] = None, + ) -> dict[str, Any]: + this_run_ctx: dict[str, InvokeInterCache] = {} + queue: list[Tuple[str, EventInput]] = [(event.id, event_input)] + + async def run_event(current_event_id: str, current_event_input: Any): + current_event = self.get_event_from_id(current_event_id) + assert current_event is not None, f"Event {current_event_id} not found" + result = await current_event.solo_run(current_event_input, global_ctx) + this_run_ctx[current_event.id] = { + "result": result, + "already_sent_to_event_group": set(), + } + if isinstance(result, _SpecialEventReturn): + if result.behavior == ReturnBehavior.GOTO: + group_markers, any_return = result.returns + for group_marker in group_markers: + this_group_returns = {current_event.id: any_return} + build_input_goto = EventInput( + group_name="$goto", + results=this_group_returns, + behavior=ReturnBehavior.GOTO, + ) + queue.append((group_marker.id, build_input_goto)) + elif result.behavior == ReturnBehavior.ABORT: + return + else: + # dispath to events who listen + for cand_event in self.__event_maps.values(): + cand_event_parents = cand_event.parent_groups + for group_hash, group in cand_event_parents.items(): + if_current_event_trigger = current_event.id in group.events + if_ctx_cover = all( + [event_id in this_run_ctx for event_id in group.events] + ) + event_group_id = f"{cand_event.id}:{group_hash}" + if if_current_event_trigger and if_ctx_cover: + if ( + any( + [ + event_group_id + in this_run_ctx[event_id][ + "already_sent_to_event_group" + ] + for event_id in group.events + ] + ) + and group.retrigger_type == "all" + ): + # some events already dispatched to this event and group, skip + logger.debug(f"Skip {cand_event} for {current_event}") + continue + this_group_returns = { + event_id: this_run_ctx[event_id]["result"] + for event_id in group.events + } + for event_id in group.events: + this_run_ctx[event_id][ + "already_sent_to_event_group" + ].add(event_group_id) + build_input = EventInput( + group_name=group.name, results=this_group_returns + ) + queue.append((cand_event.id, build_input)) + + tasks = set() + try: + while len(queue) or len(tasks): + this_batch_events = ( + queue[:max_async_events] if max_async_events else queue + ) + queue = queue[max_async_events:] if max_async_events else [] + new_tasks = { + asyncio.create_task(run_event(*run_event_input)) + for run_event_input in this_batch_events + } + tasks.update(new_tasks) + done, tasks = await asyncio.wait( + tasks, return_when=asyncio.FIRST_COMPLETED + ) + for task in done: + await task # Handle any exceptions + except asyncio.CancelledError: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + return this_run_ctx diff --git a/metachain/flow/dynamic.py b/metachain/flow/dynamic.py new file mode 100644 index 0000000..048c9ed --- /dev/null +++ b/metachain/flow/dynamic.py @@ -0,0 +1,18 @@ +from typing import Any +from .types import ( + BaseEvent, + _SpecialEventReturn, + ReturnBehavior, +) + + +def goto_events( + group_markers: list[BaseEvent], any_return: Any = None +) -> _SpecialEventReturn: + return _SpecialEventReturn( + behavior=ReturnBehavior.GOTO, returns=(group_markers, any_return) + ) + + +def abort_this(): + return _SpecialEventReturn(behavior=ReturnBehavior.ABORT, returns=None) diff --git a/metachain/flow/types.py b/metachain/flow/types.py new file mode 100644 index 0000000..3dd52f6 --- /dev/null +++ b/metachain/flow/types.py @@ -0,0 +1,146 @@ +from copy import copy +from enum import Enum +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Awaitable, Optional, Union, Callable, TypedDict, Literal + +from .utils import ( + string_to_md5_hash, + generate_uuid, + function_or_method_to_string, + function_or_method_to_repr, +) + + +class ReturnBehavior(Enum): + DISPATCH = "dispatch" + GOTO = "goto" + ABORT = "abort" + INPUT = "input" + + +class TaskStatus(Enum): + RUNNING = "running" + SUCCESS = "success" + FAILURE = "failure" + PENDING = "pending" + + +class InvokeInterCache(TypedDict): + result: Any + already_sent_to_event_group: set[str] + + +GroupEventReturns = dict[str, Any] + + +@dataclass +class EventGroupInput: + group_name: str + results: GroupEventReturns + behavior: ReturnBehavior = ReturnBehavior.DISPATCH + + +@dataclass +class EventInput(EventGroupInput): + task_id: str = field(default_factory=generate_uuid) + + @classmethod + def from_input(cls: "EventInput", input_data: dict[str, Any]) -> "EventInput": + return cls( + group_name="user_input", results=input_data, behavior=ReturnBehavior.INPUT + ) + + +@dataclass +class _SpecialEventReturn: + behavior: ReturnBehavior + returns: Any + + def __post_init__(self): + if not isinstance(self.behavior, ReturnBehavior): + raise TypeError( + f"behavior must be a ReturnBehavior, not {type(self.behavior)}" + ) + + +# (group_event_results, global ctx set by user) -> result +EventFunction = Callable[ + [Optional[EventInput], Optional[Any]], Awaitable[Union[Any, _SpecialEventReturn]] +] + + +@dataclass +class EventGroup: + name: str + events_hash: str + events: dict[str, "BaseEvent"] + retrigger_type: Literal["all", "any"] = "all" + + def hash(self) -> str: + return self.events_hash + + +class BaseEvent: + parent_groups: dict[str, EventGroup] + func_inst: EventFunction + id: str + repr_name: str + + def __init__( + self, + func_inst: EventFunction, + parent_groups: Optional[dict[str, EventGroup]] = None, + ): + self.parent_groups = parent_groups or {} + self.func_inst = func_inst + self.id = string_to_md5_hash(function_or_method_to_string(self.func_inst)) + self.repr_name = function_or_method_to_repr(self.func_inst) + self.meta = {"func_body": function_or_method_to_string(self.func_inst)} + + def debug_string(self, exclude_events: Optional[set[str]] = None) -> str: + exclude_events = exclude_events or set([self.id]) + parents_str = format_parents(self.parent_groups, exclude_events=exclude_events) + return f"{self.repr_name}\n{parents_str}" + + def __repr__(self) -> str: + return f"Node(source={self.repr_name})" + + async def solo_run( + self, event_input: EventInput, global_ctx: Any = None + ) -> Awaitable[Any]: + return await self.func_inst(event_input, global_ctx) + + +@dataclass +class Task: + task_id: str + status: TaskStatus = TaskStatus.PENDING + created_at: datetime = field(default_factory=datetime.now) + upated_at: datetime = field(default_factory=datetime.now) + + +def format_parents(parents: dict[str, EventGroup], exclude_events: set[str], indent=""): + # Below code is ugly + # But it works and only for debug display + result = [] + for i, parent_group in enumerate(parents.values()): + is_last_group = i == len(parents) - 1 + group_prefix = "└─ " if is_last_group else "├─ " + result.append(indent + group_prefix + f"<{parent_group.name}>") + for j, parent in enumerate(parent_group.events.values()): + root_events = copy(exclude_events) + is_last = j == len(parent_group.events) - 1 + child_indent = indent + (" " if is_last_group else "│ ") + inter_indent = " " if is_last else "│ " + prefix = "└─ " if is_last else "├─ " + if parent.id in root_events: + result.append(f"{child_indent}{prefix}{parent.repr_name} ") + continue + root_events.add(parent.id) + parent_debug = parent.debug_string(exclude_events=root_events).split("\n") + parent_debug = [p for p in parent_debug if p.strip()] + result.append(f"{child_indent}{prefix}{parent.repr_name}") + for line in parent_debug[1:]: + result.append(f"{child_indent}{inter_indent}{line}") + return "\n".join(result) diff --git a/metachain/flow/utils.py b/metachain/flow/utils.py new file mode 100644 index 0000000..86cf4a7 --- /dev/null +++ b/metachain/flow/utils.py @@ -0,0 +1,48 @@ +import uuid +import logging +import asyncio +import inspect +import hashlib +from typing import Callable + +logger = logging.getLogger("drive-flow") + + +def generate_uuid() -> str: + return str(uuid.uuid4()) + + +def function_or_method_to_repr(func_or_method: Callable) -> str: + is_method = inspect.ismethod(func_or_method) + is_function = inspect.isfunction(func_or_method) + if not is_method and not is_function: + raise ValueError("Input must be a function or method") + module = func_or_method.__module__ + name = func_or_method.__name__ + line_number = inspect.getsourcelines(func_or_method)[1] + + if is_method: + class_name = func_or_method.__self__.__class__.__name__ + return f"{module}.l_{line_number}.{class_name}.{name}".strip() + else: + return f"{module}.l_{line_number}.{name}".strip() + + +def function_or_method_to_string(func_or_method: Callable) -> str: + is_method = inspect.ismethod(func_or_method) + is_function = inspect.isfunction(func_or_method) + if not is_method and not is_function: + raise ValueError("Input must be a function or method") + module = func_or_method.__module__ + source = inspect.getsource(func_or_method) + line_number = inspect.getsourcelines(func_or_method)[1] + + if is_method: + class_name = func_or_method.__self__.__class__.__name__ + return f"{module}.l_{line_number}.{class_name}\n{source}".strip() + else: + return f"{module}.l_{line_number}\n{source}".strip() + + +def string_to_md5_hash(string: str) -> str: + return hashlib.md5(string.encode()).hexdigest() diff --git a/metachain/fn_call_converter.py b/metachain/fn_call_converter.py new file mode 100644 index 0000000..45acb7b --- /dev/null +++ b/metachain/fn_call_converter.py @@ -0,0 +1,845 @@ +"""Convert function calling messages to non-function calling messages and vice versa. + +This will inject prompts so that models that doesn't support function calling +can still be used with function calling agents. + +We follow format from: https://docs.litellm.ai/docs/completion/function_call +""" + +import copy +import json +import re +from typing import Iterable + +from litellm import ChatCompletionToolParam + +class FunctionCallConversionError(Exception): + """Exception raised when FunctionCallingConverter failed to convert a non-function call message to a function call message. + + This typically happens when there's a malformed message (e.g., missing tags). But not due to LLM output. + """ + + def __init__(self, message): + super().__init__(message) + +class FunctionCallValidationError(Exception): + """Exception raised when FunctionCallingConverter failed to validate a function call message. + + This typically happens when the LLM outputs unrecognized function call / parameter names / values. + """ + + def __init__(self, message): + super().__init__(message) + +# Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b +SYSTEM_PROMPT_SUFFIX_TEMPLATE = """ +You have access to the following functions: + +{description} + +If you choose to call a function ONLY reply in the following format with NO suffix: + + +value_1 + +This is the value for the second parameter +that can span +multiple lines + + + + +Reminder: +- Function calls MUST follow the specified format, start with +- Required parameters MUST be specified +- Only call one function at a time +- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after. +- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls +""" + +STOP_WORDS = [' + +pwd && ls + + + +USER: EXECUTION RESULT of [execute_bash]: +/workspace +openhands@runtime:~/workspace$ + +ASSISTANT: There is no `app.py` file in the current directory. Let me create a Python file `app.py`: + +create +/workspace/app.py + +from flask import Flask +app = Flask(__name__) + +@app.route('/') +def index(): + numbers = list(range(1, 11)) + return str(numbers) + +if __name__ == '__main__': + app.run(port=5000) + + + +USER: EXECUTION RESULT of [str_replace_editor]: +File created successfully at: /workspace/app.py + +ASSISTANT: I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you: + + +python3 app.py > server.log 2>&1 & + + + +USER: EXECUTION RESULT of [execute_bash]: +[1] 121 +[1]+ Exit 1 python3 app.py > server.log 2>&1 + +ASSISTANT: Looks like the server is running with PID 121 then crashed. Let me check the server log: + + +cat server.log + + + +USER: EXECUTION RESULT of [execute_bash]: +Traceback (most recent call last): + File "/workspace/app.py", line 2, in + from flask import Flask +ModuleNotFoundError: No module named 'flask' + +ASSISTANT: Looks like the server crashed because the `flask` module is not installed. Let me install the `flask` module for you: + + +pip3 install flask + + + +USER: EXECUTION RESULT of [execute_bash]: +Defaulting to user installation because normal site-packages is not writeable +Collecting flask + Using cached flask-3.0.3-py3-none-any.whl (101 kB) +Collecting blinker>=1.6.2 + Using cached blinker-1.7.0-py3-none-any.whl (13 kB) +Collecting Werkzeug>=3.0.0 + Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB) +Collecting click>=8.1.3 + Using cached click-8.1.7-py3-none-any.whl (97 kB) +Collecting itsdangerous>=2.1.2 + Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3) +Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5) +Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask +Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0 + +ASSISTANT: The `flask` module is installed successfully. Let me run the Python file again: + + +python3 app.py > server.log 2>&1 & + + + +USER: EXECUTION RESULT of [execute_bash]: +[1] 124 + +ASSISTANT: +Let me check the server log again: + + +cat server.log + + + +USER: EXECUTION RESULT of [execute_bash]: +* Serving Flask app 'app' + * Debug mode: off +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on http://127.0.0.1:5000 +Press CTRL+C to quit + +ASSISTANT: +The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. If you have any further questions, feel free to ask! + +USER: Now kill the server, make it display the numbers in a table format. + +ASSISTANT: +Sure! Let me stop the server first: + + +kill 124 + + + +USER: EXECUTION RESULT of [execute_bash]: +[1]+ Terminated python3 app.py > server.log 2>&1 + +ASSISTANT: +Now let me display the numbers in a table format: + +str_replace +/workspace/app.py +return str(numbers) +return '' + ''.join([f'' for i in numbers]) + '
{i}
' + + +USER: EXECUTION RESULT of [str_replace_editor]: +The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py: + 3 + 4 @app.route('/') + 5 def index(): + 6 numbers = list(range(1, 11)) + 7 return '' + ''.join([f'' for i in numbers]) + '
{i}
' + 8 + 9 if __name__ == '__main__': + 10 app.run(port=5000) +Review the changes and make sure they are as expected. Edit the file again if necessary. + +ASSISTANT: +Running the updated file: + + + +python3 app.py > server.log 2>&1 & + + + +USER: EXECUTION RESULT of [execute_bash]: +[1] 126 + +ASSISTANT: +The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests! + + + +--------------------- END OF EXAMPLE --------------------- + +Do NOT assume the environment is the same as in the example above. + +--------------------- NEW TASK DESCRIPTION --------------------- +""".lstrip() + +IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """ +--------------------- END OF NEW TASK DESCRIPTION --------------------- + +PLEASE follow the format strictly! PLEASE EMIT ONE AND ONLY ONE FUNCTION CALL PER MESSAGE. +""".lstrip() + +# Regex patterns for function call parsing +FN_REGEX_PATTERN = r']+)>\n(.*?)' +FN_PARAM_REGEX_PATTERN = r']+)>(.*?)' + +# Add new regex pattern for tool execution results +TOOL_RESULT_REGEX_PATTERN = r'EXECUTION RESULT of \[(.*?)\]:\n(.*)' + + +def convert_tool_call_to_string(tool_call: dict) -> str: + """Convert tool call to content in string format.""" + if 'function' not in tool_call: + raise FunctionCallConversionError("Tool call must contain 'function' key.") + if 'id' not in tool_call: + raise FunctionCallConversionError("Tool call must contain 'id' key.") + if 'type' not in tool_call: + raise FunctionCallConversionError("Tool call must contain 'type' key.") + if tool_call['type'] != 'function': + raise FunctionCallConversionError("Tool call type must be 'function'.") + + ret = f"\n" + try: + args = json.loads(tool_call['function']['arguments']) + except json.JSONDecodeError as e: + raise FunctionCallConversionError( + f"Failed to parse arguments as JSON. Arguments: {tool_call['function']['arguments']}" + ) from e + for param_name, param_value in args.items(): + is_multiline = isinstance(param_value, str) and '\n' in param_value + ret += f'' + if is_multiline: + ret += '\n' + ret += f'{param_value}' + if is_multiline: + ret += '\n' + ret += '\n' + ret += '' + return ret + + +def convert_tools_to_description(tools: list[dict]) -> str: + ret = '' + for i, tool in enumerate(tools): + assert tool['type'] == 'function' + fn = tool['function'] + if i > 0: + ret += '\n' + ret += f"---- BEGIN FUNCTION #{i+1}: {fn['name']} ----\n" + ret += f"Description: {fn['description']}\n" + + if 'parameters' in fn: + ret += 'Parameters:\n' + properties = fn['parameters'].get('properties', {}) + required_params = set(fn['parameters'].get('required', [])) + + for j, (param_name, param_info) in enumerate(properties.items()): + # Indicate required/optional in parentheses with type + is_required = param_name in required_params + param_status = 'required' if is_required else 'optional' + param_type = param_info.get('type', 'string') + + # Get parameter description + desc = param_info.get('description', 'No description provided') + + # Handle enum values if present + if 'enum' in param_info: + enum_values = ', '.join(f'`{v}`' for v in param_info['enum']) + desc += f'\nAllowed values: [{enum_values}]' + + ret += ( + f' ({j+1}) {param_name} ({param_type}, {param_status}): {desc}\n' + ) + else: + ret += 'No parameters are required for this function.\n' + + ret += f'---- END FUNCTION #{i+1} ----\n' + return ret + + +def convert_fncall_messages_to_non_fncall_messages( + messages: list[dict], + tools: list[ChatCompletionToolParam], + add_in_context_learning_example: bool = True, +) -> list[dict]: + """Convert function calling messages to non-function calling messages.""" + messages = copy.deepcopy(messages) + + formatted_tools = convert_tools_to_description(tools) + system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format( + description=formatted_tools + ) + + converted_messages = [] + first_user_message_encountered = False + for message in messages: + role = message['role'] + content = message['content'] + + # 1. SYSTEM MESSAGES + # append system prompt suffix to content + if role == 'system': + if isinstance(content, str): + content += system_prompt_suffix + elif isinstance(content, list): + if content and content[-1]['type'] == 'text': + content[-1]['text'] += system_prompt_suffix + else: + content.append({'type': 'text', 'text': system_prompt_suffix}) + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + converted_messages.append({'role': 'system', 'content': content}) + + # 2. USER MESSAGES (no change) + elif role == 'user': + # Add in-context learning example for the first user message + if not first_user_message_encountered and add_in_context_learning_example: + first_user_message_encountered = True + # Check tools + if not ( + tools + and len(tools) > 0 + and any( + ( + tool['type'] == 'function' + and tool['function']['name'] == 'execute_bash' + and 'command' + in tool['function']['parameters']['properties'] + ) + for tool in tools + ) + and any( + ( + tool['type'] == 'function' + and tool['function']['name'] == 'str_replace_editor' + and 'path' in tool['function']['parameters']['properties'] + and 'file_text' + in tool['function']['parameters']['properties'] + and 'old_str' + in tool['function']['parameters']['properties'] + and 'new_str' + in tool['function']['parameters']['properties'] + ) + for tool in tools + ) + ): + raise FunctionCallConversionError( + 'The currently provided tool set are NOT compatible with the in-context learning example for FnCall to Non-FnCall conversion. ' + 'Please update your tool set OR the in-context learning example in openhands/llm/fn_call_converter.py' + ) + + # add in-context learning example + if isinstance(content, str): + content = ( + IN_CONTEXT_LEARNING_EXAMPLE_PREFIX + + content + + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX + ) + elif isinstance(content, list): + if content and content[0]['type'] == 'text': + content[0]['text'] = ( + IN_CONTEXT_LEARNING_EXAMPLE_PREFIX + + content[0]['text'] + + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX + ) + else: + content = ( + [ + { + 'type': 'text', + 'text': IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, + } + ] + + content + + [ + { + 'type': 'text', + 'text': IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, + } + ] + ) + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + converted_messages.append( + { + 'role': 'user', + 'content': content, + } + ) + + # 3. ASSISTANT MESSAGES + # - 3.1 no change if no function call + # - 3.2 change if function call + elif role == 'assistant': + if 'tool_calls' in message and message['tool_calls'] is not None: + if len(message['tool_calls']) != 1: + raise FunctionCallConversionError( + f'Expected exactly one tool call in the message. More than one tool call is not supported. But got {len(message["tool_calls"])} tool calls. Content: {content}' + ) + try: + tool_content = convert_tool_call_to_string(message['tool_calls'][0]) + except FunctionCallConversionError as e: + raise FunctionCallConversionError( + f'Failed to convert tool call to string.\nCurrent tool call: {message["tool_calls"][0]}.\nRaw messages: {json.dumps(messages, indent=2)}' + ) from e + if isinstance(content, str): + content += '\n\n' + tool_content + content = content.lstrip() + elif isinstance(content, list): + if content and content[-1]['type'] == 'text': + content[-1]['text'] += '\n\n' + tool_content + content[-1]['text'] = content[-1]['text'].lstrip() + else: + content.append({'type': 'text', 'text': tool_content}) + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + converted_messages.append({'role': 'assistant', 'content': content}) + + # 4. TOOL MESSAGES (tool outputs) + elif role == 'tool': + # Convert tool result as user message + tool_name = message.get('name', 'function') + prefix = f'EXECUTION RESULT of [{tool_name}]:\n' + # and omit "tool_call_id" AND "name" + if isinstance(content, str): + content = prefix + content + elif isinstance(content, list): + if content and content[-1]['type'] == 'text': + content[-1]['text'] = prefix + content[-1]['text'] + else: + content = [{'type': 'text', 'text': prefix}] + content + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + converted_messages.append({'role': 'user', 'content': content}) + else: + raise FunctionCallConversionError( + f'Unexpected role {role}. Expected system, user, assistant or tool.' + ) + return converted_messages + + +def _extract_and_validate_params( + matching_tool: dict, param_matches: Iterable[re.Match], fn_name: str +) -> dict: + params = {} + # Parse and validate parameters + required_params = set() + if 'parameters' in matching_tool and 'required' in matching_tool['parameters']: + required_params = set(matching_tool['parameters'].get('required', [])) + + allowed_params = set() + if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']: + allowed_params = set(matching_tool['parameters']['properties'].keys()) + + param_name_to_type = {} + if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']: + param_name_to_type = { + name: val.get('type', 'string') + for name, val in matching_tool['parameters']['properties'].items() + } + + # Collect parameters + found_params = set() + for param_match in param_matches: + param_name = param_match.group(1) + param_value = param_match.group(2).strip() + + # Validate parameter is allowed + if allowed_params and param_name not in allowed_params: + raise FunctionCallValidationError( + f"Parameter '{param_name}' is not allowed for function '{fn_name}'. " + f'Allowed parameters: {allowed_params}' + ) + + # Validate and convert parameter type + # supported: string, integer, array + if param_name in param_name_to_type: + if param_name_to_type[param_name] == 'integer': + try: + param_value = int(param_value) + except ValueError: + raise FunctionCallValidationError( + f"Parameter '{param_name}' is expected to be an integer." + ) + elif param_name_to_type[param_name] == 'array': + try: + param_value = json.loads(param_value) + except json.JSONDecodeError: + raise FunctionCallValidationError( + f"Parameter '{param_name}' is expected to be an array." + ) + else: + # string + pass + + # Enum check + if 'enum' in matching_tool['parameters']['properties'][param_name]: + if ( + param_value + not in matching_tool['parameters']['properties'][param_name]['enum'] + ): + raise FunctionCallValidationError( + f"Parameter '{param_name}' is expected to be one of {matching_tool['parameters']['properties'][param_name]['enum']}." + ) + + params[param_name] = param_value + found_params.add(param_name) + + # Check all required parameters are present + missing_params = required_params - found_params + if missing_params: + raise FunctionCallValidationError( + f"Missing required parameters for function '{fn_name}': {missing_params}" + ) + return params + + +def _fix_stopword(content: str) -> str: + """Fix the issue when some LLM would NOT return the stopword.""" + if '' + else: + content = content + '\n' + return content + + +def convert_non_fncall_messages_to_fncall_messages( + messages: list[dict], + tools: list[ChatCompletionToolParam], +) -> list[dict]: + """Convert non-function calling messages back to function calling messages.""" + messages = copy.deepcopy(messages) + formatted_tools = convert_tools_to_description(tools) + system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format( + description=formatted_tools + ) + + converted_messages = [] + tool_call_counter = 1 # Counter for tool calls + + first_user_message_encountered = False + for message in messages: + role, content = message['role'], message['content'] + content = content or '' # handle cases where content is None + # For system messages, remove the added suffix + if role == 'system': + if isinstance(content, str): + # Remove the suffix if present + content = content.split(system_prompt_suffix)[0] + elif isinstance(content, list): + if content and content[-1]['type'] == 'text': + # Remove the suffix from the last text item + content[-1]['text'] = content[-1]['text'].split( + system_prompt_suffix + )[0] + converted_messages.append({'role': 'system', 'content': content}) + # Skip user messages (no conversion needed) + elif role == 'user': + # Check & replace in-context learning example + if not first_user_message_encountered: + first_user_message_encountered = True + if isinstance(content, str): + content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, '') + content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '') + elif isinstance(content, list): + for item in content: + if item['type'] == 'text': + item['text'] = item['text'].replace( + IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, '' + ) + item['text'] = item['text'].replace( + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '' + ) + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + + # Check for tool execution result pattern + if isinstance(content, str): + tool_result_match = re.search( + TOOL_RESULT_REGEX_PATTERN, content, re.DOTALL + ) + elif isinstance(content, list): + tool_result_match = next( + ( + _match + for item in content + if item.get('type') == 'text' + and ( + _match := re.search( + TOOL_RESULT_REGEX_PATTERN, item['text'], re.DOTALL + ) + ) + ), + None, + ) + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + + if tool_result_match: + if not ( + isinstance(content, str) + or ( + isinstance(content, list) + and len(content) == 1 + and content[0].get('type') == 'text' + ) + ): + raise FunctionCallConversionError( + f'Expected str or list with one text item when tool result is present in the message. Content: {content}' + ) + tool_name = tool_result_match.group(1) + tool_result = tool_result_match.group(2).strip() + + # Convert to tool message format + converted_messages.append( + { + 'role': 'tool', + 'name': tool_name, + 'content': [{'type': 'text', 'text': tool_result}] + if isinstance(content, list) + else tool_result, + 'tool_call_id': f'toolu_{tool_call_counter-1:02d}', # Use last generated ID + } + ) + else: + converted_messages.append({'role': 'user', 'content': content}) + + # Handle assistant messages + elif role == 'assistant': + if isinstance(content, str): + content = _fix_stopword(content) + fn_match = re.search(FN_REGEX_PATTERN, content, re.DOTALL) + elif isinstance(content, list): + if content and content[-1]['type'] == 'text': + content[-1]['text'] = _fix_stopword(content[-1]['text']) + fn_match = re.search( + FN_REGEX_PATTERN, content[-1]['text'], re.DOTALL + ) + else: + fn_match = None + fn_match_exists = any( + item.get('type') == 'text' + and re.search(FN_REGEX_PATTERN, item['text'], re.DOTALL) + for item in content + ) + if fn_match_exists and not fn_match: + raise FunctionCallConversionError( + f'Expecting function call in the LAST index of content list. But got content={content}' + ) + else: + raise FunctionCallConversionError( + f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' + ) + + if fn_match: + fn_name = fn_match.group(1) + fn_body = fn_match.group(2) + matching_tool = next( + ( + tool['function'] + for tool in tools + if tool['type'] == 'function' + and tool['function']['name'] == fn_name + ), + None, + ) + # Validate function exists in tools + if not matching_tool: + raise FunctionCallValidationError( + f"Function '{fn_name}' not found in available tools: {[tool['function']['name'] for tool in tools if tool['type'] == 'function']}" + ) + + # Parse parameters + param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL) + params = _extract_and_validate_params( + matching_tool, param_matches, fn_name + ) + + # Create tool call with unique ID + tool_call_id = f'toolu_{tool_call_counter:02d}' + tool_call = { + 'index': 1, # always 1 because we only support **one tool call per message** + 'id': tool_call_id, + 'type': 'function', + 'function': {'name': fn_name, 'arguments': json.dumps(params)}, + } + tool_call_counter += 1 # Increment counter + + # Remove the function call part from content + if isinstance(content, list): + assert content and content[-1]['type'] == 'text' + content[-1]['text'] = ( + content[-1]['text'].split(' list[dict]: + """Break one message with multiple tool calls into multiple messages.""" + converted_messages = [] + + pending_tool_calls: dict[str, dict] = {} + for message in messages: + role, content = message['role'], message['content'] + if role == 'assistant': + if message.get('tool_calls') and len(message['tool_calls']) > 1: + # handle multiple tool calls by breaking them into multiple messages + for i, tool_call in enumerate(message['tool_calls']): + pending_tool_calls[tool_call['id']] = { + 'role': 'assistant', + 'content': content if i == 0 else '', + 'tool_calls': [tool_call], + } + else: + converted_messages.append(message) + elif role == 'tool': + if message['tool_call_id'] in pending_tool_calls: + # remove the tool call from the pending list + _tool_call_message = pending_tool_calls.pop(message['tool_call_id']) + converted_messages.append(_tool_call_message) + # add the tool result + converted_messages.append(message) + else: + assert ( + len(pending_tool_calls) == 0 + ), f'Found pending tool calls but not found in pending list: {pending_tool_calls=}' + converted_messages.append(message) + else: + assert ( + len(pending_tool_calls) == 0 + ), f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}' + converted_messages.append(message) + + if not ignore_final_tool_result and len(pending_tool_calls) > 0: + raise FunctionCallConversionError( + f'Found pending tool calls but no tool result: {pending_tool_calls=}' + ) + return converted_messages + + + +def convert_fn_messages_to_non_fn_messages(messages: list[dict]) -> list[dict]: + """Convert function calling messages back to non-function calling messages.""" + new_messages = [] + for idx, message in enumerate(messages): + if message["role"] == "tool": + assert messages[idx-1]["role"] == "assistant" and new_messages[-1]["role"] == "assistant" + new_messages[-1]["content"] = messages[idx-1]["content"] + f""" +I have executed the tool {message["name"]} and the result is {message["content"]}. +""" + elif message["role"] == "assistant": + msg_content = message["content"] + f""" +I want to use the tool named {message["tool_calls"][0]["function"]["name"]}, with the following arguments: {message["tool_calls"][0]["function"]["arguments"]}. +""" + new_messages.append({"role": message["role"], "content": msg_content}.copy()) + else: + new_messages.append(message.copy()) + return new_messages + + +def interleave_user_into_messages(messages: list[dict]) -> list[dict]: + new_messages = [] + for idx, message in enumerate(messages): + if message["role"] == "assistant" and messages[idx-1]["role"] == "assistant": + # 在两个连续的 assistant 消息之间插入一个空的 user 消息 + new_messages.append({ + "role": "user", + "content": "Please think twice and take the next action according to your previous actions and observations." # 空内容的用户消息 + }.copy()) + new_messages.append(message.copy()) + new_messages.append({"role": "user", "content": "Please think twice and take the next action according to your previous actions and observations."}) + return new_messages \ No newline at end of file diff --git a/metachain/io_utils.py b/metachain/io_utils.py new file mode 100644 index 0000000..4fe4faa --- /dev/null +++ b/metachain/io_utils.py @@ -0,0 +1,51 @@ +import yaml +import hashlib +import zipfile +import os +import json +from rich.console import Console +def read_file(file_path): + with open(file_path, 'r', encoding='utf-8') as file: + content = file.read() + return content + +def read_yaml_file(file_path): + with open(file_path, 'r', encoding='utf-8') as file: + content = yaml.safe_load(file) + return content + +def get_file_md5(file_path): + md5_hash = hashlib.md5() + with open(file_path, "rb") as f: + # read file block + for byte_block in iter(lambda: f.read(4096), b""): + md5_hash.update(byte_block) + return md5_hash.hexdigest() + + +def compress_folder(source_folder, destination_folder, archive_name): + os.makedirs(destination_folder, exist_ok=True) + + archive_path = os.path.join(destination_folder, archive_name) + + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + + for root, _, files in os.walk(source_folder): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, source_folder) + zipf.write(file_path, arcname) + + print(f"Folder '{source_folder}' has been compressed to '{archive_path}'") + +def get_md5_hash_bytext(text): + return hashlib.md5(text.encode('utf-8')).hexdigest() + +def read_json_file(file_path): + with open(file_path, 'r', encoding='utf-8') as file: + content = json.load(file) + return content + +def print_stream(text): + console = Console() + console.print(f"[grey42]{text}[/grey42]") \ No newline at end of file diff --git a/metachain/logger.py b/metachain/logger.py new file mode 100644 index 0000000..74d5893 --- /dev/null +++ b/metachain/logger.py @@ -0,0 +1,159 @@ +from datetime import datetime +from rich.console import Console +from rich.markup import escape +import json +from typing import List +from constant import DEBUG, DEFAULT_LOG, LOG_PATH +from pathlib import Path +BAR_LENGTH = 60 +class MetaChainLogger: + def __init__(self, log_path: str): + self.log_path = log_path + self.console = Console() + self.debug = DEBUG + + def _write_log(self, message: str): + with open(self.log_path, 'a') as f: + f.write(message + '\n') + def _warp_args(self, args_dict: str): + args_dict = json.loads(args_dict) + args_str = '' + for k, v in args_dict.items(): + args_str += f"{repr(k)}={repr(v)}, " + return args_str[:-2] + def _wrap_title(self, title: str, color: str = None): + single_len = (BAR_LENGTH - len(title)) // 2 + color_bos = f"[{color}]" if color else "" + color_eos = f"[/{color}]" if color else "" + return f"{color_bos}{'*'*single_len} {title} {'*'*single_len}{color_eos}" + def info(self, *args: str, **kwargs: dict): + # console = Console() + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + message = "\n".join(map(str, args)) + color = kwargs.get("color", "white") + title = kwargs.get("title", "INFO") + log_str = f"[{timestamp}]\n{message}" + if self.debug: + # print_in_box(log_str, color=color, title=title) + self.console.print(self._wrap_title(title, f"bold {color}")) + self.console.print(escape(log_str), highlight=True, emoji=True) + log_str = self._wrap_title(title) + "\n" + log_str + if self.log_path: self._write_log(log_str) + def lprint(self, *args: str, **kwargs: dict): + if not self.debug: return + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + message = "\n".join(map(str, args)) + color = kwargs.get("color", "white") + title = kwargs.get("title", "") + log_str = f"[{timestamp}]\n{message}" + # print_in_box(log_str, color=color, title=title) + self.console.print(self._wrap_title(title, f"bold {color}")) + self.console.print(escape(log_str), highlight=True, emoji=True) + + def _wrap_timestamp(self, timestamp: str, color: bool = True): + color_bos = "[grey58]" if color else "" + color_eos = "[/grey58]" if color else "" + return f"{color_bos}[{timestamp}]{color_eos}" + def _print_tool_execution(self, message, timestamp: str): + self.console.print(self._wrap_title("Tool Execution", "bold pink3")) + self.console.print(self._wrap_timestamp(timestamp, color=True)) + self.console.print("[bold blue]Tool Execution:[/bold blue]", end=" ") + self.console.print(f"[bold purple]{message['name']}[/bold purple]\n[bold blue]Result:[/bold blue]") + self.console.print(f"---\n{escape(message['content'])}\n---") + def _save_tool_execution(self, message, timestamp: str): + self._write_log(self._wrap_title("Tool Execution")) + self._write_log(f"{self._wrap_timestamp(timestamp, color=False)}\ntool execution: {message['name']}\nResult:\n---\n{message['content']}\n---") + def _print_assistant_message(self, message, timestamp: str): + self.console.print(self._wrap_title("Assistant Message", "bold light_salmon3")) + self.console.print(f"{self._wrap_timestamp(timestamp, color=True)}\n[bold blue]{message['sender']}[/bold blue]:", end=" ") + if message["content"]: self.console.print(escape(message["content"]), highlight=True, emoji=True) + else: self.console.print(None, highlight=True, emoji=True) + def _save_assistant_message(self, message, timestamp: str): + self._write_log(self._wrap_title("Assistant Message")) + content = message["content"] if message["content"] else None + self._write_log(f"{self._wrap_timestamp(timestamp, color=False)}\n{message['sender']}: {content}") + def _print_tool_call(self, tool_calls: List, timestamp: str): + if len(tool_calls) >= 1: self.console.print(self._wrap_title("Tool Calls", "bold light_pink1")) + + for tool_call in tool_calls: + f = tool_call["function"] + name, args = f["name"], f["arguments"] + arg_str = self._warp_args(args) + self.console.print(f"{self._wrap_timestamp(timestamp, color=True)}\n[bold purple]{name}[/bold purple]({escape(arg_str)})") + def _save_tool_call(self, tool_calls: List, timestamp: str): + if len(tool_calls) >= 1: self._write_log(self._wrap_title("Tool Calls")) + + for tool_call in tool_calls: + f = tool_call["function"] + name, args = f["name"], f["arguments"] + arg_str = self._warp_args(args) + self._write_log(f"{self._wrap_timestamp(timestamp, color=False)}\n{name}({arg_str})") + + def pretty_print_messages(self, message, **kwargs) -> None: + # for message in messages: + if message["role"] != "assistant" and message["role"] != "tool": + return + # console = Console() + + # handle tool call + if message["role"] == "tool": + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if self.log_path: self._save_tool_execution(message, timestamp) + if self.debug: self._print_tool_execution(message, timestamp) + return + + # handle assistant message + # print agent name in blue + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if self.log_path: self._save_assistant_message(message, timestamp) + if self.debug: self._print_assistant_message(message, timestamp) + + # print tool calls in purple, if any + tool_calls = message.get("tool_calls") or [] + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if self.log_path: self._save_tool_call(tool_calls, timestamp) + if self.debug: self._print_tool_call(tool_calls, timestamp) +class LoggerManager: + _instance = None + _logger: MetaChainLogger = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = LoggerManager() + return cls._instance + + @classmethod + def get_logger(cls): + return cls.get_instance()._logger + + @classmethod + def set_logger(cls, new_logger): + cls.get_instance()._logger = new_logger +if DEFAULT_LOG: + if LOG_PATH is None: + log_dir = Path(f'logs/res_{datetime.now().strftime("%Y%m%d_%H%M%S")}') + log_dir.mkdir(parents=True, exist_ok=True) # recursively create all necessary parent directories + log_path = str(log_dir / "agent.log") + # logger = MetaChainLogger(log_path=log_path) + LoggerManager.set_logger(MetaChainLogger(log_path=log_path)) + else: + # logger = MetaChainLogger(log_path=LOG_PATH) + LoggerManager.set_logger(MetaChainLogger(log_path=LOG_PATH)) + # logger.info("Log file is saved to", logger.log_path, "...", title="Log Path", color="light_cyan3") + LoggerManager.get_logger().info("Log file is saved to", + LoggerManager.get_logger().log_path, "...", + title="Log Path", color="light_cyan3") +else: + # logger = None + LoggerManager.set_logger(None) +logger = LoggerManager.get_logger() + +def set_logger(new_logger): + LoggerManager.set_logger(new_logger) +# if __name__ == "__main__": +# logger = MetaChainLogger(log_path="test.log") +# logger.pretty_print_messages({"role": "assistant", "content": "Hello, world!", "tool_calls": [{"function": {"name": "test", "arguments": {"url": "https://www.google.com", "query": "test"}}}], "sender": "test_agent"}) + +# logger.pretty_print_messages({"role": "tool", "name": "test", "content": "import requests\n\nurl = 'https://www.google.com'\nquery = 'test'\n\nresponse = requests.get(url)\nprint(response.text)", "sender": "test_agent"}) +# logger.info("test content", color="red", title="test") diff --git a/metachain/main.py b/metachain/main.py new file mode 100644 index 0000000..c45f371 --- /dev/null +++ b/metachain/main.py @@ -0,0 +1,109 @@ +from metachain import MetaChain, Agent, Response +from typing import List +from metachain.logger import MetaChainLogger +from metachain.environment.utils import setup_metachain +from metachain.environment.docker_env import DockerConfig, DockerEnv + +def case_resolved(result: str): + """ + Use this tool to indicate that the case is resolved. You can use this tool only after you truly resolve the case with exsiting tools and created new tools.Please encapsulate your final answer (answer ONLY) within and . + + Args: + result: The final result of the case resolution following the instructions. + + Example: case_resolved(`The answer to the question is 42 `) + """ + return f"Case resolved. No further actions are needed. The result of the case resolution is: {result}" + +def case_not_resolved(failure_reason: str, take_away_message: str): + """ + Use this tool to indicate that the case is not resolved when all agents have tried their best. + [IMPORTANT] Please do not use this function unless all of you have tried your best. + You should give the failure reason to tell the user why the case is not resolved, and give the take away message to tell which information you gain from creating new tools. + + Args: + failure_reason: The reason why the case is not resolved. + take_away_message: The message to take away from the case. + """ + return f"Case not resolved. The reason is: {failure_reason}. But though creating new tools, I gain some information: {take_away_message}" + +async def run_in_client( + agent: Agent, + messages: List, + context_variables: dict = {}, + logger: MetaChainLogger = None, + meta_agent: Agent = None, + docker_config: DockerConfig = None, + code_env: DockerConfig = None, +): + """ + """ + client = MetaChain(log_path=logger) + + MAX_RETRY = 3 + for i in range(MAX_RETRY): + try: + response: Response = await client.run_async(agent, messages, context_variables, debug=True) + except Exception as e: + logger.info(f'Exception in main loop: {e}', title='ERROR', color='red') + raise e + if 'Case resolved' in response.messages[-1]['content']: + break + elif 'Case not resolved' in response.messages[-1]['content']: + messages.extend(response.messages) + if meta_agent and (i >= 2): + setup_metachain(docker_config.workplace_name, code_env) + messages.append({ + 'role': 'user', + 'content': """\ +It seems that the case is not resolved with the existing agent system. +Help me to solve this problem by running tools in the MetaChain. +IMPORTANT: You should fully take advantage of existing tools, and if existing tools are not enough, you should develop new tools. +Use `visual_question_answering` tool for ALL visual tasks (images, videos, visual analysis, including object detection, etc.) +IMPORTANT: You can not stop with `case_not_resolved` after you try your best to creating new tools. +IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP. +Please encapsulate your final answer (answer ONLY) within and . +""" + }) + meta_agent.functions.append(case_not_resolved) + meta_agent.functions.append(case_resolved) + response: Response = await client.run_async(meta_agent, messages, context_variables, debug=True) + if 'Case resolved' in response.messages[-1]['content']: + break + else: + messages.extend(response.messages) + + messages.append({ + 'role': 'user', + 'content': 'Please try to resolve the case again. It\'s important for me to resolve the case. Trying again in another way may be helpful.' + }) + + return response + +def run_in_client_non_async( + agent: Agent, + messages: List, + context_variables: dict = {}, + logger: MetaChainLogger = None, +): + """ + """ + client = MetaChain(log_path=logger) + + MAX_RETRY = 3 + for i in range(MAX_RETRY): + try: + response: Response = client.run(agent, messages, context_variables, debug=True) + except Exception as e: + logger.info(f'Exception in main loop: {e}', title='ERROR', color='red') + raise e + if 'Case resolved' in response.messages[-1]['content']: + break + elif 'Case not resolved' in response.messages[-1]['content']: + messages.extend(response.messages) + messages.append({ + 'role': 'user', + 'content': 'Please try to resolve the case again. It\'s important for me to resolve the case. Trying again in another way may be helpful.' + }) + + return response \ No newline at end of file diff --git a/metachain/memory/__init__.py b/metachain/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metachain/memory/code_memory.py b/metachain/memory/code_memory.py new file mode 100644 index 0000000..ca7e279 --- /dev/null +++ b/metachain/memory/code_memory.py @@ -0,0 +1,174 @@ +import os +from typing import List, Dict +from metachain.memory.rag_memory import Memory, Reranker +from litellm import completion +import re + +class CodeMemory(Memory): + def __init__(self, project_path: str, db_name: str = '.sa', platform: str = 'OpenAI', api_key: str = None, embedding_model: str = "text-embedding-ada-002"): + super().__init__(project_path, db_name, platform, api_key, embedding_model) + self.collection_name = 'code_memory' + + def add_code_files(self, directory: str, exclude_prefix: List[str] = ["workplace_"]): + """ + Add all code files in the specified directory to the memory. + + Args: + directory (str): The directory path containing the code files to add. + """ + code_files = [] + for root, _, files in os.walk(directory): + root_name = str(root) + if any(prefix in root_name for prefix in exclude_prefix): + continue + for file in files: + + if file.endswith(('.py', '.js', '.java', '.cpp', '.h', '.c', '.html', '.css')): # add more file types if needed + file_path = os.path.join(root, file) + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + code_files.append({ + "query": f"File: {file_path}\n\nContent:\n{content}", + "response": f"This is the content of file {file_path}" + }) + self.add_query(code_files, self.collection_name) + + def query_code(self, query_text: str, n_results: int = 5) -> List[Dict]: + """ + Query the code memory. + + Args: + query_text (str): The query text + n_results (int): The number of results to return + + Returns: + List[Dict]: The query results list + """ + results = self.query([query_text], self.collection_name, n_results) + return [ + { + "file": doc.split('\n')[0].replace("File: ", ""), + "content": '\n'.join(doc.split('\n')[3:]), + "metadata": metadata + } + for doc, metadata in zip(results['documents'][0], results['metadatas'][0]) + ] + +class CodeReranker(Reranker): + def __init__(self, model: str) -> None: + super().__init__(model) + def wrap_query_results(self, query_results: List[Dict]) -> str: + wrapped_query_results = "" + for result in query_results: + wrapped_query_results += f"File: {result['file']}\n" + wrapped_query_results += f"Content: {result['content'][:300]}...\n" + wrapped_query_results += "---" + return wrapped_query_results + def wrap_reranked_results(self, reranked_paths: List[str]) -> str: + wrapped_reranked_results = "[Referenced code files]:" + for path in reranked_paths: + wrapped_reranked_results += f"Code path: {path}\n" + try: + with open(path, 'r', encoding='utf-8') as file: + content = file.read() + wrapped_reranked_results += f"Code content:\n{content}\n" + except Exception as e: + wrapped_reranked_results += f"Error reading file: {str(e)}\n" + wrapped_reranked_results += "---\n" + return wrapped_reranked_results + def parse_results(self, reranked_results: str) -> List[str]: + lines = reranked_results.strip().split('\n') + + # get the last 5 lines + last_lines = lines[-5:] + + # remove the number and dot at the beginning of each line + cleaned_lines = [re.sub(r'^\d+\.\s*', '', line.strip()) for line in last_lines] + unique_lines = list(dict.fromkeys(cleaned_lines)) + + return unique_lines + def rerank(self, query_text: str, query_results: List[Dict]) -> List[Dict]: + system_prompt = \ + """ + You are a helpful assistant that reranks the given code files (containing the path of files and Overview of the content of files) based on the query. + You should rerank the code files based on the query, and the most relevant code files should be ranked on the top. + You should select the top 5 code files to answer the query, by giving the file path of the code files. + + Example: + [Query]: "The definition of 'BaseAgent'" + [Code files]: + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/__init__.py + Content: from .ABCAgent import ABCAgent + from .BaseAgent import BaseAgent + from .ManagerAgent import ManagerAge... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/__init__.py + Content: from .ABCAgent import ABCAgent + from .BaseAgent import BaseAgent + from .ManagerAgent import ManagerAge... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/__init__.py + Content: from .ABCAgent import ABCAgent + from .BaseAgent import BaseAgent + from .ManagerAgent import ManagerAge... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/__init__.py + Content: from .ABCAgent import ABCAgent + from .BaseAgent import BaseAgent + from .ManagerAgent import ManagerAge... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agent_prompts/__init__.py + Content: from .BasePrompt import BasePromptGen, ManagerPromptGen, PromptGen + ... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agent_prompts/__init__.py + Content: from .BasePrompt import BasePromptGen, ManagerPromptGen, PromptGen + ... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agent_prompts/__init__.py + Content: from .BasePrompt import BasePromptGen, ManagerPromptGen, PromptGen + ... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agent_prompts/__init__.py + Content: from .BasePrompt import BasePromptGen, ManagerPromptGen, PromptGen + ... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/BaseAgent.py + Content: from typing import List + + from sa.actions import BaseAction, FinishAct, ThinkAct, PlanAct + from sa.age... + --- + File: /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/BaseAgent.py + Content: from typing import List + + from sa.actions import BaseAction, FinishAct, ThinkAct, PlanAct + from sa.age... + --- + [Reranked 5 code files]: + 1. /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/BaseAgent.py + 2. /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/__init__.py + 3. /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/ABCAgent.py + 4. /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/ManagerAgent.py + 5. /Users/tangjiabin/Documents/reasoning/SelfAgent/sa/agents/AgentLogger.py + """ + wrapped_query_results = self.wrap_query_results(query_results) + user_prompt = \ + """ + [Query]: \n{query_text} + [Code files]: \n{query_results} + [Reranked 5 code files]: + """.format(query_text=query_text, query_results=wrapped_query_results) + chat_history = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + create_params = { + "model": self.model, + "messages": chat_history, + "stream": False, + } + response = completion(**create_params) + reranked_results = self.parse_results(response.choices[0].message.content) + reranked_results = self.wrap_reranked_results(reranked_results) + return reranked_results diff --git a/metachain/memory/code_tree/code_parser.py b/metachain/memory/code_tree/code_parser.py new file mode 100644 index 0000000..3334763 --- /dev/null +++ b/metachain/memory/code_tree/code_parser.py @@ -0,0 +1,123 @@ +import dataclasses +from tree_sitter import Language +import tree_sitter +import glob +import uuid +from loguru import logger + +@dataclasses.dataclass +class Snippet: + """Dataclass for storing Embedded Snippets""" + + id: str + embedding: list[float] | None + snippet: str + filename: str + language: str + + +class CodeParser: + """Code Parser Class.""" + + def __init__(self, language: str, node_types: list[str], path_to_object_file: str): + self.node_types = node_types + self.language = language + try: + self.parser = tree_sitter.Parser() + self.parser.set_language( + tree_sitter.Language(f"{path_to_object_file}/my-languages.so", language) + ) + except Exception as e: + logger.exception("failed to build %s parser: ", e) + + def parse_file(self, content: str, filename: str): + """ + Parse code snippets from single code file. + + Args: + content: The content of the file. + filename: The name of the code file. + + Returns: + List of Parsed Snippets + """ + try: + tree = self.parser.parse(content) + except Exception as e: + logger.error(f"Failed to parse snippet: {filename} \n Error: {e}") + return + + cursor = tree.walk() + parsed_snippets = [] + + # Walking nodes from abstract syntax tree + while cursor.goto_first_child(): + if cursor.node.type in self.node_types: + parsed_snippets.append( + Snippet( + id=str(uuid.uuid4()), + snippet=cursor.node.text, + filename=filename, + language=self.language, + embedding=None, + ) + ) + + while cursor.goto_next_sibling(): + if cursor.node.type in self.node_types: + parsed_snippets.append( + Snippet( + id=str(uuid.uuid4()), + snippet=cursor.node.text, + filename=filename, + language=self.language, + embedding=None, + ) + ) + return parsed_snippets + + def parse_directory(self, code_directory_path): + """ + Parse code snippets from all files in directory. + + Args: + code_directory_path: Directory path containing code files. + + Returns: + List of Parsed Snippets + """ + parsed_contents = [] + for filename in glob.glob(f"{code_directory_path}/**/*.py", recursive=True): + # print(filename) + with open(filename, "rb") as codefile: + code_content = codefile.read() + + parsed_content = self.parse_file(code_content, filename) + parsed_contents.extend(parsed_content) + + return parsed_contents + +def to_dataframe_row(embedded_snippets: list[Snippet]): + """ + Helper function to convert Embedded Snippet object to a dataframe row + in dictionary format. + + Args: + embedded_snippets: List of Snippets to be converted + + Returns: + List of Dictionaries + """ + outputs = [] + for embedded_snippet in embedded_snippets: + output = { + "ids": embedded_snippet.id, + "embeddings": embedded_snippet.embedding, + "snippets": embedded_snippet.snippet, + "metadatas": { + "filenames": embedded_snippet.filename, + "languages": embedded_snippet.language, + }, + } + outputs.append(output) + return outputs \ No newline at end of file diff --git a/metachain/memory/codetree_memory.py b/metachain/memory/codetree_memory.py new file mode 100644 index 0000000..f2b98c9 --- /dev/null +++ b/metachain/memory/codetree_memory.py @@ -0,0 +1,118 @@ +import os +from typing import List, Dict +from metachain.memory.rag_memory import Memory, Reranker +import openai +import re +from metachain.memory.code_tree.code_parser import CodeParser, to_dataframe_row +from tree_sitter import Language +from loguru import logger +from openai import OpenAI +import pandas as pd +class CodeTreeMemory(Memory): + def __init__(self, project_path: str, db_name: str = '.code_tree', platform: str = 'OpenAI', api_key: str = None, embedding_model: str = "text-embedding-ada-002"): + super().__init__(project_path, db_name, platform, api_key, embedding_model) + self.collection_name = 'code_tree_memory' + self.embedder = OpenAI(api_key=api_key) + + + def add_code_files(self, directory: str, exclude_prefix: List[str] = ["workplace_"]): + """ + 将指定目录下的所有代码文件添加到内存中。 + + Args: + directory (str): 要添加的代码文件所在的目录路径 + """ + tree_sitter_parent_dir = os.path.dirname(os.getcwd()) + # Build Tree sitter Parser object + Language.build_library( + f"{tree_sitter_parent_dir}/my-languages.so", + [ + f"{tree_sitter_parent_dir}/tree-sitter-python", + ], + ) + parser = CodeParser( + language="python", + node_types=["class_definition", "function_definition"], + path_to_object_file=tree_sitter_parent_dir, + ) + logger.info("Parsing Code...") + parsed_snippets = parser.parse_directory( + directory + ) + snippet_texts = list(map(lambda x: x.snippet.decode("ISO-8859-1"), parsed_snippets)) + embedded_texts = self.embedder.embeddings.create(input=snippet_texts, model="text-embedding-3-small").data + embedded_snippets = [] + for code_text, embedding, snippet in zip( + snippet_texts, embedded_texts, parsed_snippets + ): + snippet.snippet = code_text + snippet.embedding = embedding.embedding + embedded_snippets.append(snippet) + + # Convert Snippets to DataFrame for ChromaDB Ingestion + data = pd.DataFrame(to_dataframe_row(embedded_snippets)) + collection = self.client.get_or_create_collection( + name=self.collection_name, metadata={"hnsw:space": "cosine"} + ) + logger.info( + f"Adding {data.shape[0]} Code snippets and embedding to " + "local chroma db collection..." + ) + collection.add( + documents=data["snippets"].tolist(), + embeddings=data["embeddings"].tolist(), + metadatas=data["metadatas"].tolist(), + ids=data["ids"].tolist(), + ) + + + def query_code(self, query_text: str, n_results: int = 5) -> List[Dict]: + """ + Query the code memory. + + Args: + query_text (str): The query text + n_results (int): The number of results to return + + Returns: + List[Dict]: The query results list + """ + query_embedding = self.embedder.embeddings.create(input=[query_text], model="text-embedding-3-small").data[0].embedding + results = self.client.get_or_create_collection(self.collection_name).query(query_embeddings=[query_embedding], n_results=n_results) + return [ + { + "file": metadata['filenames'], + "content": doc + } + for doc, metadata in zip(results['documents'][0], results['metadatas'][0]) + ] +class DummyReranker(Reranker): + def __init__(self, model: str = None) -> None: + super().__init__(model) + def rerank(self, query_results: List[Dict]) -> List[Dict]: + wrapped_reranked_results = "[Referenced code files]:" + result_path = [] + for result in query_results: + if result['file'] in result_path: + continue + else: + result_path.append(result['file']) + wrapped_reranked_results = f"Code path: {result['file']}\n" + wrapped_reranked_results += f"Code content:\n{result['content']}...\n" + wrapped_reranked_results += "---\n" + return wrapped_reranked_results + +# 使用示例 +if __name__ == "__main__": + code_memory = CodeTreeMemory(project_path = './code_db', db_name='code_tree', platform='OpenAI', api_key='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') + + # 添加代码文件到内存 + code_memory.add_code_files("/Users/tangjiabin/Documents/reasoning/SelfAgent/workplace_test/SelfAgent", exclude_prefix=['workplace_', '__pycache__', 'code_db', '.git']) + + # 查询代码 + query_results = code_memory.query_code("The definition of BaseAction", n_results=10) + + for result in query_results: + print(f"File: {result['file']}") + print(f"Content: {result['content'][:100]}...") # 只打印前100个字符 + print("---") \ No newline at end of file diff --git a/metachain/memory/paper_memory.py b/metachain/memory/paper_memory.py new file mode 100644 index 0000000..2ab6fc3 --- /dev/null +++ b/metachain/memory/paper_memory.py @@ -0,0 +1,80 @@ +import pandas as pd +from typing import List, Dict +from metachain.memory.rag_memory import Memory, Reranker +import json +import math +import os +from litellm import completion +from metachain.memory.utils import chunking_by_token_size + +class TextMemory(Memory): + def __init__( + self, + project_path: str, + db_name: str = '.text_table', + platform: str = 'OpenAI', + api_key: str = None, + embedding_model: str = "text-embedding-3-small", + ): + super().__init__( + project_path=project_path, + db_name=db_name, + platform=platform, + api_key=api_key, + embedding_model=embedding_model + ) + self.collection_name = 'text_memory' + + def add_text_content(self, paper_content: str, batch_size: int = 100, collection = None): + assert collection is not None, "Collection is required. Should be the path of the paper." + queries = [] + content_chunks = chunking_by_token_size(paper_content, max_token_size=4096) + + idx_list = ["chunk_" + str(chunk['chunk_order_index']) for chunk in content_chunks] + for chunk in content_chunks: + query = { + 'query': chunk['content'], + 'response': chunk['content'] + } + queries.append(query) + + # self.add_query(queries, collection=collection) + print(f'Adding {len(queries)} queries to {collection} with batch size {batch_size}') + num_batches = math.ceil(len(queries) / batch_size) + + for i in range(num_batches): + start_idx = i * batch_size + end_idx = min((i + 1) * batch_size, len(queries)) + batch_queries = queries[start_idx:end_idx] + batch_idx = idx_list[start_idx:end_idx] + + # Add the current batch of queries + self.add_query(batch_queries, collection=collection, idx=batch_idx) + + print(f"Batch {i+1}/{num_batches} added") + + def query_text_content( + self, + query_text: str, + collection: str = None, + n_results: int = 5 + ) -> List[str]: + """ + Query the table and return the results + """ + assert collection is not None, "Collection is required. Should be the path of the paper." + results = self.query([query_text], collection=collection, n_results=n_results) + + metadata_results = results['metadatas'][0] + + results = [item['response'] for item in metadata_results] + return results + + def peek_table(self, collection: str = None, n_results: int = 20) -> pd.DataFrame: + """ + Peek at the data in the table + """ + assert collection is not None, "Collection is required. Should be the path of the paper." + raw_results = self.peek(collection=collection, n_results=n_results) + results = [item['response'] for item in raw_results['metadatas']] + return results \ No newline at end of file diff --git a/metachain/memory/rag_memory.py b/metachain/memory/rag_memory.py new file mode 100644 index 0000000..a18be83 --- /dev/null +++ b/metachain/memory/rag_memory.py @@ -0,0 +1,179 @@ +import uuid +import os.path +from datetime import datetime +from typing import List, Dict +import chromadb +from chromadb.utils import embedding_functions +from abc import ABC, abstractmethod +from openai import OpenAI +import numpy as np +from chromadb.api.types import QueryResult +chromadb.logger.setLevel(chromadb.logging.ERROR) + + +class Memory: + def __init__( + self, + project_path: str, + db_name: str = '.sa', + platform: str = 'OpenAI', + api_key: str = None, + embedding_model: str = "text-embedding-3-small" + ): + """ + Memory: memory and external knowledge management. + Args: + project_path: the path to store the data. + embedding_model: the embedding model to use, default will use the embedding model from ChromaDB, + if the OpenAI has been set in the configuration, it will use the OpenAI embedding model + "text-embedding-ada-002". + """ + self.db_name = db_name + self.collection_name = 'memory' + self.client = chromadb.PersistentClient(path=os.path.join(project_path, self.db_name)) + self.client.get_or_create_collection( + self.collection_name, + ) + # use the OpenAI embedding function if the openai section is set in the configuration. + if platform == 'OpenAI': + openai_client = OpenAI(api_key=api_key or os.environ["OPENAI_API_KEY"]) + self.embedder = lambda x: [i.embedding for i in openai_client.embeddings.create(input=x, model=embedding_model).data] + else: + # self.embedder = embedding_functions.DefaultEmbeddingFunction() + self.embedder = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2") + + def add_query( + self, + queries: List[Dict[str, str]], + collection: str = None, + idx: List[str] = None + ): + """ + add_query: add the queries to the memery. + Args: + queries: the queries to add to the memery. Should be in the format of + { + "query": "the query", + "response": "the response" + } + collection: the name of the collection to add the queries. + idx: the ids of the queries, should be in the same length as the queries. + If not provided, the ids will be generated by UUID. + + Return: A list of generated IDs. + """ + if idx: + ids = idx + else: + ids = [str(uuid.uuid4()) for _ in range(len(queries))] + + if not collection: + collection = self.collection_name + + query_list = [query['query'] for query in queries] + embeddings = self.embedder(query_list) + added_time = datetime.now().isoformat() + resp_list = [{'response': query['response'], 'created_at': added_time} for query in queries] + # insert the record into the database + self.client.get_or_create_collection(collection).add( + documents=query_list, + metadatas=resp_list, + ids=ids, + embeddings=embeddings + ) + + return ids + + def query(self, query_texts: List[str], collection: str = None, n_results: int = 5) -> QueryResult: + """ + query: query the memery. + Args: + query_texts: the query texts to search in the memery. + collection: the name of the collection to search. + n_results: the number of results to return. + + Returns: QueryResult + class QueryResult(TypedDict): + ids: List[IDs] + embeddings: Optional[ + Union[ + List[Embeddings], + List[PyEmbeddings], + List[NDArray[Union[np.int32, np.float32]]], + ] + ] + documents: Optional[List[List[Document]]] + uris: Optional[List[List[URI]]] + data: Optional[List[Loadable]] + metadatas: Optional[List[List[Metadata]]] + distances: Optional[List[List[float]]] + included: Include + """ + if not collection: + collection = self.collection_name + query_embedding = self.embedder(query_texts) + return self.client.get_or_create_collection(collection).query(query_embeddings=query_embedding, n_results=n_results) + + def peek(self, collection: str = None, n_results: int = 20): + """ + peek: peek the memery. + Args: + collection: the name of the collection to peek. + n_results: the number of results to return. + + Returns: the top k results. + """ + if not collection: + collection = self.collection_name + return self.client.get_or_create_collection(collection).peek(limit=n_results) + + def get(self, collection: str = None, record_id: str = None): + """ + get: get the record by the id. + Args: + record_id: the id of the record. + collection: the name of the collection to get the record. + + Returns: the record. + """ + if not collection: + collection = self.collection_name + collection = self.client.get_collection(collection) + if not record_id: + return collection.get() + + return collection.get(record_id) + + def delete(self, collection_name=None): + """ + delete: delete the memery collections. + Args: + collection_name: the name of the collection to delete. + """ + if not collection_name: + collection_name = self.collection_name + return self.client.delete_collection(name=collection_name) + + def count(self, collection_name=None): + """ + count: count the number of records in the memery. + Args: + collection_name: the name of the collection to count. + """ + if not collection_name: + collection_name = self.collection_name + return self.client.get_or_create_collection(name=collection_name).count() + + def reset(self): + """ + reset: reset the memory. + Notice: You may need to set the environment variable `ALLOW_RESET` to `TRUE` to enable this function. + """ + self.client.reset() + +class Reranker: + def __init__(self, model: str) -> None: + self.model = model + @abstractmethod + def rerank(self, query_text: str, query_results: List[Dict]) -> List[Dict]: + raise NotImplementedError("Reranker is not implemented") \ No newline at end of file diff --git a/metachain/memory/tool_memory.py b/metachain/memory/tool_memory.py new file mode 100644 index 0000000..e80c88d --- /dev/null +++ b/metachain/memory/tool_memory.py @@ -0,0 +1,164 @@ +import pandas as pd +from typing import List, Dict +from metachain.memory.rag_memory import Memory, Reranker +import json +import math +import os +from litellm import completion +from pydantic import BaseModel +""" +Category | Tool_Name | Tool_Description | API_Name | API_Description | Method | API_Details | Required_API_Key | Platform +""" +class ToolMemory(Memory): + def __init__( + self, + project_path: str, + db_name: str = '.tool_table', + platform: str = 'OpenAI', + api_key: str = None, + embedding_model: str = "text-embedding-3-small", + ): + super().__init__( + project_path=project_path, + db_name=db_name, + platform=platform, + api_key=api_key, + embedding_model=embedding_model + ) + self.collection_name = 'tool_memory' + + def add_dataframe(self, df: pd.DataFrame, collection: str = None, batch_size: int = 100): + if not collection: + collection = self.collection_name + queries = [] + for idx, row in df.iterrows(): + query = { + 'query': ' '.join(row[['Tool_Name', 'Tool_Description', 'API_Name', 'API_Description']].astype(str)), + 'response': row.to_json() + } + queries.append(query) + + # self.add_query(queries, collection=collection) + print(f'Adding {len(queries)} queries to {collection} with batch size {batch_size}') + num_batches = math.ceil(len(queries) / batch_size) + + for i in range(num_batches): + start_idx = i * batch_size + end_idx = min((i + 1) * batch_size, len(queries)) + batch_queries = queries[start_idx:end_idx] + + # Add the current batch of queries + self.add_query(batch_queries, collection=collection) + + print(f"Batch {i+1}/{num_batches} added") + + def query_table( + self, + query_text: str, + collection: str = None, + n_results: int = 5 + ) -> pd.DataFrame: + """ + Query the table and return the results + """ + if not collection: + collection = self.collection_name + results = self.query([query_text], collection=collection, n_results=n_results) + + metadata_results = results['metadatas'][0] + + df_results = pd.DataFrame([json.loads(item['response']) for item in metadata_results]) + return df_results + + def peek_table(self, collection: str = None, n_results: int = 20) -> pd.DataFrame: + """ + Peek at the data in the table + """ + if not collection: + collection = self.collection_name + results = self.peek(collection=collection, n_results=n_results) + df_results = pd.DataFrame([json.loads(item['response']) for item in results['metadatas']]) + return df_results + +class ToolReranker(Reranker): + def rerank(self, query_text: str, query_df: pd.DataFrame) -> str: + system_prompt = \ + """ + You are a helpful assistant that reranks the given API table based on the query. + You should select the top 5 APIs to answer the query in the given format. + You can only select APIs I give you. + Directly give the answer without any other words. + """ + # Use the DataFrame's to_dict method to convert all rows to a list of dictionaries + # print('query_df', query_df) + api_data = query_df.to_dict(orient='records') + + # Use a list comprehension and f-string to format each API's data + api_prompts = [f"\n\nAPI {i+1}:\n{api}" for i, api in enumerate(api_data)] + + # add the query text to the prompt + prompt = ''.join(api_prompts) + prompt = f"The query is: {query_text}\n\n{prompt}" + message = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ] + + class Tools(BaseModel): + tool_name: str + api_name: str + rank: int + + class RerankResult(BaseModel): + tools: list[Tools] + + create_params = { + "model": self.model, + "messages": message, + "stream": False, + "response_format": RerankResult + } + response = completion(**create_params).choices[0].message.content + print(response) + rerank_result = json.loads(response)["tools"] + print(rerank_result) + if len(rerank_result) == 0: + return "Fail to retrieve the relevant information from the tool documentation." + try: + return self.wrap_rerank_result(rerank_result, query_df) + except Exception as e: + raise ValueError(f"Failed to wrap rerank result: {e}") + + def wrap_rerank_result(self, rerank_result: List[pd.DataFrame], query_df: pd.DataFrame) -> str: + res = "" + res_tmp = """ +The rank {rank} referenced tool documentation is: +API Name: {api_name} +API Description: {api_description} +API Details: {api_details} +Required API Key: {required_api_key} +Platform: {platform} +""" + try: + for tool_api in rerank_result: + tool_name = tool_api['tool_name'] + api_name = tool_api['api_name'] + matched_rows = query_df[(query_df['API_Name'] == api_name) & (query_df['Tool_Name'] == tool_name)] + if not matched_rows.empty: + res = res + res_tmp.format(rank=tool_api['rank'], api_name=matched_rows['API_Name'].values[0], api_description=matched_rows['API_Description'].values[0], api_details=matched_rows['API_Details'].values[0], required_api_key=matched_rows['Required_API_Key'].values[0], platform=matched_rows['Platform'].values[0]) + return res + except Exception as e: + raise ValueError(f"Failed to wrap rerank result: {e}") + def dummy_rerank(self, query_text: str, query_df: pd.DataFrame) -> str: + res = "" + res_tmp = """ +The rank {rank} referenced tool documentation is: +API Name: {api_name} +API Description: {api_description} +API Details: {api_details} +Required API Key: {required_api_key} +Platform: {platform} +""" + for i in range(len(query_df)): + res = res + res_tmp.format(rank=i+1, api_name=query_df['API_Name'].values[i], api_description=query_df['API_Description'].values[i], api_details=query_df['API_Details'].values[i], required_api_key=query_df['Required_API_Key'].values[i], platform=query_df['Platform'].values[i]) + return res diff --git a/metachain/memory/utils.py b/metachain/memory/utils.py new file mode 100644 index 0000000..4d0657f --- /dev/null +++ b/metachain/memory/utils.py @@ -0,0 +1,36 @@ +import tiktoken +ENCODER = None + +def encode_string_by_tiktoken(content: str, model_name: str = "gpt-4o"): + global ENCODER + if ENCODER is None: + ENCODER = tiktoken.encoding_for_model(model_name) + tokens = ENCODER.encode(content) + return tokens + + +def decode_tokens_by_tiktoken(tokens: list[int], model_name: str = "gpt-4o"): + global ENCODER + if ENCODER is None: + ENCODER = tiktoken.encoding_for_model(model_name) + content = ENCODER.decode(tokens) + return content +def chunking_by_token_size( + content: str, overlap_token_size=128, max_token_size=1024, tiktoken_model="gpt-4o" +): + tokens = encode_string_by_tiktoken(content, model_name=tiktoken_model) + results = [] + for index, start in enumerate( + range(0, len(tokens), max_token_size - overlap_token_size) + ): + chunk_content = decode_tokens_by_tiktoken( + tokens[start : start + max_token_size], model_name=tiktoken_model + ) + results.append( + { + "tokens": min(max_token_size, len(tokens) - start), + "content": chunk_content.strip(), + "chunk_order_index": index, + } + ) + return results \ No newline at end of file diff --git a/metachain/registry.py b/metachain/registry.py new file mode 100644 index 0000000..ad67bab --- /dev/null +++ b/metachain/registry.py @@ -0,0 +1,226 @@ +from typing import Callable, Dict, Any, Union, Literal, List, Optional +from dataclasses import dataclass, asdict +import inspect +import os +import functools +import tiktoken +MAX_OUTPUT_LENGTH = 12000 + +def encode_string_by_tiktoken(content: str, model_name: str = "gpt-4o"): + ENCODER = tiktoken.encoding_for_model(model_name) + tokens = ENCODER.encode(content) + return tokens + + +def decode_tokens_by_tiktoken(tokens: list[int], model_name: str = "gpt-4o"): + ENCODER = tiktoken.encoding_for_model(model_name) + content = ENCODER.decode(tokens) + return content +def truncate_output(output: str, max_length: int = MAX_OUTPUT_LENGTH) -> str: + """Truncate output if it exceeds max_length""" + tokens = encode_string_by_tiktoken(output) + if len(tokens) > max_length: + return decode_tokens_by_tiktoken(tokens[:max_length]) + f"\n\n[TOOL WARNING] Output truncated, exceeded {max_length} tokens)\n[TOOL SUGGESTION] Maybe this tool with direct output is not an optimal choice, consider save the output to a file in the `workplace/` directory to implement the same functionality." + return output + +@dataclass +class FunctionInfo: + name: str + func_name: str + func: Callable + args: List[str] + docstring: Optional[str] + body: str + return_type: Optional[str] + file_path: Optional[str] + def to_dict(self) -> dict: + # using asdict, but exclude func field because it cannot be serialized + d = asdict(self) + d.pop('func') # remove func field + return d + + @classmethod + def from_dict(cls, data: dict) -> 'FunctionInfo': + # if you need to create an object from a dictionary + if 'func' not in data: + data['func'] = None # or other default value + return cls(**data) +class Registry: + _instance = None + _registry: Dict[str, Dict[str, Callable]] = { + "tools": {}, + "agents": {}, + "plugin_tools": {}, + "plugin_agents": {}, + "workflows": {} + } + _registry_info: Dict[str, Dict[str, FunctionInfo]] = { + "tools": {}, + "agents": {}, + "plugin_tools": {}, + "plugin_agents": {}, + "workflows": {} + } + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def register(self, + type: Literal["tool", "agent", "plugin_tool", "plugin_agent", "workflow"], + name: str = None, + func_name: str = None): + """ + 统一的注册装饰器 + Args: + type: 注册类型,"tool" 或 "agent" + name: 可选的注册名称 + """ + def decorator(func: Callable): + nonlocal name + if name is None: + name = func.__name__ + # if type == "agent" and name.startswith('get_'): + # name = name[4:] # 对 agent 移除 'get_' 前缀 + # 获取函数的文件路径 + + if type == "plugin_tool": + original_func = func # 保存原始函数 + @functools.wraps(original_func) + def wrapped_func(*args, **kwargs): + result = original_func(*args, **kwargs) # 调用原始函数 + if isinstance(result, str): + return truncate_output(result) + return result + else: + wrapped_func = func + try: + file_path = os.path.abspath(inspect.getfile(func)) + except: + file_path = "Unknown" + + # 获取函数信息 + signature = inspect.signature(func) + args = list(signature.parameters.keys()) + docstring = inspect.getdoc(func) + + # 获取函数体 + source_lines = inspect.getsource(func) + # 移除装饰器和函数定义行 + body_lines = source_lines.split('\n')[1:] # 跳过装饰器行 + while body_lines and (body_lines[0].strip().startswith('@') or 'def ' in body_lines[0]): + body_lines = body_lines[1:] + body = '\n'.join(body_lines) + + # 获取返回类型提示 + return_type = None + if signature.return_annotation != inspect.Signature.empty: + return_type = str(signature.return_annotation) + + # 创建函数信息对象 + func_info = FunctionInfo( + name=name, + func_name=func_name, + func=wrapped_func if type == "plugin_tool" else func, + args=args, + docstring=docstring, + body=body, + return_type=return_type, + file_path=file_path # 添加文件路径 + ) + + registry_type = f"{type}s" + self._registry[registry_type][func_name] = wrapped_func if type == "plugin_tool" else func + self._registry_info[registry_type][name] = func_info + return wrapped_func if type == "plugin_tool" else func + return decorator + + @property + def tools(self) -> Dict[str, Callable]: + return self._registry["tools"] + + @property + def agents(self) -> Dict[str, Callable]: + return self._registry["agents"] + + @property + def plugin_tools(self) -> Dict[str, Callable]: + return self._registry["plugin_tools"] + + @property + def plugin_agents(self) -> Dict[str, Callable]: + return self._registry["plugin_agents"] + + @property + def workflows(self) -> Dict[str, Callable]: + return self._registry["workflows"] + + @property + def tools_info(self) -> Dict[str, FunctionInfo]: + return self._registry_info["tools"] + + @property + def agents_info(self) -> Dict[str, FunctionInfo]: + return self._registry_info["agents"] + + @property + def plugin_tools_info(self) -> Dict[str, FunctionInfo]: + return self._registry_info["plugin_tools"] + + @property + def plugin_agents_info(self) -> Dict[str, FunctionInfo]: + return self._registry_info["plugin_agents"] + + @property + def workflows_info(self) -> Dict[str, FunctionInfo]: + return self._registry_info["workflows"] + + @property + def display_plugin_tools_info(self): + display_info = {} + for name, info in self.plugin_tools_info.items(): + tmp_info = info.to_dict().copy() + tmp_info.pop('func', None) + display_info[name] = tmp_info + return display_info + + @property + def display_plugin_agents_info(self): + display_info = {} + for name, info in self.plugin_agents_info.items(): + tmp_info = info.to_dict().copy() + tmp_info.pop('func', None) + display_info[name] = tmp_info + return display_info + + @property + def display_workflows_info(self): + display_info = {} + for name, info in self.workflows_info.items(): + tmp_info = info.to_dict().copy() + tmp_info.pop('func', None) + display_info[name] = tmp_info + return display_info + +# 创建全局实例 +registry = Registry() + +# 便捷的注册函数 +def register_tool(name: str = None): + func_name = name + return registry.register(type="tool", name=name, func_name=func_name) + +def register_agent(name: str = None, func_name: str = None): + return registry.register(type="agent", name=name, func_name=func_name) + +def register_plugin_tool(name: str = None): + func_name = name + return registry.register(type="plugin_tool", name=name, func_name=func_name) + +def register_plugin_agent(name: str = None, func_name: str = None): + return registry.register(type="plugin_agent", name=name, func_name=func_name) + +def register_workflow(name: str = None): + func_name = name + return registry.register(type="workflow", name=name, func_name=func_name) \ No newline at end of file diff --git a/metachain/repl/__init__.py b/metachain/repl/__init__.py new file mode 100644 index 0000000..2a1cb40 --- /dev/null +++ b/metachain/repl/__init__.py @@ -0,0 +1 @@ +from .repl import run_demo_loop diff --git a/metachain/repl/repl.py b/metachain/repl/repl.py new file mode 100644 index 0000000..1f620ce --- /dev/null +++ b/metachain/repl/repl.py @@ -0,0 +1,87 @@ +import json + +from metachain.core import MetaChain + + +def process_and_print_streaming_response(response): + content = "" + last_sender = "" + + for chunk in response: + if "sender" in chunk: + last_sender = chunk["sender"] + + if "content" in chunk and chunk["content"] is not None: + if not content and last_sender: + print(f"\033[94m{last_sender}:\033[0m", end=" ", flush=True) + last_sender = "" + print(chunk["content"], end="", flush=True) + content += chunk["content"] + + if "tool_calls" in chunk and chunk["tool_calls"] is not None: + for tool_call in chunk["tool_calls"]: + f = tool_call["function"] + name = f["name"] + if not name: + continue + print(f"\033[94m{last_sender}: \033[95m{name}\033[0m()") + + if "delim" in chunk and chunk["delim"] == "end" and content: + print() # End of response message + content = "" + + if "response" in chunk: + return chunk["response"] + + +def pretty_print_messages(messages) -> None: + for message in messages: + if message["role"] != "assistant": + continue + + # print agent name in blue + print(f"\033[94m{message['sender']}\033[0m:", end=" ") + + # print response, if any + if message["content"]: + print(message["content"]) + + # print tool calls in purple, if any + tool_calls = message.get("tool_calls") or [] + if len(tool_calls) > 1: + print() + for tool_call in tool_calls: + f = tool_call["function"] + name, args = f["name"], f["arguments"] + arg_str = json.dumps(json.loads(args)).replace(":", "=") + print(f"\033[95m{name}\033[0m({arg_str[1:-1]})") + + +def run_demo_loop( + starting_agent, context_variables=None, stream=False, debug=False +) -> None: + client = MetaChain() + print("Starting Swarm CLI 🐝") + + messages = [] + agent = starting_agent + + while True: + user_input = input("\033[90mUser\033[0m: ") + messages.append({"role": "user", "content": user_input}) + + response = client.run( + agent=agent, + messages=messages, + context_variables=context_variables or {}, + stream=stream, + debug=debug, + ) + + if stream: + response = process_and_print_streaming_response(response) + else: + pretty_print_messages(response.messages) + + messages.extend(response.messages) + agent = response.agent diff --git a/metachain/server.py b/metachain/server.py new file mode 100644 index 0000000..765631e --- /dev/null +++ b/metachain/server.py @@ -0,0 +1,144 @@ +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from contextlib import asynccontextmanager +from typing import Dict, Any, Optional, List +from metachain.registry import registry +from metachain import MetaChain +from metachain.types import Agent, Response +import importlib +import inspect + +# 定义lifespan上下文管理器 +@asynccontextmanager +async def lifespan(app: FastAPI): + # 启动时执行 + await create_agent_endpoints(app) + yield + # 关闭时执行 + # 清理代码(如果需要) + +app = FastAPI(title="MetaChain API", lifespan=lifespan) + +class ToolRequest(BaseModel): + args: Dict[str, Any] + +class AgentRequest(BaseModel): + model: str + query: str + context_variables: Optional[Dict[str, Any]] = {} + +class Message(BaseModel): + role: str + content: str + +class AgentResponse(BaseModel): + result: str + messages: List + agent_name: str +# 为所有注册的tools创建endpoints +@app.on_event("startup") +def create_tool_endpoints(): + for tool_name, tool_func in registry.tools.items(): + # 创建动态的POST endpoint + async def create_tool_endpoint(request: ToolRequest, func=tool_func): + try: + # 检查必需参数 + sig = inspect.signature(func) + required_params = { + name for name, param in sig.parameters.items() + if param.default == inspect.Parameter.empty + } + + # 验证是否提供了所有必需参数 + if not all(param in request.args for param in required_params): + missing = required_params - request.args.keys() + raise HTTPException( + status_code=400, + detail=f"Missing required parameters: {missing}" + ) + + result = func(**request.args) + return {"status": "success", "result": result} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + # 添加endpoint到FastAPI应用 + endpoint = create_tool_endpoint + endpoint.__name__ = f"tool_{tool_name}" + app.post(f"/tools/{tool_name}")(endpoint) +# 重写agent endpoints创建逻辑 +@app.on_event("startup") +def create_agent_endpoints(): + for agent_name, agent_func in registry.agents.items(): + async def create_agent_endpoint( + request: AgentRequest, + func=agent_func + ) -> AgentResponse: + try: + # 创建agent实例 + agent = func(model=request.model) + + # 创建MetaChain实例 + mc = MetaChain() + + # 构建messages + messages = [ + {"role": "user", "content": request.query} + ] + + # 运行agent + response = mc.run( + agent=agent, + messages=messages, + context_storage=request.context_variables, + debug=True + ) + + return AgentResponse( + result=response.messages[-1]['content'], + messages=response.messages, + agent_name=agent.name + ) + + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Agent execution failed: {str(e)}" + ) + + endpoint = create_agent_endpoint + endpoint.__name__ = f"agent_{agent_name}" + app.post(f"/agents/{agent_name}/run")(endpoint) + +# 获取所有可用的agents信息 +@app.get("/agents") +async def list_agents(): + return { + name: { + "docstring": info.docstring, + "args": info.args, + "file_path": info.file_path + } + for name, info in registry.agents_info.items() + } + +# 获取特定agent的详细信息 +@app.get("/agents/{agent_name}") +async def get_agent_info(agent_name: str): + if agent_name not in registry.agents_info: + raise HTTPException( + status_code=404, + detail=f"Agent {agent_name} not found" + ) + + info = registry.agents_info[agent_name] + return { + "name": agent_name, + "docstring": info.docstring, + "args": info.args, + "file_path": info.file_path + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/metachain/tcp_server.py b/metachain/tcp_server.py new file mode 100644 index 0000000..0d11ba2 --- /dev/null +++ b/metachain/tcp_server.py @@ -0,0 +1,61 @@ +import socket +import subprocess +import json +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("--workplace", type=str, default=None) +args = parser.parse_args() + +if __name__ == "__main__": + assert args.workplace is not None, "Workplace is not specified" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("0.0.0.0", 12345)) + server.listen(1) + + print("Listening on port 12345...") + def receive_all(conn, buffer_size=4096): + data = b"" + while True: + part = conn.recv(buffer_size) + data += part + if len(part) < buffer_size: + # 如果接收的数据小于缓冲区大小,可能已经接收完毕 + break + return data.decode() + + while True: + conn, addr = server.accept() + print(f"Connection from {addr}") + while True: + # command = conn.recv(1024).decode() + command = receive_all(conn) + if not command: + break + + # Execute the command + try: + modified_command = f"/bin/bash -c 'source /home/user/micromamba/etc/profile.d/conda.sh && conda activate autogpt && cd /{args.workplace} && {command}'" + process = subprocess.Popen(modified_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + output = '' + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + output += line + print(line, end='') + + exit_code = process.wait() + except Exception as e: + exit_code = -1 + output = f"Error running command: {str(e)}" + + # Create a JSON response + response = { + "status": exit_code, + "result": output + } + + # Send the JSON response + conn.send(json.dumps(response).encode()) + conn.close() \ No newline at end of file diff --git a/metachain/tools/__init__.py b/metachain/tools/__init__.py new file mode 100644 index 0000000..6816de7 --- /dev/null +++ b/metachain/tools/__init__.py @@ -0,0 +1,67 @@ +# from .code_knowledge import gen_code_tree_structure +# from .execution import execute_command +# from .files import read_file, create_file, write_file, list_files, create_directory +# from .PythonAction import run_python +# from .rag_code import code_rag +# from .tool_retriever import get_api_doc +# from .rag_code_tree import code_tree_rag +# from .inner import case_resolved +# from .code_report import check_tool, check_agent +# from .github_ops import get_current_branch, get_diff, push_changes, submit_pull_request + +# import os +# import importlib +# from metachain.registry import registry + +# # 获取当前目录下的所有 .py 文件 +# current_dir = os.path.dirname(__file__) +# for file in os.listdir(current_dir): +# if file.endswith('.py') and not file.startswith('__'): +# module_name = file[:-3] +# importlib.import_module(f'metachain.tools.{module_name}') + +# # 导出所有注册的工具 +# globals().update(registry.tools) + +# __all__ = list(registry.tools.keys()) + +import os +import importlib +from metachain.registry import registry + +def import_tools_recursively(base_dir: str, base_package: str): + """Recursively import all tools in .py files + + Args: + base_dir: the root directory to start searching + base_package: the base name of the Python package + """ + for root, dirs, files in os.walk(base_dir): + # get the relative path to the base directory + rel_path = os.path.relpath(root, base_dir) + + for file in files: + if file.endswith('.py') and not file.startswith('__'): + # build the module path + if rel_path == '.': + # in the root directory + module_path = f"{base_package}.{file[:-3]}" + else: + # in the subdirectory + package_path = rel_path.replace(os.path.sep, '.') + module_path = f"{base_package}.{package_path}.{file[:-3]}" + + try: + importlib.import_module(module_path) + except Exception as e: + print(f"Warning: Failed to import {module_path}: {e}") + +# get the current directory and import all tools +current_dir = os.path.dirname(__file__) +import_tools_recursively(current_dir, 'metachain.tools') + +# export all tool creation functions +globals().update(registry.tools) +globals().update(registry.plugin_tools) + +__all__ = list(registry.tools.keys()) + list(registry.plugin_tools.keys()) \ No newline at end of file diff --git a/metachain/tools/code_report.py b/metachain/tools/code_report.py new file mode 100644 index 0000000..e13b088 --- /dev/null +++ b/metachain/tools/code_report.py @@ -0,0 +1,86 @@ +import importlib +import inspect +import os +from typing import Dict, Any, Union +# from metachain.util import run_command_in_container +from metachain.environment import DockerEnv, LocalEnv +from metachain.registry import register_tool + +@register_tool("check_tool") +def check_tool(context_variables) -> str: + """ + Extract tools from existing code. + + Args: + + Returns: + A dictionary containing all function definitions {function name: {'source': function source code, 'file': function file path}} + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + python_script = \ +"""import importlib +import inspect +import os +from typing import Dict, Any +def check_tool(): + module = importlib.import_module(f"metachain.tools") + + # obtain all function definitions + functions = {} + for name, obj in inspect.getmembers(module): + if inspect.isfunction(obj): + try: + # get the source code of the function + source = inspect.getsource(obj) + # get the file path of the function definition + file_path = inspect.getfile(obj) + functions[name] = { + "source": source, + "file": file_path + } + except Exception as e: + functions[name] = { + "source": f"Failed to get source code: {str(e)}", + "file": "Unknown" + } + + return functions +print(check_tool()) +""" + exec_script = f"cd {env.docker_workplace}/metachain && python -c '{python_script.strip()}'" + response = env.run_command(exec_script) + if response["status"] == 0: + return response["result"] + else: + return f"Failed to get tool definitions. Error: {response['result']}" + +@register_tool("check_agent") +def check_agent(context_variables) -> str: + """ + Extract agents from existing code. + + Args: + + Returns: + A dictionary containing all agents definitions {agent name: {'source': agent source code, 'file': agent file path}} + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + cmd = f"ls -1 {env.docker_workplace}/metachain/metachain/agents" + response = env.run_command(cmd) + if response["status"] == 0: + agents_files = response["result"].split("\n") + else: + return f"Failed to get agent definitions. Error: {response['result']}" + agents = {} + print(agents_files) + for file in agents_files: + if file in ["__init__.py", "", "__pycache__"]: + continue + cmd = f"cat {env.docker_workplace}/metachain/metachain/agents/{file}" + response = env.run_command(cmd) + if response["status"] == 0: + agent_name = file.split(".")[0] + agents[agent_name] = {'source': response["result"], 'file': f"{env.docker_workplace}/metachain/metachain/agents/{file}"} + else: + return f"Failed to get agent definitions. Error: {response['result']}" + return agents diff --git a/metachain/tools/code_search.py b/metachain/tools/code_search.py new file mode 100644 index 0000000..c6c3a02 --- /dev/null +++ b/metachain/tools/code_search.py @@ -0,0 +1,104 @@ +import requests +from typing import Optional, List, Dict +from metachain.tools.github_client import GitHubSearcher +from metachain.registry import register_tool +from constant import GITHUB_AI_TOKEN +import json +@register_tool("search_github_repos") +def search_github_repos(query, limit=5): + """ + Search GitHub public repositories based on a keyword. + + :param query: The query to search for in repository names or descriptions. + :param limit: The total number of repositories to return. + :return: A list of dictionaries containing repository details, limited to the specified number. + """ + repos = [] + per_page = 10 + page = 1 + while len(repos) < limit: + + url = f'https://api.github.com/search/repositories?q={query}&per_page={per_page}&page={page}' + + response = requests.get(url) + + if response.status_code == 200: + items = response.json().get('items', []) + for item in items: + formatted_repo = { + "name": f"{item['owner']['login']}/{item['name']}", + "author": item['owner']['login'], + "description": item['description'], + "link": item['html_url'] + } + repos.append(formatted_repo) + if len(repos) >= limit: + break + + if len(items) < per_page: # Stop if there are no more repos to fetch + break + page += 1 + else: + raise Exception(f"GitHub API request failed with status code {response.status_code}: {response.text}") + + return_str = """ + Here are some of the repositories I found on GitHub: + """ + + for repo in repos: + return_str += f""" + Name: {repo['name']} + Description: {repo['description']} + Link: {repo['link']} + """ + + return return_str +@register_tool("search_github_code") +def search_github_code(repo_owner: str, + repo_name: str, + query: str, + language: Optional[str] = None, + per_page: int = 5, + page: int = 1) -> List[Dict]: + """ + Search GitHub code based on a keyword. + + Args: + repo_owner: The owner of the repository + repo_name: The name of the repository + query: The keyword to search for + language: The programming language to filter by, optional + per_page: The number of results per page, optional + page: The page number, optional + + Returns: + List[Dict]: The search results list + """ + searcher = GitHubSearcher(GITHUB_AI_TOKEN) + results = searcher.search_code(repo_owner, repo_name, query, language, per_page, page) + # print(results) + if 'items' not in results: + return [] + + # Extract useful information + formatted_results = [] + for item in results['items']: + response = requests.get(item['url']) + if response.status_code == 200: + download_url = response.json()['download_url'] + response = requests.get(download_url) + if response.status_code == 200: + content = response.text + else: + content = "" + else: + content = "" + formatted_results.append({ + 'name': item['name'], + 'path': item['path'], + 'url': item['html_url'], + 'repository': item['repository']['full_name'], + 'content_url': item['url'], + 'content': content + }) + return json.dumps(formatted_results, indent=4) diff --git a/metachain/tools/dummy_tool.py b/metachain/tools/dummy_tool.py new file mode 100644 index 0000000..f5f776f --- /dev/null +++ b/metachain/tools/dummy_tool.py @@ -0,0 +1,41 @@ +from metachain.types import Result, Agent +from typing import Union +from metachain.registry import register_plugin_tool + +@register_plugin_tool("tool_dummy") # the name of registry should be the same as the name of the tool +def tool_dummy(args1, args2, context_variables)-> Union[str, Agent, Result]: + """ + [This is a dummy tool, it's used for demonstrating the usage of the metachain, you should write your own tool instead of using this dummy tool, and the following docstring is just a template, you should modify it to fit your own tool.] + The tool is used to ... + + Args: + args1: ... + args2: ... + ... + Returns: + ... + Notes: + The return value can be a string, an agent, or an instance of Result. + 1. The most common return value is a string, but the length of the string should not be too long, and you can save the result to a file if the length is too long. + 2. If the tool is used to transfer the conversation context to an agent, you can return an instance of Agent, like: + ```python + sales_agent = Agent(name="sales_agent", instructions="You are a sales agent.") + def transfer_to_sales_agent(): + return sales_agent + ``` + 3. If there is some complex operations inside the tool, you can return an instance of Result, for example, you should modify the context variables in the result. + ```python + def sales_agent_tool(recommendation: str, context_variables: dict): + ''' + The tool is used to recommend products to the user. + ''' + context_variables["recommendation"] = recommendation + return Result(value="I recommend the product: " + recommendation, agent=user_agent, context_variables=context_variables) + + 4. The value of Result class should be a string, if you want to return a json dictionary, you should convert it to string by json.dumps(result) + ``` + 5. The tools should be created in the python file in the `metachain/metachain/tools` folder. + """ + ... # the implementation of the tool + return "..." # the return value of the tool. + diff --git a/metachain/tools/file_surfer_tool.py b/metachain/tools/file_surfer_tool.py new file mode 100644 index 0000000..9be2462 --- /dev/null +++ b/metachain/tools/file_surfer_tool.py @@ -0,0 +1,346 @@ +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +from metachain.environment import LocalEnv +from functools import partial, update_wrapper +from inspect import signature +from typing import Tuple +import time +from metachain.registry import register_tool, register_plugin_tool +from typing import Union, Optional +from typing import Optional +from metachain.types import Result +import requests +import mimetypes +import base64 +import uuid +import os +from litellm import completion +import cv2 +import tempfile +from typing import List +from moviepy import * +import time +import base64 +from faster_whisper import WhisperModel +from constant import COMPLETION_MODEL, API_BASE_URL + +def with_env(env: RequestsMarkdownBrowser): + """将env注入到工具函数中的装饰器""" + def decorator(func): + def wrapped(*args, **kwargs): + return func(env=env, *args, **kwargs) + + # 保留原始函数的所有属性 + update_wrapper(wrapped, func) + # 修改signature,移除env参数 + wrapped.__signature__ = signature(func).replace( + parameters=[p for p in signature(func).parameters.values() if p.name != 'env'] + ) + return wrapped + return decorator + +def _get_browser_state(env: RequestsMarkdownBrowser) -> Tuple[str, str]: + """ + Get the current state of the browser, including the header and content. + """ + # print(env.address) + header = f"Address: {env._convert_local_to_docker(env.address)}\n" + # header = f"Address: {env.address}\n" + + if env.page_title is not None: + header += f"Title: {env.page_title}\n" + + current_page = env.viewport_current_page + total_pages = len(env.viewport_pages) + + address = env.address + for i in range(len(env.history) - 2, -1, -1): # Start from the second last + if env.history[i][0] == address: + header += f"You previously visited this page {round(time.time() - env.history[i][1])} seconds ago.\n" + break + prefix = f"Your browser is currently open to the page '{env.page_title}'\n" if env.page_title is not None else "" + + header = prefix + header + header += f"Viewport position: Showing page {current_page+1} of {total_pages}.\n" + return (header, env.viewport) + +@register_tool("open_local_file") +def open_local_file(context_variables, path: str): + """ + Open a local file at a path in the text-based browser and return current viewport content. + + Args: + path: The absolute path of a local file to visit. + """ + env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert env is not None, "file_env is not set" + try: + # assert DOCKER_WORKPLACE_NAME in path, f"The path must be a absolute path from `/{DOCKER_WORKPLACE_NAME}/` directory" + # local_path = path.replace('/' + DOCKER_WORKPLACE_NAME, LOCAL_ROOT + f'/{DOCKER_WORKPLACE_NAME}') + # print(local_path) + path = env._convert_docker_to_local(path) + env.open_local_file(path) + header, content = _get_browser_state(env) + final_response = header.strip() + "\n=======================\n" + content + return final_response + except Exception as e: + return f"Error in `open_local_file`: {e}" + +@register_tool("page_up_markdown") +def page_up_markdown(context_variables): + """ + Scroll the viewport UP one page-length in the current file and return the new viewport content. + """ + env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert env is not None, "file_env is not set" + try: + env.page_up() + header, content = _get_browser_state(env) + final_response = header.strip() + "\n=======================\n" + content + return final_response + except Exception as e: + return f"Error in `page_up`: {e}" + +@register_tool("page_down_markdown") +def page_down_markdown(context_variables): + """ + Scroll the viewport DOWN one page-length in the current file and return the new viewport content. + """ + env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert env is not None, "file_env is not set" + try: + env.page_down() + header, content = _get_browser_state(env) + final_response = header.strip() + "\n=======================\n" + content + return final_response + except Exception as e: + return f"Error in `page_down`: {e}" + +@register_tool("find_on_page_ctrl_f") +def find_on_page_ctrl_f(context_variables, search_string: str): + """ + Scroll the viewport to the first occurrence of the search string. This is equivalent to Ctrl+F. + + Args: + search_string: The string to search for on the page. This search string supports wildcards like '*' + """ + env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert env is not None, "file_env is not set" + try: + env.find_on_page(search_string) + header, content = _get_browser_state(env) + final_response = header.strip() + "\n=======================\n" + content + return final_response + except Exception as e: + return f"Error in `find_on_page_ctrl_f`: {e}" + +@register_tool("find_next") +def find_next(context_variables): + """ + Scroll the viewport to next occurrence of the search string. + """ + env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert env is not None, "file_env is not set" + try: + env.find_next() + header, content = _get_browser_state(env) + final_response = header.strip() + "\n=======================\n" + content + return final_response + except Exception as e: + return f"Error in `find_next`: {e}" + +def _encode_image(context_variables, image_path: str): + """ + Encode an image to base64. + """ + env: RequestsMarkdownBrowser = context_variables.get("file_env", None) + assert env is not None, "file_env is not set" + if image_path.startswith("http"): + user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0" + request_kwargs = { + "headers": {"User-Agent": user_agent}, + "stream": True, + } + + # Send a HTTP request to the URL + response = requests.get(image_path, **request_kwargs) + response.raise_for_status() + content_type = response.headers.get("content-type", "") + + extension = mimetypes.guess_extension(content_type) + if extension is None: + extension = ".download" + + fname = str(uuid.uuid4()) + extension + download_path = os.path.abspath(os.path.join(env.local_workplace, "downloads", fname)) + + with open(download_path, "wb") as fh: + for chunk in response.iter_content(chunk_size=512): + fh.write(chunk) + + image_path = download_path + elif env.local_workplace in image_path: + image_path = image_path + else: + image_path = env._convert_docker_to_local(image_path) + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode('utf-8') +# @register_tool("visual_question_answering") +# def visual_question_answering(context_variables, image_path: str, question: Optional[str] = None) -> Result: +# """ +# A tool that can answer questions about attached images. +# Args: +# image_path: The path to the image on which to answer the question. This should be a local path to downloaded image. +# question: the question to answer (default: "Please write a detailed caption for this image.") +# """ +# env: RequestsMarkdownBrowser = context_variables.get("file_env", None) +# assert env is not None, "file_env is not set" +# try: + +# if not question: +# question = "Please write a detailed caption for this image." + +# if not isinstance(image_path, str): +# raise Exception("You should provide only one string as argument to this tool!") + +# base64_image = _encode_image(context_variables, image_path) + +# ret_str = question + +# msg = [{"role": "user", "content": [ +# {"type": "text", "text": question}, +# {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}} +# ]}] +# res = completion(model="gpt-4o-2024-08-06", messages=msg) +# ret_str = res.choices[0].message.content +# return Result( +# value=ret_str, +# # image=base64_image +# ) +# except Exception as e: +# return Result( +# value=f"Error in `visual_question_answering`: {e}", +# ) + +@register_tool("visual_question_answering") +@register_plugin_tool("visual_question_answering") +def visual_question_answering(context_variables, file_path: str, question: Optional[str] = None) -> Result: + """ + This tool is used to answer questions about attached images or videos. + Args: + file_path: File path of the image or video. + question: The question to answer (default: "Please describe the image or video content.") + """ + env: Union[RequestsMarkdownBrowser, LocalEnv] = context_variables.get("file_env", LocalEnv()) + if env is None: + env = LocalEnv() + + try: + if not isinstance(file_path, str): + raise Exception("File path must be a string!") + + # 判断文件类型 + file_extension = os.path.splitext(file_path)[1].lower() + video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.webm'} + + if file_extension in video_extensions: + # 视频处理逻辑 + if not question: + question = "Please describe the video content." + + # 1. 提取关键帧 + local_file_path = env._convert_docker_to_local(file_path) + base64Frames, audio_path = process_video(local_file_path) + if audio_path is not None: + audio_text = process_audio(audio_path) + else: + audio_text = "No audio found in the video." + + messages=[ + {"role": "system", "content":"""Use the video and transcription to answer the provided question."""}, + {"role": "user", "content": [ + {"type": "text", "text": "These are the frames from the video."}, + *map(lambda x: {"type": "image_url", + "image_url": {"url": f'data:image/png;base64,{x}', "detail": "low"}}, base64Frames), + {"type": "text", "text": f"The audio transcription is: {audio_text}\nQuestion: {question}"}, + ], + } + ] + final_res = completion(model=COMPLETION_MODEL, messages=messages, base_url=API_BASE_URL) + return Result(value=final_res.choices[0].message.content) + + else: + # 原有的图片处理逻辑 + if not question: + question = "Please describe the image content." + + base64_image = _encode_image(context_variables, file_path) + msg = [{"role": "user", "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}} + ]}] + res = completion(model=COMPLETION_MODEL, messages=msg, base_url=API_BASE_URL) + return Result(value=res.choices[0].message.content) + + except Exception as e: + return Result(value=f"Error in `visual_question_answering`: {e}") +def process_audio(audio_path): + model = WhisperModel("large-v3-turbo") + print(f"Processing batch: {audio_path}") + + segments, info = model.transcribe(audio_path) + transcribed_text = "" + for segment in segments: + transcribed_text += "[%.2fs -> %.2fs] %s\n" % (segment.start, segment.end, segment.text) + + return transcribed_text +def process_video(video_path, seconds_per_frame=2): + base64Frames = [] + base_video_path, _ = os.path.splitext(video_path) + + video = cv2.VideoCapture(video_path) + total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + fps = video.get(cv2.CAP_PROP_FPS) + frames_to_skip = int(fps * seconds_per_frame) + curr_frame=0 + + # Loop through the video and extract frames at specified sampling rate + while curr_frame < total_frames - 1: + video.set(cv2.CAP_PROP_POS_FRAMES, curr_frame) + success, frame = video.read() + if not success: + break + _, buffer = cv2.imencode(".png", frame) + base64Frames.append(base64.b64encode(buffer).decode("utf-8")) + curr_frame += frames_to_skip + video.release() + + # Extract audio from video + + clip = VideoFileClip(video_path) + if clip.audio is not None: + audio_path = f"{base_video_path}.mp3" + clip.audio.write_audiofile(audio_path, bitrate="32k") + clip.audio.close() + clip.close() + else: + audio_path = None + clip.close() + + print(f"Extracted {len(base64Frames)} frames") + print(f"Extracted audio to {audio_path}") + return base64Frames, audio_path + +if __name__ == "__main__": + local_root = os.getcwd() + workplace_name = 'workplace_gaia_meta' + env = RequestsMarkdownBrowser(viewport_size=1024 * 5, local_root=local_root, workplace_name=workplace_name, downloads_folder=os.path.join(local_root, workplace_name, "downloads")) + # print("Open file", "~"*100) + # print(open_local_file(env, f"/{workplace_name}/downloads/2207.01510v1.pdf")) + # print("Page down", "~"*100) + # print(page_down_markdown(env)) + # print("Find on page", "~"*100) + # print(find_on_page_ctrl_f(env, "Chain-of-Thought")) + # print("Find next", "~"*100) + # print(find_next(env)) + print(visual_question_answering(context_variables = {"file_env": env}, file_path = "//workplace_gaia_meta/metachain/downloaded_video.mp4", question="What is the highest number of bird species to be on camera simultaneously?").value) + # print(visual_question_answering("/workplace_meta/downloads/workflow.png", "What is the main idea of this paper?").image) \ No newline at end of file diff --git a/metachain/tools/github_client.py b/metachain/tools/github_client.py new file mode 100644 index 0000000..39688ce --- /dev/null +++ b/metachain/tools/github_client.py @@ -0,0 +1,256 @@ +import os +import requests +import json +from typing import Optional, Dict, List +import time +class GitHubClient: + """GitHub operation client""" + + def __init__(self, token: Optional[str] = None): + """ + Initialize the GitHub client + + Args: + token: GitHub Personal Access Token, if None, try to get from environment variable + """ + self.token = token or os.getenv('GITHUB_AI_TOKEN') + if not self.token: + raise ValueError("GitHub Token is required, please provide it via the token parameter or set the GITHUB_AI_TOKEN environment variable.") + + self.session = requests.Session() + self.session.headers.update({ + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + }) + self.api_base = 'https://api.github.com' + + def check_auth(self) -> dict: + """ + Check if the token is valid + """ + try: + response = self.session.get(f'{self.api_base}/user') + response.raise_for_status() + return {'status': 0, 'message': 'Authentication successful', 'user': response.json()} + except Exception as e: + return {'status': -1, 'message': f'Authentication failed: {str(e)}'} + + def create_pull_request(self, repo: str, title: str, body: str, head: str, base: str) -> dict: + """ + Create a Pull Request + + Args: + repo: The full name of the repository (e.g., 'owner/repo') + title: The PR title + body: The PR description + head: The source branch + base: The target branch + """ + try: + url = f'{self.api_base}/repos/{repo}/pulls' + data = { + 'title': title, + 'body': body, + 'head': head, + 'base': base + } + response = self.session.post(url, json=data) + response.raise_for_status() + pr_data = response.json() + return { + 'status': 0, + 'message': f'PR created successfully: {pr_data["html_url"]}', + 'pr_url': pr_data['html_url'] + } + except Exception as e: + return {'status': -1, 'message': f'PR creation failed: {str(e)}'} + + + +class GitHubSearcher: + def __init__(self, token: Optional[str] = None): + """ + Initialize the GitHub searcher + + Args: + token: GitHub Personal Access Token, optional + """ + self.session = requests.Session() + if token: + self.session.headers.update({ + 'Authorization': f'token {token}', + 'Accept': 'application/vnd.github.v3+json' + }) + self.session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }) + + # def search_code(self, + # repo_owner: str, + # repo_name: str, + # query: str, + # language: Optional[str] = None, + # per_page: int = 1) -> Dict: + # """ + # Search code in a specific repository + + # Args: + # repo_owner: The owner of the repository + # repo_name: The name of the repository + # query: The search keyword + # language: The programming language filter, optional + # per_page: The number of results per page + + # Returns: + # dict: The search results + # """ + # # Modify the search URL + # base_url = "https://api.github.com/search/code" # Modify here + + # # Build the query parameters + # q = f"repo:{repo_owner}/{repo_name} {query}" + # if language: + # q += f" language:{language}" + + # params = { + # 'q': q, + # 'per_page': per_page # add this parameter + # } + + # try: + # response = self.session.get(base_url, params=params) + # response.raise_for_status() # Check if the request is successful + + # # Handle rate limiting + # if 'X-RateLimit-Remaining' in response.headers: + # remaining = int(response.headers['X-RateLimit-Remaining']) + # if remaining < 10: # If the remaining requests are less, pause for a while + # reset_time = int(response.headers['X-RateLimit-Reset']) + # sleep_time = reset_time - time.time() + # if sleep_time > 0: + # time.sleep(sleep_time) + + # return response.json() + + # except requests.exceptions.RequestException as e: + # return { + # 'status': 'error', + # 'message': f"Request failed: {str(e)}", + # 'items': [] + # } + + # def get_file_content(self, file_url: str) -> str: + # """ + # Get the content of a file + + # Args: + # file_url: The URL of the file + + # Returns: + # str: The content of the file + # """ + # try: + # response = self.session.get(file_url) + # response.raise_for_status() + # return response.json()['content'] + + # except requests.exceptions.RequestException as e: + # return f"Failed to get file content: {str(e)}" + def search_code(self, + repo_owner: str, + repo_name: str, + query: str, + language: Optional[str] = None, + per_page: int = 5, + page: int = 1) -> Dict: + """搜索代码""" + base_url = "https://api.github.com/search/code" + + # 构建查询 + q = f"repo:{repo_owner}/{repo_name} {query}" + if language: + q += f" language:{language}" + + params = { + 'q': q, + 'per_page': min(per_page, 100), # 确保不超过最大限制 + 'page': page + } + + try: + response = self.session.get(base_url, params=params) + response.raise_for_status() + + # 处理速率限制 + self._handle_rate_limit(response.headers) + + return response.json() + + except requests.exceptions.RequestException as e: + return { + 'status': 'error', + 'message': f"Request failed: {str(e)}", + 'items': [] + } + + def get_contents_batch(self, items: List[Dict]) -> List[Dict]: + """批量获取文件内容""" + results = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + future_to_item = { + executor.submit(self._get_single_content, item): item + for item in items + } + + for future in concurrent.futures.as_completed(future_to_item): + try: + result = future.result() + results.append(result) + except Exception as e: + print(f"Error getting content: {str(e)}") + + return results + + def _get_single_content(self, item: Dict) -> Dict: + """获取单个文件的内容""" + try: + response = self.session.get(item['url']) + response.raise_for_status() + self._handle_rate_limit(response.headers) + + file_data = response.json() + if 'download_url' in file_data: + content_response = self.session.get(file_data['download_url']) + content_response.raise_for_status() + content = content_response.text + else: + content = file_data.get('content', '') + if content: + import base64 + content = base64.b64decode(content).decode('utf-8') + + return { + 'name': item['name'], + 'path': item['path'], + 'url': item['html_url'], + 'repository': item['repository']['full_name'], + 'content': content + } + except Exception as e: + return { + 'name': item['name'], + 'path': item['path'], + 'url': item['html_url'], + 'repository': item['repository']['full_name'], + 'content': f"Error: {str(e)}" + } + + def _handle_rate_limit(self, headers: Dict): + """处理 API 速率限制""" + if 'X-RateLimit-Remaining' in headers: + remaining = int(headers['X-RateLimit-Remaining']) + if remaining < 10: + reset_time = int(headers['X-RateLimit-Reset']) + sleep_time = reset_time - time.time() + if sleep_time > 0: + time.sleep(min(sleep_time, 5)) # 最多等待5秒 \ No newline at end of file diff --git a/metachain/tools/github_ops.py b/metachain/tools/github_ops.py new file mode 100644 index 0000000..2563cb5 --- /dev/null +++ b/metachain/tools/github_ops.py @@ -0,0 +1,153 @@ +# from metachain.util import run_command_in_container +from metachain.environment import DockerEnv, LocalEnv +from constant import GITHUB_AI_TOKEN +from metachain.tools.github_client import GitHubClient +import json +from metachain.registry import register_tool +from typing import Union +@register_tool("get_current_branch") +def get_current_branch(context_variables): + f""" + Get the current branch of the 'metachain'. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + branch_command = f"cd {env.docker_workplace}/metachain && git branch --show-current" + result = env.run_command(branch_command) + if result['status'] == 0: + return result['result'].strip() + else: + return f"Failed to get the current branch. Error: {result['result'].strip()}" + +@register_tool("get_diff") +def get_diff(context_variables): + f""" + Get the diff of the 'metachain'. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + diff_command = f"cd {env.docker_workplace}/metachain && git add -N . && git diff" + result = env.run_command(diff_command) + if result['status'] == 0: + return result['result'].strip() + else: + return f"Failed to get the diff. Error: {result['result'].strip()}" + +@register_tool("stage_files") +def stage_files(context_variables, file_paths=None): + """ + Stage the specified file changes + + Args: + file_paths (list): The file paths to stage, if None, add all changes to the staging area + + Returns: + dict: The operation result + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + if file_paths is None: + # add all changes to the staging area + command = f"cd {env.docker_workplace}/metachain && git add ." + else: + # add specified files to the staging area + files = ' '.join(file_paths) + command = f"cd {env.docker_workplace}/metachain && git add {files}" + + result = env.run_command(command) + return result + +@register_tool("push_changes") +def push_changes(context_variables, commit_message, file_paths=None): + """ + Push the selected changes to the remote repository + + Args: + commit_message (str): The commit message + file_paths (list): The file paths to commit, if None, commit all changes + + Returns: + dict: The push result + """ + # stage the files + # if file_paths: + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + stage_result = stage_files(env, file_paths) + if stage_result['status'] != 0: + return json.dumps({'status': 'error', 'message': f"Failed to stage files: {stage_result['result']}"}, indent=4) + + commands = [ + f"cd {env.docker_workplace}/metachain", + f'git commit -m "{commit_message}"', + "git push origin $(git branch --show-current)" + ] + + command = " && ".join(commands) + result = env.run_command(command) + + if result['status'] == 0: + return f"push success. {result['result']}" + else: + return f"push failed. {result['result']}" + +@register_tool("submit_pull_request") +def submit_pull_request(title: str, body: str, target_branch: str): + """ + Submit a Pull Request + + Args: + title: PR title + body: PR description + target_branch: target branch + """ + # initialize GitHub client + github = GitHubClient(GITHUB_AI_TOKEN) + + # check authentication + auth_result = github.check_auth() + if auth_result['status'] != 0: + return auth_result + + # create a pull request + pr_result = github.create_pull_request( + repo="tjb-tech/metachain", + title=title, + body=body, + head=get_current_branch(), + base=target_branch + ) + if pr_result['status'] == 0: + return f"PR created successfully: {json.dumps(pr_result, indent=4)}" + else: + return f"PR creation failed: {json.dumps(pr_result, indent=4)}" + +# def create_pull_request(title, body, target_branch): +# """ +# Create a Pull Request to the target branch + +# Args: +# title (str): The title of the PR +# body (str): The description content of the PR +# target_branch (str): The target branch name + +# Returns: +# dict: PR creation result +# """ + +# # use gh to create a PR. make sure the gh cli is installed in the container and the github token is set +# pr_command = f"""cd /{DOCKER_WORKPLACE_NAME}/metachain && \ +# gh pr create \ +# --title "{title}" \ +# --body "{body}" \ +# --base {target_branch} \ +# --head $(git branch --show-current)""" + +# result = run_command_in_container(pr_command) + +# if result['status'] == 0: +# return f"PR created successfully: {result['result']}" +# else: +# return f"PR creation failed: {result['result']}" +if __name__ == "__main__": + from rich import print + print("Current branch: " + get_current_branch()) + print("Diff: " + get_diff()) + print(push_changes(commit_message="test")) + print(submit_pull_request(title="test", body="test", target_branch="test_pull_1107")) \ No newline at end of file diff --git a/metachain/tools/inner.py b/metachain/tools/inner.py new file mode 100644 index 0000000..ffaadc7 --- /dev/null +++ b/metachain/tools/inner.py @@ -0,0 +1,27 @@ +from metachain.registry import register_tool + +@register_tool("case_resolved") +def case_resolved(result: str): + """ + Use this function when the case is resolved and no further actions are needed. Please encapsulate your final answer (answer ONLY) within and . + + Args: + result: The final result of the case resolution following the instructions. + + Example: case_resolved(`The answer to the question is 42 `) + """ + return f"Case resolved. No further actions are needed. The result of the case resolution is: {result}" + +@register_tool("case_not_resolved") +def case_not_resolved(failure_reason: str): + """ + Use this function when the case is not resolved when all agents have tried their best. + [IMPORTANT] Please do not use this function unless all of you have tried your best. + + Args: + failure_reason: The reason why the case is not resolved. + """ + return f"Case not resolved. No further actions are needed. The reason is: {failure_reason}" + +if __name__ == "__main__": + print(case_resolved()) \ No newline at end of file diff --git a/metachain/tools/md_obs.py b/metachain/tools/md_obs.py new file mode 100644 index 0000000..0b4c585 --- /dev/null +++ b/metachain/tools/md_obs.py @@ -0,0 +1,149 @@ +from browsergym.utils.obs import _process_bid, IGNORED_AXTREE_ROLES, IGNORED_AXTREE_PROPERTIES + +def flatten_md_axtree_to_str( + AX_tree, + extra_properties: dict = None, + with_visible: bool = False, + with_clickable: bool = False, + with_center_coords: bool = False, + with_bounding_box_coords: bool = False, + with_som: bool = False, + skip_generic: bool = True, + filter_visible_only: bool = False, + filter_with_bid_only: bool = False, + filter_som_only: bool = False, + coord_decimals: int = 0, + ignored_roles=IGNORED_AXTREE_ROLES, + ignored_properties=IGNORED_AXTREE_PROPERTIES, + remove_redundant_static_text: bool = True, + hide_bid_if_invisible: bool = False, + hide_all_children: bool = False, + hide_all_bids: bool = False, +) -> str: + """Formats the accessibility tree into a string text""" + node_id_to_idx = {} + for idx, node in enumerate(AX_tree["nodes"]): + node_id_to_idx[node["nodeId"]] = idx + + def dfs(node_idx: int, depth: int, parent_node_filtered: bool, parent_node_name: str) -> str: + tree_str = "" + node = AX_tree["nodes"][node_idx] + # indent = "\t" * depth + indent = "" + skip_node = False # node will not be printed, with no effect on children nodes + filter_node = False # node will not be printed, possibly along with its children nodes + node_role = node["role"]["value"] + node_name = "" + + if node_role in ignored_roles: + skip_node = True + pass + elif "name" not in node: + skip_node = True + pass + else: + node_name = node["name"]["value"] + if "value" in node and "value" in node["value"]: + node_value = node["value"]["value"] + else: + node_value = None + + # extract bid + bid = node.get("browsergym_id", None) + + # extract node attributes + attributes = [] + for property in node.get("properties", []): + if not "value" in property: + continue + if not "value" in property["value"]: + continue + + prop_name = property["name"] + prop_value = property["value"]["value"] + + if prop_name in ignored_properties: + continue + elif prop_name in ("required", "focused", "atomic"): + if prop_value: + attributes.append(prop_name) + else: + attributes.append(f"{prop_name}={repr(prop_value)}") + + if skip_generic and node_role == "generic" and not attributes: + skip_node = True + + if hide_all_children and parent_node_filtered: + skip_node = True + + if node_role == "StaticText": + if parent_node_filtered: + skip_node = True + elif remove_redundant_static_text and node_name in parent_node_name: + skip_node = True + else: + filter_node, extra_attributes_to_print = _process_bid( + bid, + extra_properties=extra_properties, + with_visible=with_visible, + with_clickable=with_clickable, + with_center_coords=with_center_coords, + with_bounding_box_coords=with_bounding_box_coords, + with_som=with_som, + filter_visible_only=filter_visible_only, + filter_with_bid_only=filter_with_bid_only, + filter_som_only=filter_som_only, + coord_decimals=coord_decimals, + ) + + # if either is True, skip the node + skip_node = skip_node or filter_node + + # insert extra attributes before regular attributes + attributes = extra_attributes_to_print + attributes + + # actually print the node string + if not skip_node: + if node_role == "generic" and not node_name: + node_str = f"{node_role}" + else: + node_str = f"{node_name.strip()}" + + if not ( + hide_all_bids + or bid is None + or ( + hide_bid_if_invisible + and extra_properties.get(bid, {}).get("visibility", 0) < 0.5 + ) + ): + node_str = node_str + + if node_value is not None: + node_str += f' value={repr(node["value"]["value"])}' + + if attributes: + node_str += ", ".join([""] + attributes) + + tree_str += f"{indent}{node_str}" + + for child_node_id in node["childIds"]: + if child_node_id not in node_id_to_idx or child_node_id == node["nodeId"]: + continue + # mark this to save some tokens + child_depth = depth if skip_node else (depth + 1) + child_str = dfs( + node_id_to_idx[child_node_id], + child_depth, + parent_node_filtered=filter_node, + parent_node_name=node_name, + ) + if child_str: + if tree_str: + tree_str += "\n" + tree_str += child_str + + return tree_str + + tree_str = dfs(0, 0, False, "") + return tree_str diff --git a/metachain/tools/meta/edit_agents.py b/metachain/tools/meta/edit_agents.py new file mode 100644 index 0000000..7ad5668 --- /dev/null +++ b/metachain/tools/meta/edit_agents.py @@ -0,0 +1,436 @@ +from metachain.registry import registry +from metachain.environment import LocalEnv, DockerEnv, DockerConfig +from typing import Union +from metachain.tools.terminal_tools import ( + create_file, + create_directory, + execute_command, + run_python, + print_stream, + process_terminal_response + ) +from metachain.registry import register_tool +import json +from metachain.tools.meta.edit_tools import get_metachain_path +from string import Formatter +from pydantic import BaseModel +import subprocess +import sys +import shlex +from datetime import datetime +@register_tool("list_agents") +def list_agents(context_variables): + """ + List all plugin agents in the MetaChain. + Returns: + A list of information of all plugin agents including name, args, docstring, body, return_type, file_path. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + python_code = '"from metachain.registry import registry; import json; print(\\"AGENT_LIST_START\\"); print(json.dumps(registry.display_plugin_agents_info, indent=4)); print(\\"AGENT_LIST_END\\")"' + list_agents_cmd = f"cd {path} && DEFAULT_LOG=False python -c {python_code}" + result = env.run_command(list_agents_cmd) + if result['status'] != 0: + return "[ERROR] Failed to list agents. Error: " + result['result'] + try: + output = result['result'] + start_marker = "AGENT_LIST_START" + end_marker = "AGENT_LIST_END" + start_idx = output.find(start_marker) + len(start_marker) + end_idx = output.find(end_marker) + + if start_idx == -1 or end_idx == -1: + return "[ERROR] Failed to parse agent list: markers not found" + + json_str = output[start_idx:end_idx].strip() + return json_str + except Exception as e: + return f"[ERROR] Failed to process output: {str(e)}" + + +@register_tool("delete_agent") +def delete_agent(agent_name: str, context_variables): + """ + Delete a plugin agent. + Args: + agent_name: The name of the agent to be deleted. + Returns: + A string representation of the result of the agent deletion. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "[ERROR] Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + if agent_name in agent_dict.keys(): + agent_info = agent_dict[agent_name] + else: + return "[ERROR] The agent " + agent_name + " does not exist." + except Exception as e: + return "[ERROR] Before deleting a agent, you should list all agents first. But the following error occurred: " + str(e) + + agent_path = agent_info['file_path'] + try: + result = env.run_command(f"rm {agent_path}") + if result['status'] != 0: + return f"[ERROR] Failed to delete agent: `{agent_name}`. Error: " + result['result'] + return f"[SUCCESS] Successfully deleted agent: `{agent_name}`." + except Exception as e: + return f"[ERROR] Failed to delete agent: `{agent_name}`. Error: " + str(e) + +@register_tool("run_agent") +@process_terminal_response +def run_agent(agent_name: str, query: str, ctx_vars: dict, context_variables, model: str = "claude-3-5-sonnet-20241022"): + """ + Run a plugin agent. + Args: + agent_name: The name of the agent. + model: The model to be used for the agent. Supported models: claude-3-5-sonnet-20241022. + query: The query to be used for the agent. + ctx_vars: The global context variables to be used for the agent. It is a dictionary with the key as the variable name and the value as the variable value. + Returns: + A string representation of the result of the agent run. + """ + if model not in ["claude-3-5-sonnet-20241022"]: + return "[ERROR] The model " + model + " is not supported. Supported models: claude-3-5-sonnet-20241022." + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to get the path of the MetaChain. Error: " + str(e) + + try: + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "[ERROR] Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + if agent_name in agent_dict.keys(): + agent_info = agent_dict[agent_name] + agent_func = agent_info['func_name'] + else: + return "[ERROR] The agent " + agent_name + " does not exist." + except Exception as e: + return "[ERROR] Before running a agent, you should list all agents first. But the following error occurred: " + str(e) + if isinstance(ctx_vars, dict) is False: + try: + ctx_vars = json.loads(ctx_vars) + except Exception as e: + return "[ERROR] The context variables are not a valid JSON object. Error: " + str(e) + + ctx_vars_str = "" + for key, value in ctx_vars.items(): + ctx_vars_str += f"{key}={value} " + try: + # query = shlex.quote(query) + # run_cmd = f'cd {path} && DEFAULT_LOG=False mc agent --model={model} --agent_func={agent_func} --query={query} {ctx_vars_str}' + query = shlex.quote(query) + shell_content = f"""#!/bin/bash +cd {path} +DEFAULT_LOG=False mc agent --model={model} --agent_func={agent_func} --query={query} {ctx_vars_str} +""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + create_directory(f"{path}/tmp_shell", context_variables) + create_file(f"{path}/tmp_shell/run_agent_{timestamp}.sh", shell_content, context_variables) + run_cmd = f"cd {path} && chmod +x tmp_shell/run_agent_{timestamp}.sh && ./tmp_shell/run_agent_{timestamp}.sh" + result = env.run_command(run_cmd, print_stream) + # if result['status'] != 0: + # return f"[ERROR] Failed to run agent: `{agent_func}`. Error: " + result['result'] + # return f"[SUCCESS] Successfully run agent: `{agent_func}`. The result is: \n{result['result']}" + return result + except Exception as e: + return "[ERROR] Failed to run the agent. Error: " + str(e) + +def has_format_keys(s): + formatter = Formatter() + return any(tuple_item[1] is not None for tuple_item in formatter.parse(s)) +def extract_format_keys(s): + formatter = Formatter() + ret_list = [] + for tuple_item in formatter.parse(s): + if tuple_item[1] is not None and tuple_item[1] not in ret_list: + ret_list.append(tuple_item[1]) + return ret_list +@register_tool("create_agent") +def create_agent(agent_name: str, agent_description: str, agent_tools: list[str], agent_instructions: str, context_variables): + """ + Use this tool to create a new agent or modify an existing agent. + + Args: + agent_name: The name of the agent. + agent_description: The description of the agent. + agent_tools: The tools of the agent. The tools MUST be included in the list of given tools. + agent_instructions: The system instructions of the agent, which tells the agent about the responsibility of the agent, the tools it can use and other important information. It could be a pure string or a string with the format of {global_keys}, where the global keys are the keys of the variables that are given to the agent. + + Returns: + A string representation of the result of the agent creation or modification. + """ + tools_str = "" + code_env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(code_env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + agents_dir = path + "/metachain/agents" + + for tool in agent_tools: + tools_str += f"from metachain.tools import {tool}\n" + agent_func = f"get_{agent_name.lower().replace(' ', '_')}" + if has_format_keys(agent_instructions): + format_keys = extract_format_keys(agent_instructions) + format_keys_values = [] + for fk in format_keys: + format_keys_values.append(f"{fk}=context_variables.get('{fk}', '')") + format_keys_values_str = ", ".join(format_keys_values) + instructions_str = f"""\ +def instructions(context_variables): + return {repr(agent_instructions)}.format({format_keys_values_str}) +""" + else: + instructions_str = f"""instructions = {repr(agent_instructions)}""" + tool_list = "[{}]".format(', '.join(f'{tool}' for tool in agent_tools)) + + create_codes = f"""\ +from metachain.types import Agent +{tools_str} +from metachain.registry import register_plugin_agent + +@register_plugin_agent(name="{agent_name}", func_name="{agent_func}") +def {agent_func}(model: str): + ''' + {agent_description} + ''' + {instructions_str} + return Agent( + name="{agent_name}", + model=model, + instructions=instructions, + functions={tool_list} + ) + +""" + # print(create_codes) + # with open(f"metachain/agents/{agent_name.lower().replace(' ', '_')}.py", "w", encoding="utf-8") as f: + # f.write(create_codes) + try: + msg = create_file(agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py", create_codes, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create agent. Error: " + msg + result = code_env.run_command('cd {} && python metachain/agents/{}.py'.format(path, agent_name.lower().replace(' ', '_'))) + if result['status'] != 0: + return "[ERROR] Failed to create agent. Error: " + result['result'] + return "Successfully created agent: " + agent_name + " in " + agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py" + except Exception as e: + return "[ERROR] Failed to create agent. Error: " + str(e) + +class SubAgent(BaseModel): + name: str + agent_input: str + agent_output: str +@register_tool("create_orchestrator_agent") +def create_orchestrator_agent(agent_name: str, agent_description: str, sub_agents: list[SubAgent], agent_instructions: str, context_variables): + """ + Use this tool to create a orchestrator agent for the given sub-agents. You MUST use this tool when you need to create TWO or MORE agents and regard them as a whole to complete a task. + + Args: + agent_name: The name of the orchestrator agent for the given sub-agents. + agent_description: The description of the orchestrator agent. + sub_agents: The list of sub-agents. Each sub-agent contains the name of the sub-agent, the input of the sub-agent and the output of the sub-agent. + agent_instructions: The system instructions of the orchestrator agent, which tells the agent about the responsibility of the agent (orchestrate the workflow of the given sub-agents), the given sub-agents and other important information. It could be a pure string or a string with the format of {global_keys}, where the global keys are the keys of the variables that are given to the agent. + + Returns: + A string representation of the result of the agent creation or modification. + """ + + code_env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(code_env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + agents_dir = path + "/metachain/agents" + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + sub_agent_info = [agent_dict[sub_agent["name"]] for sub_agent in sub_agents] + import_agent_str = "" + for ainfo in sub_agent_info: + import_agent_str += f""" + from metachain.agents import {ainfo['func_name']} +""" + if has_format_keys(agent_instructions): + format_keys = extract_format_keys(agent_instructions) + format_keys_values = [] + for fk in format_keys: + format_keys_values.append(f"{fk}=context_variables.get('{fk}', '')") + format_keys_values_str = ", ".join(format_keys_values) + instructions_str = f"""\ +def instructions(context_variables): + return {repr(agent_instructions)}.format({format_keys_values_str}) +""" + else: + instructions_str = f"""instructions = {repr(agent_instructions)}""" + orchestrator_agent_def = f""" + {agent_name.lower().replace(' ', '_')} = Agent( + name="{agent_name}", + model=model, + instructions=instructions, + ) +""" + sub_agent_funcs = [ainfo['func_name'] for ainfo in sub_agent_info] + get_sub_agents = "" + transfer_sub_agent_func = "" + transfer_back_to_orchestrator_func = "" + transfer_funcs_str = [] + for sub_agent_func, sub_agent in zip(sub_agent_funcs, sub_agents): + get_sub_agents += f""" + {sub_agent_func.replace('get_', '')}: Agent = {sub_agent_func}(model) + {sub_agent_func.replace('get_', '')}.tool_choice = "required" +""" + transfer_sub_agent_func += f""" + def transfer_to_{sub_agent_func.replace('get_', '')}({sub_agent["agent_input"]}: str): + ''' + Use this tool to transfer the request to the `{sub_agent_func.replace('get_', '')}` agent. + + Args: + {sub_agent["agent_input"]}: the request to be transferred to the `{sub_agent_func.replace('get_', '')}` agent. It should be a string. + ''' + return Result(value = {sub_agent["agent_input"]}, agent = {sub_agent_func.replace('get_', '')}) +""" + transfer_funcs_str.append(f"transfer_to_{sub_agent_func.replace('get_', '')}") + transfer_back_to_orchestrator_func += f""" + def transfer_back_to_{agent_name.lower().replace(' ', '_')}({sub_agent["agent_output"]}: str): + ''' + Use this tool to transfer the response back to the `{agent_name}` agent. You can only use this tool when you have tried your best to do the task the orchestrator agent assigned to you. + + Args: + {sub_agent["agent_output"]}: the response to be transferred back to the `{agent_name}` agent. It should be a string. + ''' + return Result(value = {sub_agent["agent_output"]}, agent = {agent_name.lower().replace(' ', '_')}) + {sub_agent_func.replace('get_', '')}.functions.append(transfer_back_to_{agent_name.lower().replace(' ', '_')}) +""" + + agent_func = f"get_{agent_name.lower().replace(' ', '_')}" + + + + create_codes = f"""\ +from metachain.types import Agent +from metachain.registry import register_plugin_agent +from metachain.types import Result + +@register_plugin_agent(name = "{agent_name}", func_name="{agent_func}") +def {agent_func}(model: str): + ''' + {agent_description} + ''' + {import_agent_str} + {instructions_str} + {orchestrator_agent_def} + + {get_sub_agents} + {transfer_sub_agent_func} + {transfer_back_to_orchestrator_func} + + {agent_name.lower().replace(' ', '_')}.functions = [{", ".join(transfer_funcs_str)}] + return {agent_name.lower().replace(' ', '_')} +""" + # print(create_codes) + # with open(f"metachain/agents/{agent_name.lower().replace(' ', '_')}.py", "w", encoding="utf-8") as f: + # f.write(create_codes) + try: + msg = create_file(agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py", create_codes, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create agent. Error: " + msg + result = code_env.run_command('cd {} && python metachain/agents/{}.py'.format(path, agent_name.lower().replace(' ', '_'))) + if result['status'] != 0: + return "[ERROR] Failed to create agent. Error: " + result['result'] + return "Successfully created agent: " + agent_name + " in " + agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py" + except Exception as e: + return "[ERROR] Failed to create agent. Error: " + str(e) + +def read_agent(agent_name: str, context_variables: dict): + try: + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to get the path of the MetaChain. Error: " + str(e) + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + if agent_name not in agent_dict.keys(): + return "[ERROR] The agent " + agent_name + " does not exist." + agent_info = agent_dict[agent_name] + ret_val = f"""\ + The information of the agent {agent_name} is: + {agent_info} + """ + return ret_val + except Exception as e: + return "[ERROR] Failed to read the agent. Error: " + str(e) + + +if __name__ == "__main__": +# # print(list_agents({})) +# from litellm import completion +# from metachain.util import function_to_json +# tools = [function_to_json(create_agent)] +# messages = [ +# {"role": "system", "content": "You are a helpful assistant."}, +# {"role": "user", "content": """\ +# Create an Personalized RAG agent that can answer the question about the given document. There are some tools you can use: +# - save_raw_docs_to_vector_db: Save the raw documents to the vector database. The documents could be: +# - ANY text document with the extension of pdf, docx, txt, etcs. +# - A zip file containing multiple text documents +# - a directory containing multiple text documents +# All documents will be converted to raw text format and saved to the vector database in the chunks of 4096 tokens. +# - query_db: Retrieve information from the database. Use this function when you need to search for information in the database. +# - modify_query: Modify the query based on what you know. Use this function when you need to modify the query to search for more relevant information. +# - answer_query: Answer the user query based on the supporting documents. +# - can_answer: Check if you have enough information to answer the user query. +# - visual_question_answering: This tool is used to answer questions about attached images or videos. + +# There are some global variables you can use: +# glbal_keys | global_vals +# -----------|----------- +# user_name | "Jiabin Tang" +# user_email | "jiabin.tang@gmail.com" + +# [IMPORTANT] NOT ALL tools are required to be used. You can choose the tools that you think are necessary. +# """}, +# ] +# for tool in tools: +# params = tool["function"]["parameters"] +# params["properties"].pop("context_variables", None) +# if "context_variables" in params["required"]: +# params["required"].remove("context_variables") +# # response = completion( +# # model="claude-3-5-sonnet-20241022", +# # messages=messages, +# # tools=tools, +# # tool_choice="auto", # auto is default, but we'll be explicit +# # ) +# # print("\nLLM Response1:\n", response.choices[0].message.tool_calls) +# # args = json.loads(response.choices[0].message.tool_calls[0].function.arguments) +# # create_agent(args["agent_name"], args["agent_description"], args["agent_tools"], args["agent_instructions"], {}) +# # print(list_agents({})) +# print(create_orchestrator_agent("Orchestrator Coding RAG Agent", "An Orchestrator Agent that orchestrates the workflow of the codig agent and the RAG agent.", [{"name": "Personalized RAG Agent", "agent_input": "doc_query", "agent_output": "queried_doc_content"}, {"name": "Coding Agent", "agent_input": "coding_query", "agent_output": "coding_result"}], "You are a helpful assistant.", {})) + docker_cfg = DockerConfig( + container_name = "nl2agent_showcase", + workplace_name = "workplace", + communication_port = 12350, + conda_path = "/root/miniconda3", + local_root = "/Users/tangjiabin/Documents/reasoning/metachain/workspace_meta_showcase/showcase_nl2agent_showcase" + ) + code_env = DockerEnv(docker_cfg) + context_variables = {"code_env": code_env} + print(run_agent(agent_name='Financial Analysis Orchestrator', query="Based on the 10-K reports of AAPL and MSFT from the past 5 years in the docs directory `docs/aapl-2020-2024-10K/` and `docs/msft-2020-2024-10K/`, along with AAPL's other reports `docs/aapl-other-report/` and available data, conduct a comprehensive horizontal comparison, create a comparative analysis report, and provide constructive investment advice for investing in them in 2025.", ctx_vars='{}', context_variables=context_variables)) + + diff --git a/metachain/tools/meta/edit_agents.py.back b/metachain/tools/meta/edit_agents.py.back new file mode 100644 index 0000000..deee691 --- /dev/null +++ b/metachain/tools/meta/edit_agents.py.back @@ -0,0 +1,415 @@ +from metachain.registry import registry +from metachain.environment import LocalEnv, DockerEnv +from typing import Union +from metachain.tools.terminal_tools import ( + create_file, + create_directory, + execute_command, + run_python, + print_stream, + process_terminal_response + ) +from metachain.registry import register_tool +import json +from metachain.tools.meta.edit_tools import get_metachain_path +from string import Formatter +from pydantic import BaseModel +import subprocess +import sys +@register_tool("list_agents") +def list_agents(context_variables): + """ + List all plugin agents in the MetaChain. + Returns: + A list of information of all plugin agents including name, args, docstring, body, return_type, file_path. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + python_code = '"from metachain.registry import registry; import json; print(\\"AGENT_LIST_START\\"); print(json.dumps(registry.display_plugin_agents_info, indent=4)); print(\\"AGENT_LIST_END\\")"' + list_agents_cmd = f"cd {path} && DEFAULT_LOG=False python -c {python_code}" + result = env.run_command(list_agents_cmd) + if result['status'] != 0: + return "[ERROR] Failed to list agents. Error: " + result['result'] + try: + output = result['result'] + start_marker = "AGENT_LIST_START" + end_marker = "AGENT_LIST_END" + start_idx = output.find(start_marker) + len(start_marker) + end_idx = output.find(end_marker) + + if start_idx == -1 or end_idx == -1: + return "[ERROR] Failed to parse agent list: markers not found" + + json_str = output[start_idx:end_idx].strip() + return json_str + except Exception as e: + return f"[ERROR] Failed to process output: {str(e)}" + +@register_tool("create_agent_old") +def create_agent_old(agent_func: str, agent_code: str, context_variables): + """ + Create a new plugin agent. + Args: + agent_func: The name of the function of getting the new agent. + agent_code: The code of creating the new agent. (You should strictly follow the format of the template given to you to create the new agent.) + Returns: + A string representation of the result of the agent creation. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + agents_dir = path + "/metachain/agents" + try: + msg = create_file(agents_dir + "/" + agent_func + ".py", agent_code, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create agent. Error: " + msg + return "[SUCCESS] Successfully created agent: " + agent_func + " in " + agents_dir + "/" + agent_func + ".py" + except Exception as e: + return "[ERROR] Failed to create agent. Error: " + str(e) + +@register_tool("delete_agent") +def delete_agent(agent_name: str, context_variables): + """ + Delete a plugin agent. + Args: + agent_name: The name of the agent to be deleted. + Returns: + A string representation of the result of the agent deletion. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "[ERROR] Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + if agent_name in agent_dict.keys(): + agent_info = agent_dict[agent_name] + else: + return "[ERROR] The agent " + agent_name + " does not exist." + except Exception as e: + return "[ERROR] Before deleting a agent, you should list all agents first. But the following error occurred: " + str(e) + + agent_path = agent_info['file_path'] + try: + result = env.run_command(f"rm {agent_path}") + if result['status'] != 0: + return f"[ERROR] Failed to delete agent: `{agent_name}`. Error: " + result['result'] + return f"[SUCCESS] Successfully deleted agent: `{agent_name}`." + except Exception as e: + return f"[ERROR] Failed to delete agent: `{agent_name}`. Error: " + str(e) + +@register_tool("run_agent") +@process_terminal_response +def run_agent(agent_name: str, model: str, query: str, ctx_vars: dict, context_variables): + """ + Run a plugin agent. + Args: + agent_name: The name of the agent. + model: The model to be used for the agent. + query: The query to be used for the agent. + ctx_vars: The global context variables to be used for the agent. + Returns: + A string representation of the result of the agent run. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to get the path of the MetaChain. Error: " + str(e) + + try: + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "[ERROR] Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + if agent_name in agent_dict.keys(): + agent_info = agent_dict[agent_name] + agent_func = agent_info['func_name'] + else: + return "[ERROR] The agent " + agent_name + " does not exist." + except Exception as e: + return "[ERROR] Before running a agent, you should list all agents first. But the following error occurred: " + str(e) + ctx_vars_str = "" + for key, value in ctx_vars.items(): + ctx_vars_str += f"{key}={value} " + try: + run_cmd = f'cd {path} && DEFAULT_LOG=False mc agent --model={model} --agent_func={agent_func} --query="{query}" {ctx_vars_str}' + result = env.run_command(run_cmd, print_stream) + # if result['status'] != 0: + # return f"[ERROR] Failed to run agent: `{agent_func}`. Error: " + result['result'] + # return f"[SUCCESS] Successfully run agent: `{agent_func}`. The result is: \n{result['result']}" + return result + except Exception as e: + return "[ERROR] Failed to run the agent. Error: " + str(e) + +def has_format_keys(s): + formatter = Formatter() + return any(tuple_item[1] is not None for tuple_item in formatter.parse(s)) +def extract_format_keys(s): + formatter = Formatter() + ret_list = [] + for tuple_item in formatter.parse(s): + if tuple_item[1] is not None and tuple_item[1] not in ret_list: + ret_list.append(tuple_item[1]) + return ret_list +@register_tool("create_agent") +def create_agent(agent_name: str, agent_description: str, agent_tools: list[str], agent_instructions: str, context_variables): + """ + Use this tool to create a new agent or modify an existing agent. + + Args: + agent_name: The name of the agent. + agent_description: The description of the agent. + agent_tools: The tools of the agent. The tools MUST be included in the list of given tools. + agent_instructions: The system instructions of the agent, which tells the agent about the responsibility of the agent, the tools it can use and other important information. It could be a pure string or a string with the format of {global_keys}, where the global keys are the keys of the variables that are given to the agent. + + Returns: + A string representation of the result of the agent creation or modification. + """ + tools_str = "" + code_env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(code_env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + agents_dir = path + "/metachain/agents" + + for tool in agent_tools: + tools_str += f"from metachain.tools import {tool}\n" + agent_func = f"get_{agent_name.lower().replace(' ', '_')}" + if has_format_keys(agent_instructions): + format_keys = extract_format_keys(agent_instructions) + format_keys_values = [] + for fk in format_keys: + format_keys_values.append(f"{fk}=context_variables.get('{fk}', '')") + format_keys_values_str = ", ".join(format_keys_values) + instructions_str = f"""\ +def instructions(context_variables): + return {repr(agent_instructions)}.format({format_keys_values_str}) +""" + else: + instructions_str = f"""instructions = {repr(agent_instructions)}""" + tool_list = "[{}]".format(', '.join(f'{tool}' for tool in agent_tools)) + + create_codes = f"""\ +from metachain.types import Agent +{tools_str} +from metachain.registry import register_plugin_agent + +@register_plugin_agent(name="{agent_name}", func_name="{agent_func}") +def {agent_func}(model: str): + ''' + {agent_description} + ''' + {instructions_str} + return Agent( + name="{agent_name}", + model=model, + instructions=instructions, + tools={tool_list} + ) + +""" + # print(create_codes) + # with open(f"metachain/agents/{agent_name.lower().replace(' ', '_')}.py", "w", encoding="utf-8") as f: + # f.write(create_codes) + try: + msg = create_file(agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py", create_codes, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create agent. Error: " + msg + result = code_env.run_command('cd {} && python metachain/agents/{}.py'.format(path, agent_name.lower().replace(' ', '_'))) + if result['status'] != 0: + return "[ERROR] Failed to create agent. Error: " + result['result'] + return "Successfully created agent: " + agent_func + " in " + agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py" + except Exception as e: + return "[ERROR] Failed to create agent. Error: " + str(e) + +class SubAgent(BaseModel): + name: str + agent_input: str + agent_output: str +@register_tool("create_orchestrator_agent") +def create_orchestrator_agent(agent_name: str, agent_description: str, sub_agents: list[SubAgent], agent_instructions: str, context_variables): + """ + Use this tool to create a orchestrator agent for the given sub-agents. You MUST use this tool when you need to create TWO or MORE agents and regard them as a whole to complete a task. + + Args: + agent_name: The name of the orchestrator agent for the given sub-agents. + agent_description: The description of the orchestrator agent. + sub_agents: The list of sub-agents. Each sub-agent contains the name of the sub-agent, the input of the sub-agent and the output of the sub-agent. + agent_instructions: The system instructions of the orchestrator agent, which tells the agent about the responsibility of the agent (orchestrate the workflow of the given sub-agents), the given sub-agents and other important information. It could be a pure string or a string with the format of {global_keys}, where the global keys are the keys of the variables that are given to the agent. + + Returns: + A string representation of the result of the agent creation or modification. + """ + + code_env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(code_env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + agents_dir = path + "/metachain/agents" + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + sub_agent_info = [agent_dict[sub_agent["name"]] for sub_agent in sub_agents] + import_agent_str = "" + for ainfo in sub_agent_info: + import_agent_str += f""" + from metachain.agents import {ainfo['func_name']} +""" + if has_format_keys(agent_instructions): + format_keys = extract_format_keys(agent_instructions) + format_keys_values = [] + for fk in format_keys: + format_keys_values.append(f"{fk}=context_variables.get('{fk}', '')") + format_keys_values_str = ", ".join(format_keys_values) + instructions_str = f"""\ +def instructions(context_variables): + return {repr(agent_instructions)}.format({format_keys_values_str}) +""" + else: + instructions_str = f"""instructions = {repr(agent_instructions)}""" + orchestrator_agent_def = f""" + {agent_name.lower().replace(' ', '_')} = Agent( + name="{agent_name}", + model=model, + instructions=instructions, + ) +""" + sub_agent_funcs = [ainfo['func_name'] for ainfo in sub_agent_info] + get_sub_agents = "" + transfer_sub_agent_func = "" + transfer_back_to_orchestrator_func = "" + transfer_funcs_str = [] + for sub_agent_func, sub_agent in zip(sub_agent_funcs, sub_agents): + get_sub_agents += f""" + {sub_agent_func.replace('get_', '')}: Agent = {sub_agent_func}(model) +""" + transfer_sub_agent_func += f""" + def transfer_to_{sub_agent_func.replace('get_', '')}({sub_agent["agent_input"]}): + return Result(value = {sub_agent["agent_input"]}, agent = {sub_agent_func.replace('get_', '')}) +""" + transfer_funcs_str.append(f"transfer_to_{sub_agent_func.replace('get_', '')}") + transfer_back_to_orchestrator_func += f""" + def transfer_back_to_{agent_name.lower().replace(' ', '_')}({sub_agent["agent_input"]}): + return Result(value = {sub_agent["agent_input"]}, agent = {agent_name.lower().replace(' ', '_')}) + {sub_agent_func.replace('get_', '')}.functions.append(transfer_back_to_{agent_name.lower().replace(' ', '_')}) +""" + + agent_func = f"get_{agent_name.lower().replace(' ', '_')}" + + + + create_codes = f"""\ +from metachain.types import Agent +from metachain.registry import register_plugin_agent +from metachain.types import Result + +@register_plugin_agent(name = "{agent_name}", func_name="{agent_func}") +def {agent_func}(model: str): + ''' + {agent_description} + ''' + {import_agent_str} + {instructions_str} + {orchestrator_agent_def} + + {get_sub_agents} + {transfer_sub_agent_func} + {transfer_back_to_orchestrator_func} + + {agent_name.lower().replace(' ', '_')}.functions = [{", ".join(transfer_funcs_str)}] + return {agent_name.lower().replace(' ', '_')} +""" + # print(create_codes) + # with open(f"metachain/agents/{agent_name.lower().replace(' ', '_')}.py", "w", encoding="utf-8") as f: + # f.write(create_codes) + try: + msg = create_file(agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py", create_codes, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create agent. Error: " + msg + result = code_env.run_command('cd {} && python metachain/agents/{}.py'.format(path, agent_name.lower().replace(' ', '_'))) + if result['status'] != 0: + return "[ERROR] Failed to create agent. Error: " + result['result'] + return "Successfully created agent: " + agent_func + " in " + agents_dir + "/" + agent_name.lower().replace(' ', '_') + ".py" + except Exception as e: + return "[ERROR] Failed to create agent. Error: " + str(e) + +def read_agent(agent_name: str, context_variables: dict): + try: + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to get the path of the MetaChain. Error: " + str(e) + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + if agent_name not in agent_dict.keys(): + return "[ERROR] The agent " + agent_name + " does not exist." + agent_info = agent_dict[agent_name] + ret_val = f"""\ + The information of the agent {agent_name} is: + {agent_info} + """ + return ret_val + except Exception as e: + return "[ERROR] Failed to read the agent. Error: " + str(e) + + +if __name__ == "__main__": + # print(list_agents({})) + from litellm import completion + from metachain.util import function_to_json + tools = [function_to_json(create_agent)] + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": """\ +Create an Personalized RAG agent that can answer the question about the given document. There are some tools you can use: +- save_raw_docs_to_vector_db: Save the raw documents to the vector database. The documents could be: + - ANY text document with the extension of pdf, docx, txt, etcs. + - A zip file containing multiple text documents + - a directory containing multiple text documents + All documents will be converted to raw text format and saved to the vector database in the chunks of 4096 tokens. +- query_db: Retrieve information from the database. Use this function when you need to search for information in the database. +- modify_query: Modify the query based on what you know. Use this function when you need to modify the query to search for more relevant information. +- answer_query: Answer the user query based on the supporting documents. +- can_answer: Check if you have enough information to answer the user query. +- visual_question_answering: This tool is used to answer questions about attached images or videos. + +There are some global variables you can use: +glbal_keys | global_vals +-----------|----------- +user_name | "Jiabin Tang" +user_email | "jiabin.tang@gmail.com" + +[IMPORTANT] NOT ALL tools are required to be used. You can choose the tools that you think are necessary. + """}, + ] + for tool in tools: + params = tool["function"]["parameters"] + params["properties"].pop("context_variables", None) + if "context_variables" in params["required"]: + params["required"].remove("context_variables") + # response = completion( + # model="claude-3-5-sonnet-20241022", + # messages=messages, + # tools=tools, + # tool_choice="auto", # auto is default, but we'll be explicit + # ) + # print("\nLLM Response1:\n", response.choices[0].message.tool_calls) + # args = json.loads(response.choices[0].message.tool_calls[0].function.arguments) + # create_agent(args["agent_name"], args["agent_description"], args["agent_tools"], args["agent_instructions"], {}) + # print(list_agents({})) + print(create_orchestrator_agent("Orchestrator Coding RAG Agent", "An Orchestrator Agent that orchestrates the workflow of the codig agent and the RAG agent.", [{"name": "Personalized RAG Agent", "agent_input": "doc_query", "agent_output": "queried_doc_content"}, {"name": "Coding Agent", "agent_input": "coding_query", "agent_output": "coding_result"}], "You are a helpful assistant.", {})) \ No newline at end of file diff --git a/metachain/tools/meta/edit_tools copy.py b/metachain/tools/meta/edit_tools copy.py new file mode 100644 index 0000000..ec2af22 --- /dev/null +++ b/metachain/tools/meta/edit_tools copy.py @@ -0,0 +1,207 @@ +from metachain.registry import registry +from metachain.environment import LocalEnv, DockerEnv +from typing import Union +from metachain.tools.terminal_tools import ( + create_file, + create_directory, + run_python, + ) +from metachain.registry import register_tool + +from metachain.io_utils import print_stream +import json +def get_metachain_path(env: Union[LocalEnv, DockerEnv]) -> str: + result = env.run_command('pip show metachain') + if result['status'] != 0: + raise Exception("Failed to list tools. Error: " + result['result']) + stdout = result['result'] + for line in stdout.split('\n'): + if line.startswith('Editable project location:'): + path = line.split(':', 1)[1].strip() + return path + raise Exception("Failed to list tools. The MetaChain is not installed in editable mode.") + +@register_tool("list_tools") +def list_tools(context_variables): + """ + List all plugin tools in the MetaChain. + Returns: + A list of information of all plugin tools including name, args, docstring, body, return_type, file_path. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "Failed to list tools. Error: " + str(e) + python_code = '"from metachain.registry import registry; import json; print(\\"TOOL_LIST_START\\"); print(json.dumps(registry.display_plugin_tools_info, indent=4)); print(\\"TOOL_LIST_END\\")"' + list_tools_cmd = f"cd {path} && DEFAULT_LOG=False python -c {python_code}" + result = env.run_command(list_tools_cmd) + if result['status'] != 0: + return "Failed to list tools. Error: " + result['result'] + try: + output = result['result'] + start_marker = "TOOL_LIST_START" + end_marker = "TOOL_LIST_END" + start_idx = output.find(start_marker) + len(start_marker) + end_idx = output.find(end_marker) + + if start_idx == -1 or end_idx == -1: + return "Failed to parse tool list: markers not found" + + json_str = output[start_idx:end_idx].strip() + return json_str + except Exception as e: + return f"Failed to process output: {str(e)}" + # return result['result'] + +@register_tool("create_tool") +def create_tool(tool_name: str, tool_code: str, context_variables): + """ + Create a plugin tool. + Args: + tool_name: The name of the tool. + tool_code: The code of creating the tool. (You should strictly follow the format of the template given to you to create the tool.) + Returns: + A string representation of the result of the tool creation. + """ + if tool_name == "visual_question_answering": + return "The tool `visual_question_answering` is not allowed to be modified." + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "Failed to list tools. Error: " + str(e) + + tools_dir = path + "/metachain/tools" + try: + msg = create_file(tools_dir + "/" + tool_name + ".py", tool_code, context_variables) + if msg.startswith("Error creating file:"): + return "Failed to create tool. Error: " + msg + return "Successfully created tool: " + tool_name + " in " + tools_dir + "/" + tool_name + ".py" + except Exception as e: + return "Failed to create tool. Error: " + str(e) + +def tool_exists(tool_name: str, context_variables): + try: + list_res = list_tools(context_variables) + tool_dict = json.loads(list_res) + if tool_name not in tool_dict.keys(): + return False, tool_dict + return True, tool_dict + except Exception as e: + return "Before deleting a tool, you should list all tools first. But the following error occurred: " + str(e), None + +@register_tool("delete_tool") +def delete_tool(tool_name: str, context_variables): + """ + Delete a plugin tool. + Args: + tool_name: The name of the tool to be deleted. + Returns: + A string representation of the result of the tool deletion. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + # try: + # exist_flag, tool_dict = tool_exists(tool_name, context_variables) + # if isinstance(exist_flag, str): + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + exist_flag + # if not exist_flag: + # return f"The tool `{tool_name}` does not exist." + # except Exception as e: + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + str(e) + list_res = list_tools(context_variables) + tool_dict = json.loads(list_res) + try: + tool_path = tool_dict[tool_name]['file_path'] + except KeyError: + return "The tool `" + tool_name + "` does not exist." + except Exception as e: + return "Error: " + str(e) + try: + result = env.run_command(f"rm {tool_path}") + if result['status'] != 0: + return f"Failed to delete tool: `{tool_name}`. Error: " + result['result'] + return f"Successfully deleted tool: `{tool_name}`." + except Exception as e: + return f"Failed to delete tool: `{tool_name}`. Error: " + str(e) + +@register_tool("update_tool") +def update_tool(tool_name: str, tool_code: str, context_variables): + """ + Update an existing plugin tool. + Args: + tool_name: The name of the tool to be updated. + tool_code: The code of the tool to be updated. + Returns: + A string representation of the result of the tool update. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + # try: + # exist_flag, tool_dict = tool_exists(tool_name, context_variables) + # if isinstance(exist_flag, str): + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + exist_flag + # if not exist_flag: + # return f"The tool `{tool_name}` does not exist." + # except Exception as e: + # return "Before updating a tool, you should list all tools first. But the following error occurred: " + str(e) + list_res = list_tools(context_variables) + tool_dict = json.loads(list_res) + try: + tool_path = tool_dict[tool_name]['file_path'] + except KeyError: + return "The tool `" + tool_name + "` does not exist." + except Exception as e: + return "Error: " + str(e) + + try: + msg = create_file(tool_path, tool_code, context_variables) + if msg.startswith("Error creating file:"): + return "Failed to update tool. Error: " + msg + return "Successfully updated tool: " + tool_name + " in " + tool_path + except Exception as e: + return "Failed to update tool. Error: " + str(e) + + +@register_tool("test_tool") +def test_tool(tool_name: str, test_code: str, context_variables): + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "Failed to get the path of the MetaChain. Error: " + str(e) + # try: + # exist_flag, tool_dict = tool_exists(tool_name, context_variables) + # if isinstance(exist_flag, str): + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + exist_flag + # if not exist_flag: + # return f"The tool `{tool_name}` does not exist." + # except Exception as e: + # return "Before testing a tool, you should list all tools first. But the following error occurred: " + str(e) + + test_dir = path + "/test_tools" + + try: + msg = create_directory(test_dir, context_variables) + if msg.startswith("Error creating directory:"): + return "Failed to create the test directory. Error: " + msg + except Exception as e: + return "Failed to create the test directory. Error: " + str(e) + + test_file_path = test_dir + "/" + "test_" + tool_name + ".py" + try: + msg = create_file(test_file_path, test_code, context_variables) + if msg.startswith("Error creating file:"): + return "Failed to create the test file. Error: " + msg + except Exception as e: + return "Failed to create the test file. Error: " + str(e) + + try: + result = run_python(context_variables, test_file_path, cwd=path, env_vars={"DEFAULT_LOG": "False"}) + if "Exit code: 0" not in result: + return "Failed to test the tool. The test case is not correct. The result is: " + result + return f"The result is of the tool `{tool_name}`: \n{result}" + except Exception as e: + return "Failed to test the tool. Error: " + str(e) + +if __name__ == "__main__": + print(list_tools({})) diff --git a/metachain/tools/meta/edit_tools.py b/metachain/tools/meta/edit_tools.py new file mode 100644 index 0000000..4a37a35 --- /dev/null +++ b/metachain/tools/meta/edit_tools.py @@ -0,0 +1,222 @@ +from metachain.registry import registry +from metachain.environment import LocalEnv, DockerEnv +from typing import Union +from metachain.tools.terminal_tools import ( + create_file, + create_directory, + run_python, + print_stream, + terminal_page_up, + terminal_page_down, + terminal_page_to, + process_terminal_response + ) +from metachain.registry import register_tool +import json +def get_metachain_path(env: Union[LocalEnv, DockerEnv]) -> str: + result = env.run_command('pip show metachain') + if result['status'] != 0: + raise Exception("Failed to list tools. Error: " + result['result']) + stdout = result['result'] + for line in stdout.split('\n'): + if line.startswith('Editable project location:'): + path = line.split(':', 1)[1].strip() + return path + raise Exception("Failed to list tools. The MetaChain is not installed in editable mode.") + +def protect_tools(tool_name: str): + if tool_name in registry.tools_info.keys(): + raise Exception(f"The tool `{tool_name}` can NOT be modified. You can DIRECTLY use the `{tool_name}` tool by USING the `run_tool` tool. Or you can create a new tool using this tool by `from metachain.tools import {tool_name}`.") + + +@register_tool("list_tools") +def list_tools(context_variables): + """ + List all plugin tools in the MetaChain. + Returns: + A list of information of all plugin tools including name, args, docstring, body, return_type, file_path. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "Failed to list tools. Error: " + str(e) + python_code = '"from metachain.registry import registry; import json; print(\\"TOOL_LIST_START\\"); print(json.dumps(registry.display_plugin_tools_info, indent=4)); print(\\"TOOL_LIST_END\\")"' + list_tools_cmd = f"cd {path} && DEFAULT_LOG=False python -c {python_code}" + result = env.run_command(list_tools_cmd) + if result['status'] != 0: + return "Failed to list tools. Error: " + result['result'] + try: + output = result['result'] + start_marker = "TOOL_LIST_START" + end_marker = "TOOL_LIST_END" + start_idx = output.find(start_marker) + len(start_marker) + end_idx = output.find(end_marker) + + if start_idx == -1 or end_idx == -1: + return "Failed to parse tool list: markers not found" + + json_str = output[start_idx:end_idx].strip() + return json_str + except Exception as e: + return f"Failed to process output: {str(e)}" + # return result['result'] +def check_tool_name(tool_name: str): + if tool_name == "visual_question_answering": + raise Exception("The tool `visual_question_answering` is not allowed to be modified. Directly use the `visual_question_answering` tool to handlen ANY visual tasks.") +@register_tool("create_tool") +def create_tool(tool_name: str, tool_code: str, context_variables): + """ + Create a plugin tool. + Args: + tool_name: The name of the tool. + tool_code: The code of creating the tool. (You should strictly follow the format of the template given to you to create the tool.) + Returns: + A string representation of the result of the tool creation. + """ + # try: + # check_tool_name(tool_name) + # except Exception as e: + # return str(e) + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + protect_tools(tool_name) + except Exception as e: + return "[ERROR] Failed to protect the tool `" + tool_name + "` with the following error: " + str(e) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to list tools. Error: " + str(e) + + tools_dir = path + "/metachain/tools" + try: + tool_path = tools_dir + "/" + tool_name + ".py" + if "from metachain.registry import register_plugin_tool" not in tool_code: + tool_code = "from metachain.registry import register_plugin_tool\n" + tool_code + msg = create_file(tool_path, tool_code, context_variables) + + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create tool. Error: " + msg + results = env.run_command(f'cd {path} && python {tool_path}') + if results['status'] != 0: + return "[ERROR] Failed to create tool. The python code of the tool is not correct. Error: " + results['result'] + return "[SUCCESS] Successfully created tool: " + tool_name + " in " + tools_dir + "/" + tool_name + ".py" + except Exception as e: + return "[ERROR] Failed to create tool. Error: " + str(e) + +def tool_exists(tool_name: str, context_variables): + try: + list_res = list_tools(context_variables) + tool_dict = json.loads(list_res) + if tool_name not in tool_dict.keys(): + return False, tool_dict + return True, tool_dict + except Exception as e: + return "Before deleting a tool, you should list all tools first. But the following error occurred: " + str(e), None + +@register_tool("delete_tool") +def delete_tool(tool_name: str, context_variables): + """ + Delete a plugin tool. + Args: + tool_name: The name of the tool to be deleted. + Returns: + A string representation of the result of the tool deletion. + """ + # try: + # check_tool_name(tool_name) + # except Exception as e: + # return str(e) + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + # try: + # exist_flag, tool_dict = tool_exists(tool_name, context_variables) + # if isinstance(exist_flag, str): + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + exist_flag + # if not exist_flag: + # return f"The tool `{tool_name}` does not exist." + # except Exception as e: + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + str(e) + try: + protect_tools(tool_name) + except Exception as e: + return "[ERROR] Failed to delete the tool `" + tool_name + "` with the following error: " + str(e) + list_res = list_tools(context_variables) + tool_dict = json.loads(list_res) + try: + tool_path = tool_dict[tool_name]['file_path'] + except KeyError: + return "The tool `" + tool_name + "` does not exist." + except Exception as e: + return "Error: " + str(e) + try: + result = env.run_command(f"rm {tool_path}") + if result['status'] != 0: + return f"[ERROR] Failed to delete tool: `{tool_name}`. Error: " + result['result'] + return f"[SUCCESS] Successfully deleted tool: `{tool_name}`." + except Exception as e: + return f"[ERROR] Failed to delete tool: `{tool_name}`. Error: " + str(e) + + +@register_tool("run_tool") +@process_terminal_response +def run_tool(tool_name: str, run_code: str, context_variables): + """ + Run a tool with the given code. + + Args: + tool_name: The name of the tool to be run. + run_code: The code to be run. + Returns: + A string representation of the result of the tool running. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "Failed to get the path of the MetaChain. Error: " + str(e) + # try: + # exist_flag, tool_dict = tool_exists(tool_name, context_variables) + # if isinstance(exist_flag, str): + # return "Before deleting a tool, you should list all tools first. But the following error occurred: " + exist_flag + # if not exist_flag: + # return f"The tool `{tool_name}` does not exist." + # except Exception as e: + # return "Before testing a tool, you should list all tools first. But the following error occurred: " + str(e) + + test_dir = path + "/test_tools" + + try: + msg = create_directory(test_dir, context_variables) + if msg.startswith("Error creating directory:"): + return "[ERROR] Failed to create the test directory. Error: " + msg + except Exception as e: + return "[ERROR] Failed to create the test directory. Error: " + str(e) + + test_file_path = test_dir + "/" + "test_" + tool_name + ".py" + try: + msg = create_file(test_file_path, run_code, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create the test file. Error: " + msg + except Exception as e: + return "[ERROR] Failed to create the test file. Error: " + str(e) + + try: + # result = run_python(context_variables, test_file_path, cwd=path, env_vars={"DEFAULT_LOG": "False"}) + # if "[SUCCESS]" not in result: + # return "[ERROR] Failed to test the tool. The test case is not correct. The result is: " + result + # return f"The result is of the tool `{tool_name}`: \n{result.replace('[SUCCESS]', '')}" + run_cmd = f"cd {path} && DEFAULT_LOG=False python {test_file_path}" + result = env.run_command(run_cmd, print_stream) + return result + except Exception as e: + return "[ERROR] Failed to test the tool. Error: " + str(e) + +if __name__ == "__main__": + # print(list_tools({})) + # print(create_tool("visual_question_answering", "print('Hello, World!')", {})) + test_code = """ +from metachain.tools import test_file_tools +print(test_file_tools()) +""" + print(run_tool("test_file_tools", test_code, {})) + print(terminal_page_down()) diff --git a/metachain/tools/meta/edit_workflow.py b/metachain/tools/meta/edit_workflow.py new file mode 100644 index 0000000..40c3a7f --- /dev/null +++ b/metachain/tools/meta/edit_workflow.py @@ -0,0 +1,301 @@ +from typing import Union +from metachain.environment import LocalEnv, DockerEnv +from metachain.tools.meta.edit_tools import get_metachain_path +from metachain.tools.meta.edit_agents import list_agents +from metachain.tools.terminal_tools import create_file, create_directory, print_stream, process_terminal_response +from metachain.registry import register_tool +import json +from metachain import MetaChain +from metachain.types import Response +import shlex +from datetime import datetime +from pydantic import BaseModel +CODE_PREFIX = """\ +import asyncio +import json +import argparse +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionMessageToolCall +from metachain.flow import default_drive, EventInput, ReturnBehavior +from metachain.flow.dynamic import goto_events, abort_this +import re +from metachain import MetaChain +from metachain.types import Response +from metachain.registry import register_workflow + +def extract_answer(response: str, key: str): + pattern = f"<{key}>(.*?)" + matches = re.findall(pattern, response, re.DOTALL) + return matches[0] if len(matches) > 0 else None +""" + + +CODE_MAIN = """ +@register_workflow(name = '{workflow_name}') +async def {workflow_name}(system_input: str): + storage_results = dict({input_key} = system_input) + await default_drive.invoke_event( + on_start, + global_ctx=storage_results, + ) + system_output = storage_results.get({output_key}, None) + return system_output +""" + +EVENT_TEMPLATE_PREFIX = """\ +@default_drive.{event_method} +async def {event_name}(event: EventInput, global_ctx): + inputs = {inputs} + input_dict = dict() + for inp in inputs: + input_dict[inp["key"]] = global_ctx.get(inp["key"], None) + + messages = global_ctx.get('messages', []) + task = {task} + outputs = {output_list} + agent = {agent_func_name}({model}) + +""" +EVENT_TEMPLATE_FIX = r""" + input_str = [] + for key, value in input_dict.items(): + input_str.append(f"The {key.replace('_', ' ')} is {value}") + input_str = "\n".join(input_str) + "\n" + query = input_str + '.\nThe task is: ' + task + '.\n' +""" + +# QUERY_TEMPLATE = """\ +# query = input_str + '.\\nThe task is: ' + task + '.\\n' +# """ + +START_EVENT_CODE = """\ +@default_drive.make_event +async def on_start(event: EventInput, global_ctx): + print("start the workflow:" + {workflow_name}) +""" + + + + +IF_ELSE_SUFFIX = \ +""" +You should follow the above instructions, and return the result in the following format: +""" + +EVENT_TEMPLATE_SUFFIX = """\ + messages.append({ + "role": "user", + "content": query + }) + client = MetaChain() + response: Response = await client.run_async(agent = agent, messages = messages, context_variables = global_ctx, debug = True) + result = response.messages[-1]["content"] + messages.extend(response.messages) + global_ctx["messages"] = messages + + for output in outputs: + ans = extract_answer(result, output["key"]) + if ans: + if output["action"]["type"] == "RESULT": + global_ctx[output["key"]] = ans + return ans + elif output["action"]["type"] == "ABORT": + return abort_this() + elif output["action"]["type"] == "GO_TO": + return goto_events([output["action"]["value"]]) + elif len(outputs) == 1: + global_ctx[output["key"]] = result + return result + raise Exception("No valid answer found") +""" + + +def start_event_to_code(workflow_name: str) -> str: + """ + Convert the start event to code. + """ + return START_EVENT_CODE.format(workflow_name = repr(workflow_name)) + +def single_event_to_code(event: dict, agent_info_dict: dict) -> str: + """ + Convert a single event to code. + + A event contains: + - name (str): the name of the event + - input (dict): the input to the event + - task (str): the task to perform + - outputs (list[dict]): the outputs to the event + - listen (list[str]): the listen to the event + - agent (dict): the agent to run + """ + if event["listen"] == None or len(event["listen"]) == 0: + event_method = "make_event" + else: + event_method = "listen_group([{}])".format(", ".join(event["listen"])) + inputs = event["inputs"] + + event_code = EVENT_TEMPLATE_PREFIX.format(event_method = event_method, event_name = event["name"], inputs = inputs, task = repr(event["task"]), output_list = event["outputs"], agent_mode_name = agent_info_dict[event["agent"]["name"]]["mode_name"], agent_func_name = agent_info_dict[event["agent"]["name"]]["func_name"], model = repr(event["agent"]["model"])) + EVENT_TEMPLATE_FIX + + if len(event["outputs"]) > 1: + condition_str = [] + for output in event["outputs"]: + condition_str.append(f"If {output['condition']}, then encapsulate your final answer (answer ONLY) within <{output['key']}> and . ") + query_suffix = "\n".join(condition_str) + query_suffix = f""" + query_suffix = {repr(IF_ELSE_SUFFIX)} + query_suffix += {repr(query_suffix)} + query += query_suffix +""" + event_code += query_suffix + EVENT_TEMPLATE_SUFFIX + else: + event_code += EVENT_TEMPLATE_SUFFIX + + return event_code + +@register_tool("create_workflow") +def create_workflow(workflow_name: str, context_variables: dict) -> str: + workflow_form = context_variables.get("workflow_form", None) + if workflow_form is None: + return "Failed to get workflow form. Please provide a workflow form." + workflow_form = workflow_form.model_dump() if isinstance(workflow_form, BaseModel) else workflow_form + assert workflow_name == workflow_form['name'], "The workflow name must be the same as the name in the workflow form." + system_input = workflow_form['system_input'] + system_output = workflow_form['system_output'] + code_env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(code_env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + workflows_dir = path + "/metachain/workflows" + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + agent_info_dict = {} + workflow_name = workflow_form["name"] + for a in workflow_form["agents"]: + agent_info_dict[a["name"]] = {"name": a["name"], "func_name": agent_dict[a["name"]]["func_name"], "mode_name": a["name"].replace(" ", "_").lower()} + + import_agent_str = "" + for ainfo in agent_info_dict.values(): + import_agent_str += f""" +from metachain.agents import {ainfo['func_name']} +""" + events = workflow_form["events"] + events_code = CODE_PREFIX + import_agent_str + for event in events: + if event["name"] == "on_start": + events_code += start_event_to_code(workflow_name) + else: + events_code += single_event_to_code(event, agent_info_dict) + + + events_code += CODE_MAIN.format(workflow_name = workflow_name, input_key = system_input["key"], output_key = repr(system_output["key"])) + + try: + msg = create_file(workflows_dir + "/" + workflow_name.lower().replace(' ', '_') + "_flow.py", events_code, context_variables) + if msg.startswith("Error creating file:"): + return "[ERROR] Failed to create workflow. Error: " + msg + result = code_env.run_command('cd {} && python metachain/workflows/{}_flow.py'.format(path, workflow_name.lower().replace(' ', '_'))) + if result['status'] != 0: + return "[ERROR] Failed to create workflow. Error: " + result['result'] + return "Successfully created workflow: " + workflow_name + " in " + workflows_dir + "/" + workflow_name.lower().replace(' ', '_') + "_flow.py" + except Exception as e: + return "[ERROR] Failed to create workflow. Error: " + str(e) + +@register_tool("list_workflows") +def list_workflows(context_variables): + """ + List all workflows in the MetaChain. + Returns: + A list of information of all workflows including name, args, docstring, body, return_type, file_path. + """ + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to list workflows. Error: " + str(e) + python_code = '"from metachain.registry import registry; import json; print(\\"WORKFLOW_LIST_START\\"); print(json.dumps(registry.display_workflows_info, indent=4)); print(\\"WORKFLOW_LIST_END\\")"' + list_workflows_cmd = f"cd {path} && DEFAULT_LOG=False python -c {python_code}" + result = env.run_command(list_workflows_cmd) + if result['status'] != 0: + return "[ERROR] Failed to list workflows. Error: " + result['result'] + try: + output = result['result'] + start_marker = "WORKFLOW_LIST_START" + end_marker = "WORKFLOW_LIST_END" + start_idx = output.find(start_marker) + len(start_marker) + end_idx = output.find(end_marker) + + if start_idx == -1 or end_idx == -1: + return "[ERROR] Failed to parse workflow list: markers not found" + + json_str = output[start_idx:end_idx].strip() + return json_str + except Exception as e: + return f"[ERROR] Failed to process output: {str(e)}" + +@register_tool("run_workflow") +@process_terminal_response +def run_workflow(workflow_name: str, system_input: str, context_variables: dict) -> str: + env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(env) + except Exception as e: + return "[ERROR] Failed to get the path of the MetaChain. Error: " + str(e) + try: + workflow_list = list_workflows(context_variables) + if workflow_list.startswith("[ERROR]"): + return "[ERROR] Failed to list workflows. Error: " + workflow_list + workflow_dict = json.loads(workflow_list) + if workflow_name in workflow_dict.keys(): + workflow_info = workflow_dict[workflow_name] + workflow_func = workflow_info['func_name'] + else: + return "[ERROR] The workflow " + workflow_name + " does not exist." + except Exception as e: + return "[ERROR] Before running a agent, you should list all agents first. But the following error occurred: " + str(e) + + try: + # query = shlex.quote(query) + # run_cmd = f'cd {path} && DEFAULT_LOG=False mc agent --model={model} --agent_func={agent_func} --query={query} {ctx_vars_str}' + system_input = shlex.quote(system_input) + # timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + # create_directory(f"{path}/tmp_input", context_variables) + # input_file = f"{path}/tmp_input/input_{timestamp}.txt" + # create_file(input_file, system_input, context_variables) + shell_content = f"""#!/bin/bash +cd {path} +DEFAULT_LOG=False mc workflow --workflow_name={workflow_name} --system_input={system_input} +""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + create_directory(f"{path}/tmp_shell", context_variables) + create_file(f"{path}/tmp_shell/run_workflow_{timestamp}.sh", shell_content, context_variables) + run_cmd = f"cd {path} && chmod +x tmp_shell/run_workflow_{timestamp}.sh && ./tmp_shell/run_workflow_{timestamp}.sh" + result = env.run_command(run_cmd, print_stream) + return result + except Exception as e: + return "[ERROR] Failed to run the workflow. Error: " + str(e) + +if __name__ == "__main__": + from metachain.environment import DockerConfig, DockerEnv, LocalEnv + docker_cfg = DockerConfig( + container_name = "nl2agent_showcase", + workplace_name = "workplace", + communication_port = 12350, + conda_path = "/root/miniconda3", + local_root = "/Users/tangjiabin/Documents/reasoning/metachain/workspace_meta_showcase/showcase_nl2agent_showcase" + ) + code_env = DockerEnv(docker_cfg) + with open("/Users/tangjiabin/Documents/reasoning/metachain/metachain/agents/meta_agent/workflow_form/condition_mining.json", 'r', encoding='utf-8') as f: + workflow_form = json.load(f) + print(workflow_form) + + context_variables = {"workflow_form": workflow_form, "code_env": code_env} + result = create_workflow(workflow_form["name"], context_variables) + print(result) + + result = run_workflow(workflow_form["name"], 'The wheel shown is spun twice, so that the numbers indicated by the pointer are randomly determined (with each number on the wheel being equally likely). The two numbers determined in this way are recorded. The first number is divided by 4, determining one of the remainders 1,2,3 marking the columns of the checkerboard shown. The second number is divided by 5, determining one of the remainders 1,2,3,4 marking the rows of the checkerboard. Finally, a checker is placed on the square where this column and row meet. What is the probability that the checker is placed on a shaded square of the checkerboard? [asy] unitsize(1cm); draw(Circle((0,0),2),linewidth(0.7)); draw((1.7,1)--(-1.7,-1),linewidth(0.7)); draw((1.7,-1)--(-1.7,1),linewidth(0.7)); draw((0,2)--(0,-2)); label("1",(0.8,0.5),NW); label("2",(0.8,-0.5),SW); label("6",(-0.8,0.5),NE); label("9",(-0.8,-0.5),SE); label("3",(-0.7,0),W); label("7",(0.7,0),E); draw((-2.8,0)--(-2.1,0),Arrow); label("Pointer",(-2.8,0),W); fill((3,0)--(3,1)--(4,1)--(4,0)--cycle,gray(0.7)); fill((3,-2)--(3,-1)--(4,-1)--(4,-2)--cycle,gray(0.7)); fill((4,1)--(4,2)--(5,2)--(5,1)--cycle,gray(0.7)); fill((4,-1)--(4,0)--(5,0)--(5,-1)--cycle,gray(0.7)); fill((5,0)--(5,1)--(6,1)--(6,0)--cycle,gray(0.7)); fill((5,-2)--(5,-1)--(6,-1)--(6,-2)--cycle,gray(0.7)); draw((3,-2)--(3,2)--(6,2)--(6,-2)--cycle,linewidth(0.7)); draw((3,-1)--(6,-1),linewidth(0.7)); draw((3,0)--(6,0),linewidth(0.7)); draw((3,1)--(6,1),linewidth(0.7)); draw((4,-2)--(4,2),linewidth(0.7)); draw((5,-2)--(5,2),linewidth(0.7)); label("1",(3.5,-2),S); label("2",(4.5,-2),S); label("3",(5.5,-2),S); label("1",(3,-1.5),W); label("2",(3,-0.5),W); label("3",(3,0.5),W); label("4",(3,1.5),W); [/asy]', context_variables) + print(result) + \ No newline at end of file diff --git a/metachain/tools/meta/edit_workflow.py.backup b/metachain/tools/meta/edit_workflow.py.backup new file mode 100644 index 0000000..46a411a --- /dev/null +++ b/metachain/tools/meta/edit_workflow.py.backup @@ -0,0 +1,207 @@ +from typing import Union +from metachain.environment import LocalEnv, DockerEnv +from metachain.tools.meta.edit_tools import get_metachain_path +from metachain.tools.meta.edit_agents import list_agents +import json +from metachain import MetaChain +from metachain.types import Response +CODE_PREFIX = """\ +import asyncio +import json +import argparse +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionMessageToolCall +from metachain.flow import default_drive, EventInput, ReturnBehavior +from metachain.flow.dynamic import goto_events, abort_this +import re +from metachain import MetaChain +from metachain.types import Response +from metachain.registry import register_workflow + +def extract_answer(response: str, key: str): + pattern = f"<{key}>(.*?)" + matches = re.findall(pattern, response) + return matches[0] if len(matches) > 0 else None +""" + +CODE_SUFFIX = r""" +def get_args(): + parser = argparse.ArgumentParser(description="running_workflow") + parser.add_argument('--system_input', type=str, default=None) + args = parser.parse_args() + return args + +if __name__ == "__main__": + args = get_args() +""" + +CODE_MAIN = """ + storage_results = dict({input_key} = args.system_input) + asyncio.run( + default_drive.invoke_event( + on_start, + global_ctx=storage_results, + ) + ) + system_output = storage_results.get({output_key}, None) + print("The system output is: " + system_output) +""" + +EVENT_TEMPLATE_PREFIX = """\ +@default_drive.{event_method} +async def {event_name}(event: EventInput, global_ctx): + inputs = {inputs} + input_dict = dict() + for inp in inputs: + input_dict[inp["key"]] = global_ctx.get(inp["key"], None) + + messages = global_ctx.get('messages', []) + task = {task} + outputs = {output_list} + agent = {agent_func_name}({model}) + +""" +EVENT_TEMPLATE_FIX = r""" + input_str = [] + for key, value in input_dict.items(): + input_str.append(f"The {key.replace('_', ' ')} is {value}") + input_str = "\n".join(input_str) + "\n" + query = input_str + '.\nThe task is: ' + task + '.\n' +""" + +# QUERY_TEMPLATE = """\ +# query = input_str + '.\\nThe task is: ' + task + '.\\n' +# """ + +START_EVENT_CODE = """\ +@default_drive.make_event +async def on_start(event: EventInput, global_ctx): + print("start the workflow:" + {workflow_name}) +""" + + + + +IF_ELSE_SUFFIX = \ +""" +You should follow the above instructions, and return the result in the following format: +""" + +EVENT_TEMPLATE_SUFFIX = """\ + messages.append({ + "role": "user", + "content": query + }) + client = MetaChain() + response: Response = await client.run_async(agent = agent, messages = messages, context_variables = global_ctx, debug = True) + result = response.messages[-1]["content"] + messages.extend(response.messages) + global_ctx["messages"] = messages + + for output in outputs: + ans = extract_answer(result, output["key"]) + if ans: + if output["action"]["type"] == "RESULT": + global_ctx[output["key"]] = ans + return ans + elif output["action"]["type"] == "ABORT": + return abort_this() + elif output["action"]["type"] == "GO_TO": + return goto_events([output["action"]["value"]]) + elif len(outputs) == 1: + global_ctx[output["key"]] = result + return result + raise Exception("No valid answer found") +""" +def start_event_to_code(workflow_name: str) -> str: + """ + Convert the start event to code. + """ + return START_EVENT_CODE.format(workflow_name = repr(workflow_name)) + +def single_event_to_code(event: dict, agent_info_dict: dict) -> str: + """ + Convert a single event to code. + + A event contains: + - name (str): the name of the event + - input (dict): the input to the event + - task (str): the task to perform + - outputs (list[dict]): the outputs to the event + - listen (list[str]): the listen to the event + - agent (dict): the agent to run + """ + if event["listen"] == None or len(event["listen"]) == 0: + event_method = "make_event" + else: + event_method = "listen_group([{}])".format(", ".join(event["listen"])) + inputs = event["inputs"] + + event_code = EVENT_TEMPLATE_PREFIX.format(event_method = event_method, event_name = event["name"], inputs = inputs, task = repr(event["task"]), output_list = event["outputs"], agent_mode_name = agent_info_dict[event["agent"]["name"]]["mode_name"], agent_func_name = agent_info_dict[event["agent"]["name"]]["func_name"], model = repr(event["agent"]["model"])) + EVENT_TEMPLATE_FIX + + if len(event["outputs"]) > 1: + condition_str = [] + for output in event["outputs"]: + condition_str.append(f"If {output['condition']}, then encapsulate your final answer (answer ONLY) within <{output['key']}> and . ") + query_suffix = "\n".join(condition_str) + query_suffix = f""" + query_suffix = {repr(IF_ELSE_SUFFIX)} + query_suffix += {repr(query_suffix)} + query += query_suffix +""" + event_code += query_suffix + EVENT_TEMPLATE_SUFFIX + else: + event_code += EVENT_TEMPLATE_SUFFIX + + return event_code + + +def json_to_workflow_code(workflow_form: dict, context_variables: dict) -> str: + system_input = workflow_form['system_input'] + system_output = workflow_form['system_output'] + code_env: Union[LocalEnv, DockerEnv] = context_variables.get("code_env", LocalEnv()) + try: + path = get_metachain_path(code_env) + except Exception as e: + return "[ERROR] Failed to list agents. Error: " + str(e) + + agents_dir = path + "/metachain/agents" + agent_list = list_agents(context_variables) + if agent_list.startswith("[ERROR]"): + return "Failed to list agents. Error: " + agent_list + agent_dict = json.loads(agent_list) + agent_info_dict = {} + workflow_name = workflow_form["name"] + for a in workflow_form["agents"]: + agent_info_dict[a["name"]] = {"name": a["name"], "func_name": agent_dict[a["name"]]["func_name"], "mode_name": a["name"].replace(" ", "_").lower()} + + import_agent_str = "" + for ainfo in agent_info_dict.values(): + import_agent_str += f""" +from metachain.agents import {ainfo['func_name']} +""" + events = workflow_form["events"] + events_code = CODE_PREFIX + import_agent_str + for event in events: + if event["name"] == "on_start": + events_code += start_event_to_code(workflow_name) + else: + events_code += single_event_to_code(event, agent_info_dict) + + events_code += CODE_SUFFIX + + events_code += CODE_MAIN.format(input_key = system_input["key"], output_key = repr(system_output["key"])) + + with open("math_majority_voting.py", "w") as f: + f.write(events_code) + +def create_workflow(workflow_form: dict, context_variables: dict) -> str: + pass + +def run_workflow(workflow_name: str, context_variables: dict) -> str: + pass + + +if __name__ == "__main__": + workflow_form = json.load(open("metachain/agents/meta_agent/workflow_form/math_majority_voting.json", "r")) + json_to_workflow_code(workflow_form, {}) \ No newline at end of file diff --git a/metachain/tools/meta/search_tools.py b/metachain/tools/meta/search_tools.py new file mode 100644 index 0000000..9081942 --- /dev/null +++ b/metachain/tools/meta/search_tools.py @@ -0,0 +1,106 @@ +from metachain.registry import register_tool +from huggingface_hub import HfApi, hf_hub_download +from typing import List +import tempfile +import os + +@register_tool("search_trending_models_on_huggingface") +def search_trending_models_on_huggingface(pipeline_tag: str, limit: int = 5) -> str: + """ + Search trending models on Hugging Face. Use this tool when you want to create a tool that uses Hugging Face models, only support the following tags: ['audio-text-to-text', 'text-to-image', 'image-to-image', 'image-to-video', 'text-to-video', 'text-to-speech', 'text-to-audio', 'automatic-speech-recognition', 'audio-to-audio']. + + Args: + pipeline_tag: The pipeline tag you want to search on Hugging Face. ONLY support the following tags: ['audio-text-to-text', 'text-to-image', 'image-to-image', 'image-to-video', 'text-to-video', 'text-to-speech', 'text-to-audio', 'automatic-speech-recognition', 'audio-to-audio']. + limit: The number of models you want to search on Hugging Face. + Returns: + A string representation of the information you found on Hugging Face. + """ + # if pipeline_tag in ['image-text-to-text', 'visual-question-answering', 'video-text-to-text']: + # return f"As for the tags {pipeline_tag}, you should use `visual_question_answering` tool instead!" + if pipeline_tag not in ['audio-text-to-text', 'text-to-image', 'image-to-image', 'image-to-video', 'text-to-video', 'text-to-speech', 'text-to-audio', 'automatic-speech-recognition', 'audio-to-audio']: + return f"Only the following tags are supported: ['audio-text-to-text', 'text-to-image', 'image-to-image', 'image-to-video', 'text-to-video', 'text-to-speech', 'text-to-audio', 'automatic-speech-recognition', 'audio-to-audio']. If you want to use ['image-text-to-text', 'visual-question-answering', 'video-text-to-text'], you should use `visual_question_answering` tool instead!" + api = HfApi() + + # 搜索模型和数据集 + models = api.list_models(pipeline_tag=pipeline_tag, limit=limit) + + # 格式化结果 + result = [] + + # 添加模型信息 + result.append("Finding models on Hugging Face:") + for model in models: + result.append(f"- Model ID: {model.id}") + + # 收集模型信息 + info = [] + if model.card_data: + if model.card_data.language: + info.append(f"Language: {model.card_data.language}") + if model.card_data.license: + info.append(f"License: {model.card_data.license}") + if model.card_data.library_name: + info.append(f"Framework: {model.card_data.library_name}") + if model.card_data.pipeline_tag: + info.append(f"Task: {model.card_data.pipeline_tag}") + + if model.tags: + info.append(f"Tags: {', '.join(model.tags)}") + if model.downloads: + info.append(f"Downloads(30 days): {model.downloads}") + + # 添加收集到的信息 + if info: + result.append(" " + "\n ".join(info)) + + # 尝试获取README内容 + try: + with tempfile.TemporaryDirectory() as tmp_dir: + readme_path = hf_hub_download( + repo_id=model.id, + filename="README.md", + repo_type="model", + local_dir=tmp_dir + ) + with open(readme_path, 'r', encoding='utf-8') as f: + readme_content = f.read() + # 提取前500个字符作为简介 + summary = readme_content[:500].strip() + "..." + result.append(" Summary: " + summary.replace('\n', ' ')) + except Exception as e: + result.append(" Summary: Failed to get") + + result.append("") + + return "\n".join(result) + +@register_tool("get_hf_model_tools_doc") +def get_hf_model_tools_doc(model_id: str) -> str: + """ + Get the detailed information of a model on Hugging Face, such as the detailed usage of the model containing the model's README.md. You should use this tool after you have used `search_trending_models_on_huggingface` to find the model you want to use. + + Args: + model_id: The model id you want to get the detailed information on Hugging Face. + Returns: + A string representation of the detailed information of the model. + """ + result = [] + try: + with tempfile.TemporaryDirectory() as tmp_dir: + readme_path = hf_hub_download( + repo_id=model_id, + filename="README.md", + repo_type="model", + local_dir=tmp_dir + ) + with open(readme_path, 'r', encoding='utf-8') as f: + readme_content = f.read() + summary = readme_content.strip() + result.append("The detailed usage of the model is: " + summary) + except Exception as e: + result.append("Failed to get the detailed usage of the model. Error: " + str(e)) + return "\n".join(result) + +if __name__ == "__main__": + print(search_trending_models_on_huggingface("automatic-speech-recognition", limit=5)) + diff --git a/metachain/tools/meta/tool_retriever.py b/metachain/tools/meta/tool_retriever.py new file mode 100644 index 0000000..ebe18b6 --- /dev/null +++ b/metachain/tools/meta/tool_retriever.py @@ -0,0 +1,38 @@ +from metachain.memory.tool_memory import ToolMemory, ToolReranker +import os +from metachain.io_utils import get_file_md5 +import pandas as pd +from metachain.registry import register_tool +from constant import COMPLETION_MODEL, EMBEDDING_MODEL + +@register_tool("get_api_plugin_tools_doc") +def get_api_plugin_tools_doc(query_text: str) -> str: + """ + Retrieve satisfied tool documents based on the query text. + Args: + query_text: A query or request from users and you need to find the satisfied tool documents based on the query text. + Returns: + A string representation of the reranked results. + """ + platform = 'default' + tool_memory = ToolMemory(project_path = './code_db', db_name = ".tool_table_" + platform, platform=platform, api_key=os.getenv("OPENAI_API_KEY"), embedding_model=EMBEDDING_MODEL) + # tool_reranker = ToolReranker(model="gpt-4o-2024-08-06") + tool_reranker = ToolReranker(model=COMPLETION_MODEL) + tool_path = "./tool_docs.csv" + code_id = get_file_md5(tool_path) + # print(code_id) + tool_memory.collection_name = tool_memory.collection_name + f"_{code_id}" + if tool_memory.count() == 0: + tool_memory.add_dataframe(pd.read_csv(tool_path), batch_size=100) + res_df = tool_memory.query_table(query_text, n_results=5) + # print(res_df) + try: + reranked_result = tool_reranker.dummy_rerank(query_text, res_df) + except Exception as e: + return "Failed to rerank the tool documentation. Error: " + str(e) + + return reranked_result + +if __name__ == "__main__": + os.environ["GEMINI_API_KEY"] = "AIzaSyDblGdaCwhWq0RpXe7aCPFQr0MBg__GN2E" + print(get_api_plugin_tools_doc("Youtube")) \ No newline at end of file diff --git a/metachain/tools/rag_code.py b/metachain/tools/rag_code.py new file mode 100644 index 0000000..fb1727e --- /dev/null +++ b/metachain/tools/rag_code.py @@ -0,0 +1,32 @@ +from metachain.memory.code_memory import CodeMemory, CodeReranker +import os +from metachain.environment import DockerEnv, LocalEnv +from metachain.io_utils import compress_folder, get_file_md5 +from metachain.registry import register_tool +from typing import Union +@register_tool("code_rag") +def code_rag(query_text: str, context_variables) -> str: + """ + Retrieve information from a code directory. Use this function when there is a need to search for information in the codebase. + Args: + query_text: Anything you want to search in the code directory, like a function name, a class name, a variable name, etc. + Returns: + A string representation of the reranked results. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + code_memory = CodeMemory(project_path = './code_db', platform='OpenAI', api_key=os.getenv("OPENAI_API_KEY"),embedding_model='text-embedding-3-small') + code_reranker = CodeReranker(model="gpt-4o-2024-08-06") + code_path = f"{env.local_workplace}/metachain" + compress_folder(code_path, f"{env.local_workplace}/", "metachain.zip") + code_id = get_file_md5(f"{env.local_workplace}/metachain.zip") + code_memory.collection_name = code_memory.collection_name + f"_{code_id}" + + if code_memory.count() == 0: + code_memory.add_code_files(f"{env.local_workplace}/metachain", exclude_prefix=['__pycache__', 'code_db', '.git']) + + query_results = code_memory.query_code(query_text, n_results=20) + reranked_results = code_reranker.rerank(query_text, query_results) + return reranked_results + + + diff --git a/metachain/tools/rag_tools.py b/metachain/tools/rag_tools.py new file mode 100644 index 0000000..e341a82 --- /dev/null +++ b/metachain/tools/rag_tools.py @@ -0,0 +1,276 @@ +from metachain.memory.rag_memory import Memory +import os +from metachain.environment.docker_env import DockerEnv +from metachain.environment.local_env import LocalEnv +from typing import Union +from metachain.environment.local_env import LocalEnv +from metachain.io_utils import compress_folder, get_file_md5 +from metachain.registry import register_tool, register_plugin_tool +from litellm import completion +import zipfile +import shutil +from metachain.environment.markdown_browser.mdconvert import MarkdownConverter +from metachain.memory.utils import chunking_by_token_size +import math +from metachain.types import Result +# @register_tool("load_db") +# def load_db(db_path: str) -> str: + +@register_tool("save_raw_docs_to_vector_db") +@register_plugin_tool("save_raw_docs_to_vector_db") +def save_raw_docs_to_vector_db(context_variables: dict, doc_name: str, saved_vector_db_name: str, overwrite: bool = False) -> Result: + """ + Save the raw documents to the vector database. The documents could be: + - ANY text document with the extension of pdf, docx, txt, etcs. + - A zip file containing multiple text documents + - a directory containing multiple text documents + All documents will be converted to raw text format and saved to the vector database in the chunks of 4096 tokens. + + Args: + doc_name: The name of the raw documents. All documents will be stored in the the directory: /workplace/docs. + [NOTES] doc_name should be the name of the file or directory, not the path to the file or directory, which means `docs/dir_name/` is not a valid doc_name. + saved_vector_db_name: the name of collection you want to save the documents to. + overwrite: Whether to overwrite the existing vector database when the vector database of the documents already exists. (default: False) + """ + try: + memo: Memory = context_variables.get("memo", Memory(project_path=os.path.join(os.getcwd(), "user_db"), db_name = ".user_db")) + assert memo is not None, "memo is not set" + code_env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + assert code_env is not None, "code_env is not set" + + # check if the saved_vector_db_name is already in the vector database + if memo.count(saved_vector_db_name) > 0: + if overwrite: + prefix_res = f"[WARNING] The collection `{saved_vector_db_name}` of the vector database already exists. Overwriting the existing collection." + else: + return f"[WARNING] The collection `{saved_vector_db_name}` of the vector database already exists. Please set the overwrite flag to True if you want to overwrite the existing collection." + else: + prefix_res = "" + + doc_dir = os.path.join(code_env.local_workplace, "docs") + os.makedirs(doc_dir, exist_ok=True) + if doc_name.startswith("docs/"): + doc_name = doc_name.replace("docs/", "") + elif doc_name.startswith("/workspace/docs/"): + doc_name = doc_name.replace("/workspace/docs/", "") + doc_path = os.path.join(doc_dir, doc_name) + assert os.path.exists(doc_path), f"The document `{doc_name}` does not exist in the directory `/workplace/docs`" + # the doc_path is a directory + if os.path.isdir(doc_path): + file_list = [] + for file in os.listdir(doc_path): + if file.endswith(('.pdf', '.docx', '.txt')): + file_list.append(os.path.join(doc_path, file)) + # the doc_path is a zip file + elif os.path.isfile(doc_path) and (doc_path.endswith('.zip') or doc_path.endswith('.tar') or doc_path.endswith('.tar.gz')): + file_name = os.path.splitext(doc_name)[0] + extract_dir = os.path.join(doc_dir, file_name) + os.makedirs(extract_dir, exist_ok=True) + + with zipfile.ZipFile(doc_path, 'r') as zip_ref: + zip_ref.extractall(extract_dir) + + # 将提取的文件路径添加到file_list中 + file_list = [] + for root, _, files in os.walk(extract_dir): + for file in files: + if file.endswith(('.pdf', '.docx', '.txt')): + file_list.append(os.path.join(root, file)) + # the doc_path is a single file + elif os.path.isfile(doc_path): + file_list.append(doc_path) + else: + raise ValueError(f"The document `{doc_name}` is not a valid file or directory") + + mdconvert = MarkdownConverter() + + ret_val = prefix_res + batch_size = 200 + for file in file_list: + queries = [] + doc_content = mdconvert.convert_local(file).text_content + content_chunks = chunking_by_token_size(doc_content, max_token_size=4096) + idx_list = ["chunk_" + str(chunk['chunk_order_index']) for chunk in content_chunks] + for chunk in content_chunks: + query = { + 'query': chunk['content'], + 'response': f"The {chunk['chunk_order_index']} chunk of the content of the file {file} is: \n{chunk['content']}" + } + queries.append(query) + + num_batches = math.ceil(len(queries) / batch_size) + + for i in range(num_batches): + start_idx = i * batch_size + end_idx = min((i + 1) * batch_size, len(queries)) + batch_queries = queries[start_idx:end_idx] + batch_idx = idx_list[start_idx:end_idx] + + # Add the current batch of queries + memo.add_query(batch_queries, collection=saved_vector_db_name, idx=batch_idx) + ret_val += f"The {file} has been added to the vector database `{saved_vector_db_name}`." + context_variables["memo"] = memo + return Result( + value=ret_val, + context_variables=context_variables + ) + except Exception as e: + ret_val = f"[ERROR] Failed to save the raw documents to the vector database: {e}" + return ret_val + +@register_tool("query_db") +@register_plugin_tool("query_db") +def query_db(context_variables: dict, query_text: str, saved_vector_db_name: str, n_results: int = 5) -> str: + """ + Retrieve information from the database. Use this function when you need to search for information in the database. + + Args: + query_text: The query to search for information in the database. + saved_vector_db_name: The name of the vector database to search for information. + n_results: The number of results to return. (default: 5) + Returns: + A string representation of the queried results. + """ + try: + memo: Memory = context_variables.get("memo", Memory(project_path=os.path.join(os.getcwd(), "user_db"), db_name = ".user_db")) + assert memo is not None, "memo is not set" + if memo.count(saved_vector_db_name) == 0: + return f"[ERROR] The vector database `{saved_vector_db_name}` does not exist. Please use function `save_raw_docs_to_vector_db` to save the raw documents to the vector database." + results = memo.query([query_text], collection=saved_vector_db_name, n_results=n_results) + + metadata_results = results['metadatas'][0] + results = [item['response'] for item in metadata_results] + ret_val = "\n".join(results) + except Exception as e: + ret_val = f"[ERROR] Failed to query the vector database: {e}" + finally: + return ret_val + +@register_tool("modify_query") +@register_plugin_tool("modify_query") +def modify_query(what_you_know: str, query_text: str, **kwargs) -> str: + """ + Modify the query based on what you know. Use this function when you need to modify the query to search for more relevant information. + + Args: + what_you_know: The knowledge you have about the case. + query_text: The original query. + Returns: + The modified query. + """ + system_prompt = \ + f""" + + Assume you are an assistant searching for information. Now that you already know some knowledge ([What you know]), what sub-questions ([Modified query]) do you need to search for to help you answer the question ([Query]) you want to explore. + + Modify the query based on what you know, here is some example: + Example 1: + [What you know]: Alice and Bob have lunch together at 12:00 PM. + [Query]: What did Alice and Bob do after the lunch? + [Modified query]: What did Alice and Bob do after 12:00 PM? + + Example 2: + [What you know]: Alice and Bob went to the cinema yesterday. + [Query]: What did Alice and Bob do after the cinema? + [Modified query]: What did Alice and Bob do yesterday? + + Return only 1 modified query. + """ + + user_prompt = f""" + What you know: {what_you_know} + Query: {query_text} + Modified query: + """ + create_params = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "stream": False, + } + response = completion(**create_params) + modified_query = response.choices[0].message.content + return f"The modified query is: {modified_query}. Now use function `query_db` to search the related information in the DataBase." + + + +@register_tool("answer_query") +@register_plugin_tool("answer_query") +def answer_query(original_user_query: str, supporting_docs: str, **kwargs) -> str: + """ + Answer the user query based on the supporting documents. + + Args: + original_user_query: The original user query. + supporting_docs: The supporting documents. + Returns: + The answer to the user query. + """ + system_prompt = \ + f""" + You are a helpful assistant. Answer the user query based on the supporting documents. + If you have not found the answer, say "Insufficient information." + """ + + user_prompt = f""" + + Here is the original user query and the supporting documents: + Original user query: {original_user_query} + Supporting documents: {supporting_docs} + Answer: + """ + create_params = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "stream": False, + } + response = completion(**create_params) + answer = response.choices[0].message.content + + + return answer + + +@register_tool("can_answer") +@register_plugin_tool("can_answer") +def can_answer(user_query: str, supporting_docs: str, **kwargs) -> str: + """ + Check if you have enough information to answer the user query. + + Args: + user_query: The user query. + supporting_docs: The supporting documents. + Returns: + "True" if you have enough information to answer the user query, "False" otherwise. + """ + system_prompt = \ + f""" + You are a helpful assistant. Check if you have enough information to answer the user query. + The answer should only be "True" or "False". + """ + + user_prompt = f""" + + Here is the original user query and the supporting documents: + Original user query: {user_query} + Supporting documents: {supporting_docs} + Answer: + """ + create_params = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "stream": False, + } + response = completion(**create_params) + answer = response.choices[0].message.content + + return answer + diff --git a/metachain/tools/terminal_tools.py b/metachain/tools/terminal_tools.py new file mode 100644 index 0000000..7d7ed27 --- /dev/null +++ b/metachain/tools/terminal_tools.py @@ -0,0 +1,440 @@ + +import os +import socket +import json +import base64 +import math +# from metachain.util import run_command_in_container +from metachain.environment.docker_env import DockerEnv, DockerConfig +from metachain.registry import register_tool +from metachain.environment.markdown_browser.requests_markdown_browser import RequestsMarkdownBrowser +from typing import Tuple, Optional, Dict, Union +import time +import tiktoken +from datetime import datetime +from functools import wraps +from rich.console import Console +from pathlib import Path +from metachain.environment.local_env import LocalEnv +from metachain.environment.docker_env import DockerEnv +import inspect + +terminal_env = RequestsMarkdownBrowser(local_root=os.getcwd(), workplace_name="terminal_env", viewport_size=1024 * 8) + +def _get_browser_state(env: RequestsMarkdownBrowser) -> Tuple[str, str]: + """ + Get the current state of the browser, including the header and content. + """ + # print(env.address) + address = env.address + tool_name = address.split('/')[-1].split('.')[0].split('___')[-1] + header = f"[The output of the tool `{tool_name}` showing in the interactive terminal]\n" + + current_page = env.viewport_current_page + total_pages = len(env.viewport_pages) + + + for i in range(len(env.history) - 2, -1, -1): # Start from the second last + if env.history[i][0] == address: + header += f"You previously visited this page of terminal {round(time.time() - env.history[i][1])} seconds ago.\n" + break + prefix = f"[Your terminal is currently open to the page '{env.page_title}']\n" if env.page_title is not None else "" + + header = prefix + header + header += f"Terminal viewport position: Showing page {current_page+1} of {total_pages}.\n" + if total_pages > 1: + header += f"[NOTE] The output of the tool `{tool_name}`, you can use `terminal_page_up` to scroll up and `terminal_page_down` to scroll down. If there are many pages with meaningless content like progress bar or output of generating directory structure when there are many datasets in the directory, you can use `terminal_page_to` to move the viewport to the end of terminal where the meaningful content is.\n" + return (header, env.viewport) + +def open_local_terminal_output(path: str): + """ + Open a local file at a path in the text-based browser and return current viewport content. + + Args: + path: The absolute path of a local file to visit. + """ + try: + # assert DOCKER_WORKPLACE_NAME in path, f"The path must be a absolute path from `/{DOCKER_WORKPLACE_NAME}/` directory" + # local_path = path.replace('/' + DOCKER_WORKPLACE_NAME, LOCAL_ROOT + f'/{DOCKER_WORKPLACE_NAME}') + # print(local_path) + terminal_env.open_local_file(path) + header, content = _get_browser_state(terminal_env) + final_response = header.strip() + "\n==============================================\n" + content + "\n==============================================\n" + return final_response + except Exception as e: + return f"Error in `open_local_terminal_output`: {e}" + +@register_tool("terminal_page_up") +def terminal_page_up(): + """ + Scroll the viewport UP one page-length in the current terminal. Use this function when the terminal is too long and you want to scroll up to see the previous content. + """ + try: + terminal_env.page_up() + header, content = _get_browser_state(terminal_env) + final_response = header.strip() + "\n==============================================\n" + content + "\n==============================================\n" + return final_response + except Exception as e: + return f"Error in `page_up`: {e}" + +@register_tool("terminal_page_down") +def terminal_page_down(): + """ + Scroll the viewport DOWN one page-length in the current terminal. Use this function when the terminal is too long and you want to scroll down to see the next content. + """ + try: + terminal_env.page_down() + header, content = _get_browser_state(terminal_env) + final_response = header.strip() + "\n==============================================\n" + content + "\n==============================================\n" + return final_response + except Exception as e: + return f"Error in `page_down`: {e}" +@register_tool("terminal_page_to") +def terminal_page_to(page_idx: int): + """ + Move the viewport to the specified page index. The index starts from 1. + Use this function when you want to move the viewport to a specific page, especially when the middle of terminal output are meaningless, like the output of progress bar or output of generating directory structure when there are many datasets in the directory, you can use this function to move the viewport to the end of terminal where the meaningful content is. + """ + try: + terminal_env.page_to(page_idx - 1) + header, content = _get_browser_state(terminal_env) + final_response = header.strip() + "\n==============================================\n" + content + "\n==============================================\n" + return final_response + except Exception as e: + return f"Error in `page_to`: {e}" + +def process_terminal_agent_response(func): + """ + 装饰器函数,用于处理命令执行的响应结果 + - 如果结果是包含 status 和 result 的字典,返回格式化后的结果 + - 如果结果是错误字符串,直接返回 + """ + # original_func = func # 保存原始函数引用 + @wraps(func) # 保持原函数的签名和文档 + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + + # 如果返回值是字典且包含 status 和 result + if isinstance(result, dict) and 'status' in result and 'result' in result: + try: + res_output = result['result'] + if res_output == "": res_output = " " + if result['status'] != 0: + res_output = f"[ERROR] {res_output}" + else: + res_output = f"[SUCCESS] {res_output}" + tmp_file = os.path.join(os.getcwd(), "terminal_tmp", "terminal_output_{}___{}.txt".format(datetime.now().strftime("%Y%m%d_%H%M%S"), func.__name__)) + with open(tmp_file, "w") as f: + f.write(res_output) + return open_local_terminal_output(tmp_file) + except Exception as e: + return f"Error in the post-processing of `{func.__name__}`: {e}" + + elif isinstance(result, str): + return result + else: + return f"Error in `{func.__name__}`: {result}" + # 复制原始函数的签名到包装函数 + # 保持原始函数的属性 + return wrapper + +def process_terminal_response(func): + """ + 装饰器函数,用于处理命令执行的响应结果 + - 如果结果是包含 status 和 result 的字典,返回格式化后的结果 + - 如果结果是错误字符串,直接返回 + """ + # original_func = func # 保存原始函数引用 + @wraps(func) # 保持原函数的签名和文档 + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + + # 如果返回值是字典且包含 status 和 result + if isinstance(result, dict) and 'status' in result and 'result' in result: + try: + res_output = result['result'] + if res_output == "": res_output = " " + if result['status'] != 0: + res_output = f"[ERROR] {res_output}" + else: + res_output = f"[SUCCESS] {res_output}" + tmp_file = os.path.join(os.getcwd(), "terminal_tmp", "terminal_output_{}___{}.txt".format(datetime.now().strftime("%Y%m%d_%H%M%S"), func.__name__)) + with open(tmp_file, "w") as f: + f.write(res_output) + return open_local_terminal_output(tmp_file) + except Exception as e: + return f"Error in the post-processing of `{func.__name__}`: {e}" + + elif isinstance(result, str): + return result + else: + return f"Error in `{func.__name__}`: {result}" + # 复制原始函数的签名到包装函数 + # 保持原始函数的属性 + return wrapper +@register_tool("read_file") +@process_terminal_response +def read_file(file_path: str, context_variables) -> str: + """ + Read the contents of a file and return it as a string. Use this function when there is a need to check an existing file. + Args: + file_path: The path of the file to read. + Returns: + A string representation of the contents of the file. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + command = f"cat {file_path}" + response = env.run_command(command) # status, result + # res_output = truncate_by_tokens(env, response['result'], 10000) + # return f"Exit code: {response['status']} \nOutput: \n{res_output}" + return response + except FileNotFoundError: + return f"[ERROR] Error in reading file: {file_path}" + +def write_file_in_chunks(file_content, output_path, env: DockerEnv, chunk_size=100000): + encoded_content = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') + total_chunks = math.ceil(len(encoded_content) / chunk_size) + + for i in range(total_chunks): + start = i * chunk_size + end = (i + 1) * chunk_size + chunk = encoded_content[start:end] + + # use cat command + if i == 0: + command = f"echo \"{chunk}\" | base64 -d > {output_path}" + else: + command = f"echo \"{chunk}\" | base64 -d >> {output_path}" + + response = env.run_command(command) + + if response["status"] != 0: + return f"Error creating file {output_path}: " + response["result"] + + # print(f"Successfully written block {i+1}/{total_chunks}") + + return f"File created at: {output_path}" + +@register_tool("create_file") +def create_file(path: str, content: str, context_variables) -> str: + """ + Create a file with the given path and content. Use this function when there is a need to create a new file with initial content. + Args: + path: The path to the file to create. + content: The initial content to write to the file. + Returns: + A string representation of the result of the file creation. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + msg = write_file_in_chunks(content, path, env) + return msg + except Exception as e: + return f"Error creating file: {str(e)}" + +@register_tool("write_file") +def write_file(path: str, content: str, context_variables) -> str: + """ + Write content to a file. Use this function when there is a need to write content to an existing file. + Args: + path: The path to the file to write to. + content: The content to write to the file. + Returns: + A string representation of the result of the file writing. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + msg = write_file_in_chunks(content, path, env) + return msg + except Exception as e: + return f"Error writing to file: {str(e)}" + +@register_tool("list_files") +@process_terminal_response +def list_files(path: str, context_variables) -> str: + """ + List all files and directories under the given path if it is a directory. Use this function when there is a need to list the contents of a directory. + Args: + path: The file system path to check and list contents from. + Returns: + A string representation of the contents of the directory. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + if os.path.isfile(path): + return "[ERROR] The given path is a file. Please provide a path of a directory." + command = f"ls -1 {path}" + response = env.run_command(command) + if response["status"] != 0: + return f"[ERROR] Error listing files: {response['result']}" + return response + +@register_tool("create_directory") +def create_directory(path: str, context_variables) -> str: + """ + Create a directory if it does not exist. Use this function when there is a need to create a new directory. + Args: + path: The path of the directory to create. + Returns: + A string representation of the result of the directory creation. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + command = f"mkdir -p {path}" + response = env.run_command(command) + if response["status"] != 0: + return f"Error creating directory: {response['result']}" + return f"Directory '{path}' created successfully." + except OSError as error: + return f"Creation of the directory '{path}' failed due to: {error}" + +@register_tool("gen_code_tree_structure") +@process_terminal_response +def gen_code_tree_structure(directory: str, context_variables) -> str: + """Generate a tree structure of the code in the specified directory. Use this function when you need to know the overview of the codebase and want to generate a tree structure of the codebase. + Args: + directory: The directory to generate the tree structure for. + Returns: + A string representation of the tree structure of the code in the specified directory. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + command = f"tree {directory}" + response = env.run_command(command) + return response + except Exception as e: + return f"[ERROR] Error running tree {directory}: {str(e)}" + +def print_stream(text): + console = Console() + console.print(f"[grey42]{text}[/grey42]") +@register_tool("execute_command") +@process_terminal_response +def execute_command(command: str, context_variables) -> str: + """ + Execute a command in the system shell. Use this function when there is a need to run a system command, and execute programs. + Args: + command: The command to execute in the system shell. + Returns: + A string representation of the exit code and output of the command. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + response = env.run_command(command, print_stream) + return response + except Exception as e: + return f"[ERROR] Error running command: {str(e)}" + +def print_stream(text): + console = Console() + def escape_inner_tags(text): + # 先保护[grey42]标签 + text = text.replace("[grey42]", "###GREY42_START###") + text = text.replace("[/grey42]", "###GREY42_END###") + + # 转义所有其他的[]标签 + text = text.replace("[", "\\[") + text = text.replace("]", "\\]") + + # 恢复[grey42]标签 + text = text.replace("###GREY42_START###", "[grey42]") + text = text.replace("###GREY42_END###", "[/grey42]") + + return text + escaped_text = escape_inner_tags(text) + console.print(f"[grey42]{escaped_text}[/grey42]") +def set_doc(doc_template): + def decorator(func): + func.__doc__ = doc_template + return func + return decorator + +@register_tool("run_python") +@process_terminal_response +def run_python( + context_variables, + code_path: str, + cwd: str = None, + env_vars: Optional[Dict[str, str]] = None, +) -> str: + """ + Run a python script. + Args: + code_path: The absolute or relative path (the relative path is from the root of the workplace `/workplace`) to the python script file. + cwd: The working directory of the python script. If not provided, will regard the directory of the script as the working directory. If there is a command `cd ...` in the instruction for running the script, you should provide the cwd and not use the default value. (Optional) + env_vars: The environment variables to be set before running the python script. (Optional) + Returns: + A string representation of the exit code and output of the python script. + """ + env: Union[DockerEnv, LocalEnv] = context_variables.get("code_env", LocalEnv()) + try: + # 转换为绝对路径 + # abs_path = str(Path(code_path).resolve()) + if Path(code_path).is_absolute(): + if env.run_command(f"ls {code_path}")['status'] != 0: return f"[ERROR] File {code_path} does not exist" + code_abs_path = code_path + else: + code_abs_path = f"{env.docker_workplace}/{code_path}" + if env.run_command(f"ls {code_abs_path}")['status'] != 0: return f'[ERROR] You use a relative path, so we regard the `{env.docker_workplace}` as the root of the workplace, but `{code_abs_path}` does not exist' + + + if cwd: + # 使用指定的项目根目录 + if Path(cwd).is_absolute(): + if env.run_command(f"ls {cwd}")['status'] != 0: return f"[ERROR] Working directory {cwd} does not exist" + else: + cwd = f"{env.docker_workplace}/{cwd}" + if env.run_command(f"ls {cwd}")['status'] != 0: return f"[ERROR] You use a relative path for `cwd`, so we regard the `{env.docker_workplace}` as the working directory, but `{cwd}` does not exist" + else: + cwd = str(Path(code_abs_path).parent) + + + # 设置PYTHONPATH + pythonpath = str(cwd) + + # 获取Python解释器路径 + env_str = f"PYTHONPATH={pythonpath}" + + if env_vars: + env_str += " " + " ".join([f"{k}={v}" for k, v in env_vars.items()]) + # print(env_str) + + # 构建相对模块路径 + try: + rel_path = Path(code_abs_path).relative_to(cwd) + module_path = str(rel_path.with_suffix('')).replace(os.sep, '.') + + command = f"cd {cwd} && {env_str} python -m {module_path}" + except ValueError: + # 如果无法构建相对路径,使用完整路径 + command = f"cd {cwd} && {env_str} python {code_path}" + + # print(f"Executing: {command}") + + result = env.run_command(command, print_stream) + return result + + except Exception as e: + return f"[ERROR] Error when running the python script: {e}" + + +if __name__ == "__main__": + env_config = DockerConfig( + container_name = "paper_eval_dit", + workplace_name = "workplace", + communication_port = 12347, + conda_path = "/home/user/micromamba", + local_root = "/home/tjb/llm/agent/Inno-agent/workplace_paper/task_dit/workplace" + ) + env = DockerEnv(env_config) + + # print(read_file("/workplace/lucidrains_denoising_diffusion/denoising_diffusion_pytorch/denoising_diffusion_pytorch.py", env)) + # print(terminal_page_to(3)) + sig = inspect.signature(execute_command) + print("Parameters from signature:", list(sig.parameters.keys())) + # print(terminal_page_down()) + # print(terminal_page_down()) + # print(terminal_page_down()) + # print(terminal_page_down()) + # print(execute_command("cp project/configs.py ./", env)) + diff --git a/metachain/tools/tool_utils.py b/metachain/tools/tool_utils.py new file mode 100644 index 0000000..5e202f3 --- /dev/null +++ b/metachain/tools/tool_utils.py @@ -0,0 +1,24 @@ +from metachain.environment.docker_env import DockerEnv + +import tiktoken +from datetime import datetime + +def truncate_by_tokens(env: DockerEnv, text, max_tokens = 4096, model="gpt-4o-2024-08-06"): + from metachain.tools.terminal_tools import create_file + encoding = tiktoken.encoding_for_model(model) + tokens = encoding.encode(text) + + if len(tokens) <= max_tokens: + return text + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + output_path = f"{env.docker_workplace}/console_output/truncated_output_{timestamp}.txt" + create_msg = create_file(output_path, content = text, context_variables={'code_env': env}) + # 截断tokens并解码回字符串 + truncated_tokens_bos = tokens[:max_tokens//2] + truncated_tokens_eos = tokens[-(max_tokens - len(truncated_tokens_bos)):] + if create_msg.startswith("Error"): + return encoding.decode(truncated_tokens_bos) + "\n...\n" + encoding.decode(truncated_tokens_eos) + "\n\nThe full console output is too long, so I want to save them into the file: {0}\n\nBut I got an error: {1}".format(output_path, create_msg) + + return encoding.decode(truncated_tokens_bos) + "\n...\n" + encoding.decode(truncated_tokens_eos) + "\n\nThe full console output is too long, so it is saved in the file: {0}\n\nYou may use the `File Surfer Agent` to view the full output.".format(output_path) + diff --git a/metachain/tools/web_tools.py b/metachain/tools/web_tools.py new file mode 100644 index 0000000..4c72f59 --- /dev/null +++ b/metachain/tools/web_tools.py @@ -0,0 +1,457 @@ +from metachain.registry import register_tool +from browsergym.core.action.highlevel import HighLevelActionSet +from typing import Literal +from metachain.environment import DockerEnv, DockerConfig, BrowserEnv, VIEWPORT +from browsergym.utils.obs import flatten_axtree_to_str +from dataclasses import dataclass, field +from typing import Dict +from urllib.parse import quote_plus +from metachain.types import Result +from functools import partial, update_wrapper +from inspect import signature +import tiktoken +from datetime import datetime +from metachain.util import function_to_json +from metachain.tools.md_obs import flatten_md_axtree_to_str +# def with_env(env: BrowserEnv): +# """将env注入到工具函数中的装饰器""" +# def decorator(func): +# # 创建新函数,固定env参数 +# new_func = partial(func, env=env) +# # 保留原始函数的docstring和signature +# update_wrapper(new_func, func) +# # 修改signature,移除env参数 +# new_func.__signature__ = signature(func).replace( +# parameters=[p for p in signature(func).parameters.values() if p.name != 'env'] +# ) +# return new_func +# return decorator +def with_env(env: BrowserEnv): + """将env注入到工具函数中的装饰器""" + def decorator(func): + def wrapped(*args, **kwargs): + return func(env=env, *args, **kwargs) + + # 保留原始函数的所有属性 + update_wrapper(wrapped, func) + # 修改signature,移除env参数 + wrapped.__signature__ = signature(func).replace( + parameters=[p for p in signature(func).parameters.values() if p.name != 'env'] + ) + return wrapped + return decorator + +def with_two_envs(env: BrowserEnv, code_env: DockerEnv): + """将env注入到工具函数中的装饰器""" + def decorator(func): + def wrapped(*args, **kwargs): + return func(env=env, code_env=code_env, *args, **kwargs) + + # 保留原始函数的所有属性 + update_wrapper(wrapped, func) + # 修改signature,移除env参数 + wrapped.__signature__ = signature(func).replace( + parameters=[p for p in signature(func).parameters.values() if p.name not in ['env', 'code_env']] + ) + return wrapped + return decorator +@dataclass +class WebObservation: + content: str # text content of the page + url: str # URL of the page + screenshot: str # base64-encoded screenshot, png + open_pages_urls: list[str] # list of open pages + active_page_index: int # index of the active page + dom_object: dict # DOM object + axtree_object: dict # accessibility tree object + extra_element_properties: dict + focused_element_bid: str # focused element bid + last_browser_action: str # last browser env action performed + last_browser_action_error: str # last browser env action error + error: bool # error flag + +def to_web_obs(obs) -> WebObservation: + obs_dict = dict( + content=obs['text_content'], # text content of the page + url=obs.get('url', ''), # URL of the page + # screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png + screenshot=None, # base64-encoded screenshot, png + open_pages_urls=obs.get('open_pages_urls', []), # list of open pages + active_page_index=obs.get( + 'active_page_index', -1 + ), # index of the active page + dom_object=obs.get('dom_object', {}), # DOM object + axtree_object=obs.get('axtree_object', {}), # accessibility tree object + extra_element_properties=obs.get('extra_element_properties', {}), + focused_element_bid=obs.get( + 'focused_element_bid', None + ), # focused element bid + last_browser_action=obs.get( + 'last_action', '' + ), # last browser env action performed + last_browser_action_error=obs.get('last_action_error', ''), + error=True if obs.get('last_action_error', '') else False, # error flag + ) + return WebObservation(**obs_dict) +def wrap_return_value(web_obs: WebObservation, action_description: str = ""): + error_prefix = "" + if web_obs.error: + error_prefix = get_error_prefix(web_obs.last_browser_action, web_obs.last_browser_action_error) + cur_url = web_obs.url + try: + cur_axtree_txt = flatten_axtree_to_str( + web_obs.axtree_object, + extra_properties=web_obs.extra_element_properties, + with_clickable=True, + filter_visible_only=True, + ) + except Exception as e: + cur_axtree_txt = f'Error encountered when browsing.\nError when trying to process the accessibility tree:{str(e)}' + ret_value = f"""\ +{error_prefix} +{action_description} + +# Current Page URL: +{cur_url} + +# Current Accessibility Tree: +{cur_axtree_txt} + +Here is an example with chain of thought of a valid action when clicking on a button: +" +In order to accomplish my goal I need to click on the button with bid 12 +```click("12")``` +" +""".strip() + return ret_value + +def wrap_return_value_markdown(web_obs: WebObservation, action_description: str = ""): + error_prefix = "" + if web_obs.error: + error_prefix = get_error_prefix(web_obs.last_browser_action, web_obs.last_browser_action_error) + cur_url = web_obs.url + try: + cur_axtree_txt = flatten_md_axtree_to_str( + web_obs.axtree_object, + extra_properties=web_obs.extra_element_properties, + with_clickable=True, + filter_visible_only=True, + ) + except Exception as e: + cur_axtree_txt = f'Error encountered when browsing.\nError when trying to process the accessibility tree:{str(e)}' + ret_value = f"""\ +{error_prefix} +{action_description} + +# The current page is converted to markdown format: +{cur_axtree_txt} + +If the content is too long, you can use `page_down()` and `page_up()` to navigate through the text. +If you have not yet got the answer and want to back to the previous page, please use `history_back()` to navigate back. +""".strip() + return ret_value +def get_error_prefix(last_browser_action: str, last_browser_action_error: str) -> str: + return f'IMPORTANT! Last action is incorrect:\n{last_browser_action}\nThink again with the current observation of the page.\nThe error message is:\n{last_browser_action_error}' + +# @register_tool("click") +# def click(env: BrowserEnv, bid: str, button: Literal["left", "middle", "right"] = "left", modifiers: list[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] = []): +# """ +# Clicks the mouse on the target with the given element bid. +# Args: +# bid: The bid of the element to click. +# button: The button to click. +# modifiers: The modifiers to click. +# """ +# try: +# # 执行动作 +# # action = action_func(*args, **kwargs) +# button_str = f''', button="{button}"''' if button else '' +# modifiers_str = f', modifiers={modifiers}' if modifiers else '' +# action_str = f"""click('{bid}'{button_str}{modifiers_str})""" + +# # 与环境交互 +# obs = env.step(action_str) +# web_obs = to_web_obs(obs) + +# except Exception as e: +# return f"Error encountered when taking action: {action_str}\nError: {e}" +# ret_value = wrap_return_value(web_obs) +# return Result( +# value=ret_value, +# image=web_obs.screenshot, +# ) +@register_tool("click") +def click(context_variables, bid: str, button: Literal["left", "middle", "right"] = "left"): + """ + Clicks the mouse on the target with the given element bid. + Args: + bid: The bid of the element to click. + button: The button to click. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + # 执行动作 + # action = action_func(*args, **kwargs) + button_str = f''', button="{button}"''' if button else '' + action_str = f"""_click_id('{bid}'{button_str})""" + + # 与环境交互 + obs = env.step(action_str) + web_obs = to_web_obs(obs) + + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +@register_tool("page_down") +def page_down(context_variables): + """ + Scrolls the entire browser viewport one page DOWN towards the end. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + action_str = f'scroll(0, {VIEWPORT["height"]-50})' + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + if web_obs.url.startswith("data:text/html;base64,"): + ret_value = wrap_return_value_markdown(web_obs) + else: + ret_value = wrap_return_value(web_obs) + + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +@register_tool("page_up") +def page_up(context_variables): + """ + Scrolls the entire browser viewport one page UP towards the beginning. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + action_str = f'scroll(0, -{VIEWPORT["height"]-50})' + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + if web_obs.url.startswith("data:text/html;base64,"): + ret_value = wrap_return_value_markdown(web_obs) + else: + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +@register_tool("history_back") +def history_back(context_variables): + """ + Navigates back one page in the browser's history. This is equivalent to clicking the browser back button. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + action_str = 'go_back()' + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +@register_tool("history_forward") +def history_forward(context_variables): + """ + Navigates forward one page in the browser's history. This is equivalent to clicking the browser forward button. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + action_str = 'go_forward()' + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +@register_tool("input_text") +def input_text(context_variables, bid: str, text: str): + """ + Types the given text value into the specified field. + Args: + bid: The bid of the element to type into. + text: The text to type into the input field. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + action_str = f"fill('{bid}', '{text}')" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) + +@register_tool("visit_url") +def visit_url(context_variables, url: str): + """ + Navigate directly to a provided URL using the browser's address bar. Prefer this tool over other navigation techniques in cases where the user provides a fully-qualified URL (e.g., choose it over clicking links, or inputing queries into search boxes). + Args: + url: The URL to navigate to. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + if url.startswith(("https://", "http://", "file://", "about:")): + action_str = f"_visit_page('{url}')" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + elif " " in url: + query = quote_plus(url) + action_str = f"_visit_page('https://www.google.com.sg/search?q={query}&hl=en&gl=US')" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + else: + action_str = f"_visit_page('https://{url}')" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) + +@register_tool("web_search") +def web_search(context_variables, query: str): + """ + Performs a web search on 'https://www.google.com.sg/?hl=en&gl=US' with the given query. + Args: + query: The query to search for. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + try: + action_str = f"_visit_page('https://www.google.com.sg/search?q={quote_plus(query)}&hl=en&gl=US')" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +@register_tool("sleep") +def sleep(context_variables): + """ + Wait a short period of time. Call this function if the page has not yet fully loaded, or if it is determined that a small delay would increase the task's chances of success. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + + try: + action_str = f"noop(3000)" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + ret_value = wrap_return_value(web_obs) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) +def truncate_by_tokens(env: DockerEnv, text, max_tokens = 4096, model="gpt-4o-2024-08-06"): + from metachain.tools.terminal_tools import create_file, create_directory + encoding = tiktoken.encoding_for_model(model) + tokens = encoding.encode(text) + + if len(tokens) <= max_tokens: + return text + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + create_directory(f"{env.docker_workplace}/web_page_markdown_output", context_variables={"code_env": env}) + output_path = f"{env.docker_workplace}/web_page_markdown_output/full_output_{timestamp}.md" + create_msg = create_file(output_path, content = text, context_variables={"code_env": env}) + # 截断tokens并解码回字符串 + truncated_tokens_bos = tokens[:max_tokens//2] + truncated_tokens_eos = tokens[-(max_tokens - len(truncated_tokens_bos)):] + if create_msg.startswith("Error"): + return encoding.decode(truncated_tokens_bos) + "\n...\n" + encoding.decode(truncated_tokens_eos) + "\n\nThe full convert markdown output is too long, so I want to save them into the file: {0}\n\nBut I got an error: {1}".format(output_path, create_msg) + + return encoding.decode(truncated_tokens_bos) + "\n...\n" + encoding.decode(truncated_tokens_eos) + "\n\nThe full convert markdown output is too long, so it is saved in the file: {0}\n\nYou may use the `File Surfer Agent` to view the full output.".format(output_path) + +@register_tool("get_page_markdown") +def get_page_markdown(context_variables): + """ + Get the markdown content of the current page. + Use this tool if you need to watch the Youtube video, Wikipedia page, or other pages that contain media content. + Note that this tool can only be used after you have visited a valid page. + """ + env: BrowserEnv = context_variables.get("web_env", None) + assert env is not None, "web_env is not set" + # code_env: DockerEnv = context_variables.get("code_env", None) + # assert code_env is not None, "code_env is not set" + try: + action_str = "_get_page_markdown()" + obs = env.step(action_str) + web_obs = to_web_obs(obs) + # obs = env.step("go_back()") + except Exception as e: + return f"Error encountered when taking action: {action_str}\nError: {e}" + +# ret_value = \ +# f""" +# I have converted the current page into clean markdown format: +# {web_obs.content} +# """.strip() + ret_value = wrap_return_value_markdown(web_obs) + # ret_value = truncate_by_tokens(code_env, ret_value, max_tokens=10000) + return Result( + value=ret_value, + image=web_obs.screenshot, + ) + +if __name__ == "__main__": + env = BrowserEnv(browsergym_eval_env = None, local_root="/Users/tangjiabin/Documents/reasoning/metachain", workplace_name="workplace_gaia_eval") + # code_env = DockerEnv(DockerConfig(container_name = "gaia_lite_eval", + # workplace_name = "workplace_gaia_eval", + # communication_port = 12345, + # conda_path = "/root/miniconda3")) + # code_env.init_container() + # import json + # web_search_with_env = with_env(env)(web_search) + # print(json.dumps(function_to_json(web_search_with_env), indent=4)) + # visit_url(env, "https://scholar.google.com.hk/scholar?hl=zh-CN&as_sdt=0%2C5&q=LLMRec&oq=") + # res = page_down(env) + # print(res.value) + # res = visit_url(env, 'https://arxiv.org/pdf/2310.13023') + # print(res.value) + context_variables = {"web_env": env} + res = visit_url(context_variables, 'https://en.wikipedia.org/wiki/History_of_the_United_States') + # res = visit_url(env, 'https://www.reddit.com/r/ChatGPT/comments/1h5ey4m/chatgpt_helped_me_not_blow_up_on_my_boss/') + print("******visit_url", res.value) + + res = get_page_markdown(context_variables) + print("******get_page_markdown", res.value) + res = page_down(context_variables) + print("******page_down", res.value) + res = history_back(context_variables) + print("******history_back", res.value) + \ No newline at end of file diff --git a/metachain/types.py b/metachain/types.py new file mode 100644 index 0000000..500c6f5 --- /dev/null +++ b/metachain/types.py @@ -0,0 +1,41 @@ +from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message +from typing import List, Callable, Union, Optional, Tuple, Dict + +# Third-party imports +from pydantic import BaseModel + +AgentFunction = Callable[[], Union[str, "Agent", dict]] + + +class Agent(BaseModel): + name: str = "Agent" + model: str = "gpt-4o" + instructions: Union[str, Callable[[], str]] = "You are a helpful agent." + functions: List[AgentFunction] = [] + tool_choice: str = None + parallel_tool_calls: bool = False + examples: Union[List[Tuple[dict, str]], Callable[[], str]] = [] + handle_mm_func: Callable[[], str] = None + agent_teams: Dict[str, Callable] = {} + + +class Response(BaseModel): + messages: List = [] + agent: Optional[Agent] = None + context_variables: dict = {} + + +class Result(BaseModel): + """ + Encapsulates the possible return values for an agent function. + + Attributes: + value (str): The result value as a string. + agent (Agent): The agent instance, if applicable. + context_variables (dict): A dictionary of context variables. + """ + + value: str = "" + agent: Optional[Agent] = None + context_variables: dict = {} + image: Optional[str] = None # base64 encoded image diff --git a/metachain/util.py b/metachain/util.py new file mode 100644 index 0000000..b63e74e --- /dev/null +++ b/metachain/util.py @@ -0,0 +1,526 @@ +import inspect +from datetime import datetime +import socket +import json +import uuid +from typing import Callable, List, Dict, Any, Optional, Callable, Union, get_args, get_origin +from dataclasses import is_dataclass, fields, MISSING +from pydantic import BaseModel +from rich.panel import Panel +from rich.prompt import Prompt +from rich.console import Console +import inquirer +from rich.markdown import Markdown +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.styles import Style +def debug_print_swarm(debug: bool, *args: str) -> None: + if not debug: + return + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + message = " ".join(map(str, args)) + print(f"\033[97m[\033[90m{timestamp}\033[97m]\033[90m {message}\033[0m") +def print_in_box(text: str, console: Optional[Console] = None, title: str = "", color: str = "white") -> None: + """ + Print the text in a box. + :param text: the text to print. + :param console: the console to print the text. + :param title: the title of the box. + :param color: the border color. + :return: + """ + console = console or Console() + + # panel = Panel(text, title=title, border_style=color, expand=True, highlight=True) + # console.print(panel) + console.print('_'*20 + title + '_'*20, style=f"bold {color}") + console.print(text, highlight=True, emoji=True) + + + +def debug_print(debug: bool, *args: str, **kwargs: dict) -> None: + if not debug: + return + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + message = "\n".join(map(str, args)) + color = kwargs.get("color", "white") + title = kwargs.get("title", "") + log_str = f"[{timestamp}]\n{message}" + print_in_box(log_str, color=color, title=title) + log_path = kwargs.get("log_path", None) + if log_path: + with open(log_path, 'a') as f: + f.write(log_str + '\n') + + + +def ask_text(question: str, title: str = "User", console: Optional[Console] = None, default_answer: str = "") -> str: + """ + Display a question in a panel and prompt the user for an answer. + :param question: the question to display. + :param title: the title of the panel. + :param console: the console to use. + :return: the user's answer. + """ + console = console or Console() + + console.print(Panel(question, title=title, border_style="green")) + answer = Prompt.ask(f"Type your answer here, press Enter to use default answer", default=default_answer) + console.print(Panel(answer, title=title)) + return answer + +def print_markdown(md_path: str, console: Optional[Console] = None): + console = console or Console() + with open(md_path, 'r') as f: + md_content = f.read() + console.print(Markdown(md_content)) + +def single_select_menu(options, message: str = ""): + questions = [ + inquirer.List( + 'choice', + message=message, + choices=options, + ), + ] + answers = inquirer.prompt(questions) + return answers['choice'] + + +def get_user_confirmation(prompt: str) -> bool: + user_input = prompt.strip().lower() + if user_input in ['y', 'yes', 'true', 't']: + return True + elif user_input in ['n', 'no', 'false', 'f'] or user_input == '': + return False + else: + print("Invalid input. Please enter 'y' for yes or 'n' for no.") + +def merge_fields(target, source): + for key, value in source.items(): + if isinstance(value, str): + target[key] += value + elif value is not None and isinstance(value, dict): + merge_fields(target[key], value) + + +def merge_chunk(final_response: dict, delta: dict) -> None: + delta.pop("role", None) + merge_fields(final_response, delta) + + tool_calls = delta.get("tool_calls") + if tool_calls and len(tool_calls) > 0: + index = tool_calls[0].pop("index") + merge_fields(final_response["tool_calls"][index], tool_calls[0]) + + +# def function_to_json(func) -> dict: +# """ +# Converts a Python function into a JSON-serializable dictionary +# that describes the function's signature, including its name, +# description, and parameters. + +# Args: +# func: The function to be converted. + +# Returns: +# A dictionary representing the function's signature in JSON format. +# """ +# type_map = { +# str: "string", +# int: "integer", +# float: "number", +# bool: "boolean", +# list: "array", +# dict: "object", +# type(None): "null", +# } + +# try: +# signature = inspect.signature(func) +# except ValueError as e: +# raise ValueError( +# f"Failed to get signature for function {func.__name__}: {str(e)}" +# ) + +# parameters = {} +# for param in signature.parameters.values(): +# try: +# param_type = type_map.get(param.annotation, "string") +# except KeyError as e: +# raise KeyError( +# f"Unknown type annotation {param.annotation} for parameter {param.name}: {str(e)}" +# ) +# parameters[param.name] = {"type": param_type} + +# required = [ +# param.name +# for param in signature.parameters.values() +# if param.default == inspect._empty +# ] + +# return { +# "type": "function", +# "function": { +# "name": func.__name__, +# "description": func.__doc__ or "", +# "parameters": { +# "type": "object", +# "properties": parameters, +# "required": required, +# }, +# }, +# } + +def get_type_info(annotation, base_type_map): + # 处理基本类型 + if annotation in base_type_map: + return {"type": base_type_map[annotation]} + + # 处理typing类型 + origin = get_origin(annotation) + if origin is not None: + args = get_args(annotation) + + # 处理List类型 + if origin is list or origin is List: + item_type = args[0] + return { + "type": "array", + "items": get_type_info(item_type, base_type_map) + } + + # 处理Dict类型 + elif origin is dict or origin is Dict: + key_type, value_type = args + if key_type != str: + raise ValueError("Dictionary keys must be strings") + + # 如果value_type是TypedDict或Pydantic模型 + if (hasattr(value_type, "__annotations__") or + (isinstance(value_type, type) and issubclass(value_type, BaseModel))): + return get_type_info(value_type, base_type_map) + + # 普通Dict类型 + return { + "type": "object", + "additionalProperties": get_type_info(value_type, base_type_map) + } + + # 处理Union类型 + elif origin is Union: + types = [get_type_info(arg, base_type_map) for arg in args if arg != type(None)] + if len(types) == 1: + return types[0] + return {"oneOf": types} + + # 处理Pydantic模型 + if isinstance(annotation, type): + try: + if issubclass(annotation, BaseModel): + schema = annotation.model_json_schema() + # 提取主要schema部分 + properties = schema.get("properties", {}) + required = schema.get("required", []) + + # 处理definitions + definitions = schema.get("$defs", {}) + if definitions: + # 如果有引用的定义,直接展开它们 + for prop_name, prop_schema in properties.items(): + if "$ref" in prop_schema: + ref_name = prop_schema["$ref"].split("/")[-1] + if ref_name in definitions: + properties[prop_name] = definitions[ref_name] + + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False + } + except TypeError: + pass + + # 处理dataclass + if is_dataclass(annotation): + properties = {} + required = [] + for field in fields(annotation): + properties[field.name] = get_type_info(field.type, base_type_map) + if field.default == field.default_factory == MISSING: + required.append(field.name) + + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False + } + + # 处理TypedDict + if hasattr(annotation, "__annotations__"): + properties = {} + required = getattr(annotation, "__required_keys__", annotation.__annotations__.keys()) + + for key, field_type in annotation.__annotations__.items(): + properties[key] = get_type_info(field_type, base_type_map) + + return { + "type": "object", + "properties": properties, + "required": list(required), + "additionalProperties": False + } + + # 默认返回string类型 + return {"type": "string"} + + +def function_to_json(func) -> dict: + """ + Converts a Python function into a JSON-serializable dictionary + that describes the function's signature, including its name, + description, and parameters. + + Args: + func: The function to be converted. + + Returns: + A dictionary representing the function's signature in JSON format. + """ + type_map = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + # list: "array", + # dict: "object", + type(None): "null", + } + # def get_type_info(annotation): + # if hasattr(annotation, "__origin__"): # 处理typing类型 + # origin = annotation.__origin__ + # if origin is list: # 处理List类型 + # item_type = annotation.__args__[0] + # return { + # "type": "array", + # "items": { + # "type": type_map.get(item_type, "string") + # } + # } + # elif origin is dict: # 处理Dict类型 + # return {"type": "object"} + # return {"type": type_map.get(annotation, "string")} + + try: + signature = inspect.signature(func) + except ValueError as e: + raise ValueError( + f"Failed to get signature for function {func.__name__}: {str(e)}" + ) + + parameters = {} + # for param in signature.parameters.values(): + # try: + # param_type = type_map.get(param.annotation, "string") + # except KeyError as e: + # raise KeyError( + # f"Unknown type annotation {param.annotation} for parameter {param.name}: {str(e)}" + # ) + # parameters[param.name] = {"type": param_type} + for param in signature.parameters.values(): + try: + parameters[param.name] = get_type_info(param.annotation, type_map) + except KeyError as e: + raise KeyError(f"Unknown type annotation {param.annotation} for parameter {param.name}: {str(e)}") + + required = [ + param.name + for param in signature.parameters.values() + if param.default == inspect._empty + ] + + return { + "type": "function", + "function": { + "name": func.__name__, + "description": func.__doc__ or "", + "parameters": { + "type": "object", + "properties": parameters, + "required": required, + }, + }, + } + +def run_command_in_container_v1(command, stream_callback: Callable = None): + # TCP parameters + hostname = 'localhost' + port = 12345 # TCP port mapped to the container + buffer_size = 4096 + + # Create TCP client + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((hostname, port)) + s.sendall(command.encode()) + full_response = b"" + while True: + chunk = s.recv(buffer_size) + if not chunk: + break + full_response += chunk + if stream_callback: + stream_callback(chunk) + if len(chunk) < buffer_size: + # If the received data is less than the buffer size, it may have been received + break + + # Decode the complete response + try: + decoded_response = full_response.decode('utf-8') + return json.loads(decoded_response) + except json.JSONDecodeError as e: + print(f"JSON parsing error: {e}") + print(f"Raw response received: {decoded_response}") + return {"status": -1, "result": "Response parsing error"} + +def run_command_in_container(command, stream_callback=None): + """ + communicate with docker container and execute command, support stream output + + Args: + command: the command to execute + stream_callback: optional callback function, for handling stream output + the function signature should be callback(text: str) + + Returns: + dict: the complete JSON result returned by the docker container + """ + hostname = 'localhost' + port = 12345 + buffer_size = 4096 + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((hostname, port)) + s.sendall(command.encode()) + + partial_line = "" + while True: + chunk = s.recv(buffer_size) + if not chunk: + break + + # add new received data to the unfinished data + data = partial_line + chunk.decode('utf-8') + lines = data.split('\n') + + # except the last line, process all complete lines + for line in lines[:-1]: + if line: + try: + response = json.loads(line) + if response['type'] == 'chunk': + # process stream output + if stream_callback: + stream_callback(response['data']) + elif response['type'] == 'final': + # return the final result + return { + 'status': response['status'], + 'result': response['result'] + } + except json.JSONDecodeError: + print(f"Invalid JSON: {line}") + + # save the possibly unfinished last line + partial_line = lines[-1] + + # if the loop ends normally without receiving a final response + return { + 'status': -1, + 'result': 'Connection closed without final response' + } + + +def make_tool_message(tools: Callable, args: dict, tool_content: str) -> List[Dict]: + tool_calls = [ + { + "type": "function", + "function": { + "name": tools.__name__, + "arguments": json.dumps(args) + }, + "id": str(uuid.uuid4()).replace('-', '')[:9] + } + ] + return [ + {'role': 'assistant', 'tool_calls': tool_calls}, + {'role': 'tool', 'content': tool_content, 'name': tools.__name__, 'tool_call_id': tool_calls[0]['id']} + ] +def make_message(role: str, content: str): + return [ + {'role': role, 'content': content} + ] + + + + +class UserCompleter(Completer): + + def __init__(self, users: List[str]): + super().__init__() + self.users = users + def get_completions(self, document, complete_event): + word = document.get_word_before_cursor() + + if word.startswith('@'): + prefix = word[1:] # 去掉@ + for user in self.users: + if user.startswith(prefix): + yield Completion( + user, + start_position=-len(prefix), + style='fg:blue bold' # 蓝色加粗 + ) +def pretty_print_messages(message, **kwargs) -> None: + # for message in messages: + if message["role"] != "assistant" and message["role"] != "tool": + return + console = Console() + if message["role"] == "tool": + console.print("[bold blue]tool execution:[/bold blue]", end=" ") + console.print(f"[bold purple]{message['name']}[/bold purple], result: {message['content']}") + log_path = kwargs.get("log_path", None) + if log_path: + with open(log_path, 'a') as file: + file.write(f"tool execution: {message['name']}, result: {message['content']}\n") + return + + # print agent name in blue + console.print(f"[bold blue]{message['sender']}[/bold blue]:", end=" ") + + # print response, if any + if message["content"]: + console.print(message["content"], highlight=True, emoji=True) + + # print tool calls in purple, if any + tool_calls = message.get("tool_calls") or [] + if len(tool_calls) > 1: + console.print() + for tool_call in tool_calls: + f = tool_call["function"] + name, args = f["name"], f["arguments"] + arg_str = json.dumps(json.loads(args)).replace(":", "=") + console.print(f"[bold purple]{name}[/bold purple]({arg_str[1:-1]})") + log_path = kwargs.get("log_path", None) + if log_path: + with open(log_path, 'a') as file: + file.write(f"{message['sender']}: {message['content']}\n") + for tool_call in tool_calls: + f = tool_call["function"] + name, args = f["name"], f["arguments"] + arg_str = json.dumps(json.loads(args)).replace(":", "=") + file.write(f"{name}({arg_str[1:-1]})\n") + diff --git a/metachain/workflows/__init__.py b/metachain/workflows/__init__.py new file mode 100644 index 0000000..a09f309 --- /dev/null +++ b/metachain/workflows/__init__.py @@ -0,0 +1,39 @@ +import os +import importlib +from metachain.registry import registry + +def import_workflows_recursively(base_dir: str, base_package: str): + """Recursively import all workflows in .py files + + Args: + base_dir: the root directory to start searching + base_package: the base name of the Python package + """ + for root, dirs, files in os.walk(base_dir): + # get the relative path to the base directory + rel_path = os.path.relpath(root, base_dir) + + for file in files: + if file.endswith('.py') and not file.startswith('__'): + # build the module path + if rel_path == '.': + # in the root directory + module_path = f"{base_package}.{file[:-3]}" + else: + # in the subdirectory + package_path = rel_path.replace(os.path.sep, '.') + module_path = f"{base_package}.{package_path}.{file[:-3]}" + + try: + importlib.import_module(module_path) + except Exception as e: + print(f"Warning: Failed to import {module_path}: {e}") + +# get the current directory and import all tools +current_dir = os.path.dirname(__file__) +import_workflows_recursively(current_dir, 'metachain.workflows') + +# export all tool creation functions +globals().update(registry.workflows) + +__all__ = list(registry.workflows.keys()) \ No newline at end of file diff --git a/metachain/workflows/math_solver_workflow_flow.py b/metachain/workflows/math_solver_workflow_flow.py new file mode 100644 index 0000000..2f80d41 --- /dev/null +++ b/metachain/workflows/math_solver_workflow_flow.py @@ -0,0 +1,201 @@ +import asyncio +import json +import argparse +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionMessageToolCall +from metachain.flow import default_drive, EventInput, ReturnBehavior +from metachain.flow.dynamic import goto_events, abort_this +import re +from metachain import MetaChain +from metachain.types import Response +from metachain.registry import register_workflow + +def extract_answer(response: str, key: str): + pattern = f"<{key}>(.*?)" + matches = re.findall(pattern, response) + return matches[0] if len(matches) > 0 else None + +from metachain.agents import get_math_solver_agent + +from metachain.agents import get_vote_aggregator_agent +@default_drive.make_event +async def on_start(event: EventInput, global_ctx): + print("start the workflow:" + 'math_solver_workflow') +@default_drive.listen_group([on_start]) +async def solve_with_gpt4(event: EventInput, global_ctx): + inputs = [{'key': 'math_problem', 'description': 'The math problem that needs to be solved.'}] + input_dict = dict() + for inp in inputs: + input_dict[inp["key"]] = global_ctx.get(inp["key"], None) + + messages = global_ctx.get('messages', []) + task = 'Solve the math problem using systematic approach and show detailed steps.' + outputs = [{'key': 'gpt4_solution', 'description': 'The solution generated by GPT-4 model.', 'condition': None, 'action': {'type': 'RESULT', 'value': None}}] + agent = get_math_solver_agent('gpt-4o-2024-08-06') + + + input_str = [] + for key, value in input_dict.items(): + input_str.append(f"The {key.replace('_', ' ')} is {value}") + input_str = "\n".join(input_str) + "\n" + query = input_str + '.\nThe task is: ' + task + '.\n' + messages.append({ + "role": "user", + "content": query + }) + client = MetaChain() + response: Response = await client.run_async(agent = agent, messages = messages, context_variables = global_ctx, debug = True) + result = response.messages[-1]["content"] + messages.extend(response.messages) + global_ctx["messages"] = messages + + for output in outputs: + ans = extract_answer(result, output["key"]) + if ans: + if output["action"]["type"] == "RESULT": + global_ctx[output["key"]] = ans + return ans + elif output["action"]["type"] == "ABORT": + return abort_this() + elif output["action"]["type"] == "GO_TO": + return goto_events([output["action"]["value"]]) + elif len(outputs) == 1: + global_ctx[output["key"]] = result + return result + raise Exception("No valid answer found") +@default_drive.listen_group([on_start]) +async def solve_with_claude(event: EventInput, global_ctx): + inputs = [{'key': 'math_problem', 'description': 'The math problem that needs to be solved.'}] + input_dict = dict() + for inp in inputs: + input_dict[inp["key"]] = global_ctx.get(inp["key"], None) + + messages = global_ctx.get('messages', []) + task = 'Solve the math problem using systematic approach and show detailed steps.' + outputs = [{'key': 'claude_solution', 'description': 'The solution generated by Claude model.', 'condition': None, 'action': {'type': 'RESULT', 'value': None}}] + agent = get_math_solver_agent('claude-3-5-sonnet-20241022') + + + input_str = [] + for key, value in input_dict.items(): + input_str.append(f"The {key.replace('_', ' ')} is {value}") + input_str = "\n".join(input_str) + "\n" + query = input_str + '.\nThe task is: ' + task + '.\n' + messages.append({ + "role": "user", + "content": query + }) + client = MetaChain() + response: Response = await client.run_async(agent = agent, messages = messages, context_variables = global_ctx, debug = True) + result = response.messages[-1]["content"] + messages.extend(response.messages) + global_ctx["messages"] = messages + + for output in outputs: + ans = extract_answer(result, output["key"]) + if ans: + if output["action"]["type"] == "RESULT": + global_ctx[output["key"]] = ans + return ans + elif output["action"]["type"] == "ABORT": + return abort_this() + elif output["action"]["type"] == "GO_TO": + return goto_events([output["action"]["value"]]) + elif len(outputs) == 1: + global_ctx[output["key"]] = result + return result + raise Exception("No valid answer found") +@default_drive.listen_group([on_start]) +async def solve_with_deepseek(event: EventInput, global_ctx): + inputs = [{'key': 'math_problem', 'description': 'The math problem that needs to be solved.'}] + input_dict = dict() + for inp in inputs: + input_dict[inp["key"]] = global_ctx.get(inp["key"], None) + + messages = global_ctx.get('messages', []) + task = 'Solve the math problem using systematic approach and show detailed steps.' + outputs = [{'key': 'deepseek_solution', 'description': 'The solution generated by Deepseek model.', 'condition': None, 'action': {'type': 'RESULT', 'value': None}}] + agent = get_math_solver_agent('deepseek/deepseek-chat') + + + input_str = [] + for key, value in input_dict.items(): + input_str.append(f"The {key.replace('_', ' ')} is {value}") + input_str = "\n".join(input_str) + "\n" + query = input_str + '.\nThe task is: ' + task + '.\n' + messages.append({ + "role": "user", + "content": query + }) + client = MetaChain() + response: Response = await client.run_async(agent = agent, messages = messages, context_variables = global_ctx, debug = True) + result = response.messages[-1]["content"] + messages.extend(response.messages) + global_ctx["messages"] = messages + + for output in outputs: + ans = extract_answer(result, output["key"]) + if ans: + if output["action"]["type"] == "RESULT": + global_ctx[output["key"]] = ans + return ans + elif output["action"]["type"] == "ABORT": + return abort_this() + elif output["action"]["type"] == "GO_TO": + return goto_events([output["action"]["value"]]) + elif len(outputs) == 1: + global_ctx[output["key"]] = result + return result + raise Exception("No valid answer found") +@default_drive.listen_group([solve_with_gpt4, solve_with_claude, solve_with_deepseek]) +async def aggregate_solutions(event: EventInput, global_ctx): + inputs = [{'key': 'gpt4_solution', 'description': 'The solution generated by GPT-4 model.'}, {'key': 'claude_solution', 'description': 'The solution generated by Claude model.'}, {'key': 'deepseek_solution', 'description': 'The solution generated by Deepseek model.'}] + input_dict = dict() + for inp in inputs: + input_dict[inp["key"]] = global_ctx.get(inp["key"], None) + + messages = global_ctx.get('messages', []) + task = 'Compare all solutions and determine the final answer through majority voting.' + outputs = [{'key': 'final_solution', 'description': 'The final agreed-upon solution after majority voting.', 'condition': None, 'action': {'type': 'RESULT', 'value': None}}] + agent = get_vote_aggregator_agent('gpt-4o-2024-08-06') + + + input_str = [] + for key, value in input_dict.items(): + input_str.append(f"The {key.replace('_', ' ')} is {value}") + input_str = "\n".join(input_str) + "\n" + query = input_str + '.\nThe task is: ' + task + '.\n' + messages.append({ + "role": "user", + "content": query + }) + client = MetaChain() + response: Response = await client.run_async(agent = agent, messages = messages, context_variables = global_ctx, debug = True) + result = response.messages[-1]["content"] + messages.extend(response.messages) + global_ctx["messages"] = messages + + for output in outputs: + ans = extract_answer(result, output["key"]) + if ans: + if output["action"]["type"] == "RESULT": + global_ctx[output["key"]] = ans + return ans + elif output["action"]["type"] == "ABORT": + return abort_this() + elif output["action"]["type"] == "GO_TO": + return goto_events([output["action"]["value"]]) + elif len(outputs) == 1: + global_ctx[output["key"]] = result + return result + raise Exception("No valid answer found") + +@register_workflow(name = 'majority_voting') +async def majority_voting(system_input: str): + storage_results = dict(math_problem = system_input) + await default_drive.invoke_event( + on_start, + global_ctx=storage_results, + ) + system_output = storage_results.get('final_solution', None) + return system_output diff --git a/metachain_meta_agent.py b/metachain_meta_agent.py new file mode 100644 index 0000000..c937e1e --- /dev/null +++ b/metachain_meta_agent.py @@ -0,0 +1,266 @@ +from constant import DOCKER_WORKPLACE_NAME +from metachain.io_utils import read_yaml_file, get_md5_hash_bytext, read_file +from metachain.workflow import Graph, FlowEngine, meta_agent +from metachain.environment.utils import setup_metachain +from metachain.types import Response +from metachain import MetaChain +from metachain.util import ask_text, single_select_menu, print_markdown, debug_print, UserCompleter +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.styles import Style +from rich.progress import Progress, SpinnerColumn, TextColumn +import json +import argparse +from datetime import datetime +from metachain.agents.meta_agent import tool_editor, agent_editor +from metachain.tools.meta.edit_tools import list_tools +from metachain.tools.meta.edit_agents import list_agents +from loop_utils.font_page import MC_LOGO, version_table, NOTES, GOODBYE_LOGO +from rich.live import Live +from metachain.environment.docker_env import DockerEnv, DockerConfig, check_container_ports +from metachain.environment.browser_env import BrowserEnv +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +from evaluation.utils import update_progress, check_port_available, run_evaluation, clean_msg +import os +import os.path as osp +from metachain.agents import get_system_triage_agent +from metachain.logger import LoggerManager, MetaChainLogger +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table +from rich.columns import Columns +from rich.text import Text +from rich.panel import Panel +from metachain.agents.meta_agent.agent_former import get_agent_former_agent +from metachain.agents.meta_agent.tool_editor import get_tool_editor_agent +from metachain.agents.meta_agent.agent_creator import get_agent_creator_agent +import re +from metachain.agents.meta_agent.form_complie import parse_agent_form + +def get_args(): + parser = argparse.ArgumentParser(description="working@tjb-tech") + parser.add_argument('--container_name', type=str, default='gpu_test') + parser.add_argument('--model', type=str, default='gpt-4o-2024-08-06') + parser.add_argument('--test_pull_name', type=str, default='test_pull_1010') + parser.add_argument('--debug', action='store_true', default=False) + parser.add_argument('--port', type=int, default=12350) + parser.add_argument('--git_clone', action='store_true', default=False) + parser.add_argument('--task_cfg', type=str, default='benchmarks/case_study/finance_agent/case_finance_agent_single.yaml') + args = parser.parse_args() + return args +def get_config(args): + container_name = args.container_name + + port_info = check_container_ports(container_name) + port = args.port + if port_info: + port = port_info[0] + else: + # while not check_port_available(port): + # port += 1 + # 使用文件锁来确保端口分配的原子性 + import filelock + lock_file = os.path.join(os.getcwd(), ".port_lock") + lock = filelock.FileLock(lock_file) + + with lock: + port = args.port + while not check_port_available(port): + port += 1 + print(f'{port} is not available, trying {port+1}') + # 立即标记该端口为已使用 + with open(os.path.join(os.getcwd(), f".port_{port}"), 'w') as f: + f.write(container_name) + local_root = os.path.join(os.getcwd(), f"workspace_meta_showcase", f"showcase_{container_name}") + os.makedirs(local_root, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + docker_config = DockerConfig( + workplace_name=DOCKER_WORKPLACE_NAME, + container_name=container_name, + communication_port=port, + conda_path='/root/miniconda3', + local_root=local_root, + git_clone=args.git_clone, + test_pull_name=args.test_pull_name, + task_name = "meta_agent_" + timestamp + ) + return docker_config +def create_environment(docker_config: DockerConfig): + """ + 1. create the code environment + 2. create the web environment + 3. create the file environment + """ + code_env = DockerEnv(docker_config) + code_env.init_container() + + web_env = BrowserEnv(browsergym_eval_env = None, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name) + file_env = RequestsMarkdownBrowser(viewport_size=1024 * 5, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name, downloads_folder=os.path.join(docker_config.local_root, docker_config.workplace_name, "downloads")) + + return code_env, web_env, file_env +def extract_agents_content(text): + pattern = r'(.*?)' + # re.DOTALL 让 . 也能匹配换行符 + match = re.search(pattern, text, re.DOTALL) + if match: + return match.group(1) + return None +def main(args): + print('\033[s\033[?25l', end='') # Save cursor position and hide cursor + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True # 这会让进度条完成后消失 + ) as progress: + task = progress.add_task("[cyan]Initializing...", total=None) + + progress.update(task, description="[cyan]Initializing config...[/cyan]\n") + docker_config = get_config(args) + + progress.update(task, description="[cyan]Setting up logger...[/cyan]\n") + log_path = osp.join("casestudy_results", 'logs', f'agent_{args.container_name}_{args.model}.log') + os.makedirs(osp.dirname(log_path), exist_ok=True) + LoggerManager.set_logger(MetaChainLogger(log_path = log_path)) + + progress.update(task, description="[cyan]Creating environment...[/cyan]\n") + code_env, web_env, file_env = create_environment(docker_config) + + progress.update(task, description="[cyan]Setting up metachain...[/cyan]\n") + setup_metachain(workplace_name=docker_config.workplace_name, env=code_env) + + context_variables = {"working_dir": docker_config.workplace_name, "code_env": code_env, "web_env": web_env, "file_env": file_env} + task_cfg = read_yaml_file(args.task_cfg) + + # generate agent form + client = MetaChain(LoggerManager.get_logger()) + agent_former = get_agent_former_agent(args.model) + messages = [ + {"role": "user", "content": task_cfg["requirements"] + """ +Directly output the form in the XML format without ANY other text. +"""} + ] + response = client.run(agent_former, messages, context_variables) + output_xml_form = response.messages[-1]["content"] + messages.extend(response.messages) + + + MAX_RETRY = 3 + for i in range(MAX_RETRY): + try: + output_xml_form = extract_agents_content(output_xml_form) + assert output_xml_form is not None, "No the XML form should be found in the output with the tag ...." + agent_form = parse_agent_form(output_xml_form) + break + except Exception as e: + print(f"Error parsing XML to agent form: {e}. Retry {i+1}/{MAX_RETRY}") + messages.append({"role": "user", "content": f"Error parsing XML to agent form: {e}\nNote that there are some special restrictions for creating agent form, please try again."}) + response = client.run(agent_former, messages, context_variables) + output_xml_form = response.messages[-1]["content"] + messages.extend(response.messages) + print(output_xml_form) + tool_editor_agent = get_tool_editor_agent(args.model) + def case_resolved(task_response: str, context_variables: dict): + """ + Use this tools when the desired tool is created and tested successfully. You can NOT use this tool if the tool is not created or tested successfully by running the tool. + + Args: + task_response: the response of creating the tool which contains the completion status of the tool. + """ + return f"Case resolved. The desired tool is created and tested successfully. Details: {task_response}" + def case_not_resolved(task_response: str, context_variables: dict): + """ + Use this tools when you encounter irresistible errors after trying your best with multiple attempts for creating the desired tool. You can NOT use this tool before you have tried your best. + + Args: + task_response: the reason why the tool is not created or tested successfully. + """ + return f"Case not resolved. The desired tool is not created or tested successfully. Details: {task_response}" + tool_editor_agent.functions.extend([case_resolved, case_not_resolved]) + + agents = agent_form.agents + for agent in agents: + if len(agent.tools.new) > 0: + new_tools = [] + for idx, tool in enumerate(agent.tools.new): + new_tools.append(f"{idx+1}. Tool name: {tool.name}, Tool description: {tool.description}") + new_tools_str = "\n".join(new_tools) + messages.append({"role": "user", "content": f"""\ +Your task is to create a list of new tools for me, the tools are: +{new_tools_str} + +Please create these new tools for me, note that you can NOT stop util you have created all the tools and tested them using `run_tool` successfully. + +If EVERY tool is created and tested successfully, you can stop and output "Case resolved". Otherwise, you should continue to create the tools. After you have tried your best, you can output "Case not resolved" and give the reason why the tool is not created or tested successfully. + +[IMPORTANT] EVERY tool MUST be tested successfully by running the tool using `run_tool` before you stop. +"""}) + response = client.run(tool_editor_agent, messages, context_variables) + content = response.messages[-1]["content"] + for i in range(MAX_RETRY): + if content.startswith("Case resolved"): + break + messages.append({"role": "user", "content": f"""\ +Your task is to create a list of new tools for me, the tools are: +{new_tools_str} + +Please create these new tools for me, note that you can NOT stop util you have created all the tools and tested them using `run_tool` successfully. +The last attempt failed with the following error: {content}, please try again to create the tools. +"""}) + response = client.run(tool_editor_agent, messages, context_variables) + content = response.messages[-1]["content"] + if i == MAX_RETRY: + return f"The desired tool is not created or tested successfully with {MAX_RETRY} attempts." + + # create agents: + agent_creator_agent = get_agent_creator_agent(args.model) + def case_resolved(task_response: str, context_variables: dict): + """ + Use this tools when the desired agent(s) is created and tested successfully. You can NOT use this tool if the agent(s) is not created or tested successfully by running the agent(s). + """ + return f"Case resolved. The desired agent(s) is created and tested successfully. : {task_response}" + def case_not_resolved(task_response: str, context_variables: dict): + """ + Use this tools when you encounter irresistible errors after trying your best with multiple attempts for creating the desired agent(s). You can NOT use this tool before you have tried your best. + """ + return f"Case not resolved. The desired agent(s) is not created or tested successfully. Details: {task_response}" + agent_creator_agent.functions.extend([case_resolved, case_not_resolved]) + messages.append({"role": "user", "content": f"""\ +The user's request to create agent(s) is: {task_cfg["requirements"]} +Given the completed agent form with XML format: {output_xml_form} +After previous attempts, you have created new tools that required by the desired agent(s). + +Your task is to create the desired agent(s) for me, note that you may create ONE single agent or multiple agents connected by orchestrator agent. + +After you have created the agent(s), you should test the agent(s) by running the agent(s) using `run_agent` tool to complete the user's task: +{task_cfg["task"]} + +Note that you can NOT stop util you have created the agent(s) and tested it successfully. +"""}) + response = client.run(agent_creator_agent, messages, context_variables) + content = response.messages[-1]["content"] + for i in range(MAX_RETRY): + if content.startswith("Case resolved"): + break + messages.append({"role": "user", "content": f"""\ +The user's request to create agent(s) is: {task_cfg["requirements"]} +Given the completed agent form with XML format: {output_xml_form} +After previous attempts, you have created new tools that required by the desired agent(s). + +Your task is to create the desired agent(s) for me, note that you may create ONE single agent or multiple agents connected by orchestrator agent. + +After you have created the agent(s), you should test the agent(s) by running the agent(s) using `run_agent` tool to complete the user's task: +{task_cfg["task"]} + +Note that you can NOT stop util you have created the agent(s) and tested it successfully. +The last attempt failed with the following error: {content}, please try again to create the desired agent(s). +"""}) + response = client.run(agent_creator_agent, messages, context_variables) + content = response.messages[-1]["content"] + if i == MAX_RETRY: + return f"The desired agent(s) is not created or tested successfully with {MAX_RETRY} attempts." + + +if __name__ == "__main__": + args = get_args() + main(args) \ No newline at end of file diff --git a/metachain_meta_agent.sh b/metachain_meta_agent.sh new file mode 100644 index 0000000..809ce92 --- /dev/null +++ b/metachain_meta_agent.sh @@ -0,0 +1,13 @@ +current_dir=$(dirname "$(readlink -f "$0")") + +cd $current_dir +export DOCKER_WORKPLACE_NAME=workplace +export EVAL_MODE=True +export DEBUG=True +export BASE_IMAGES=tjb-gaia-bookworm:v2 +export COMPLETION_MODEL=claude-3-5-sonnet-20241022 +# export COMPLETION_MODEL=gpt-4o-2024-08-06 +export MC_MODE=False + +python metachain_meta_agent.py --container_name nl2agent_showcase --model ${COMPLETION_MODEL} --test_pull_name test_pull_0111 --debug --port 12350 --git_clone +# python /Users/tangjiabin/Documents/reasoning/metachain/test_gaia_tool.py diff --git a/metachain_meta_workflow.py b/metachain_meta_workflow.py new file mode 100644 index 0000000..aecb370 --- /dev/null +++ b/metachain_meta_workflow.py @@ -0,0 +1,231 @@ +from constant import DOCKER_WORKPLACE_NAME +from metachain.io_utils import read_yaml_file, get_md5_hash_bytext, read_file +from metachain.workflow import Graph, FlowEngine, meta_agent +from metachain.environment.utils import setup_metachain +from metachain.types import Response +from metachain import MetaChain +from metachain.util import ask_text, single_select_menu, print_markdown, debug_print, UserCompleter +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.styles import Style +from rich.progress import Progress, SpinnerColumn, TextColumn +import json +import argparse +from datetime import datetime +from metachain.agents.meta_agent import tool_editor, agent_editor +from metachain.tools.meta.edit_tools import list_tools +from metachain.tools.meta.edit_agents import list_agents +from loop_utils.font_page import MC_LOGO, version_table, NOTES, GOODBYE_LOGO +from rich.live import Live +from metachain.environment.docker_env import DockerEnv, DockerConfig, check_container_ports +from metachain.environment.browser_env import BrowserEnv +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +from evaluation.utils import update_progress, check_port_available, run_evaluation, clean_msg +import os +import os.path as osp +from metachain.agents import get_system_triage_agent +from metachain.logger import LoggerManager, MetaChainLogger +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table +from rich.columns import Columns +from rich.text import Text +from rich.panel import Panel +from metachain.agents.meta_agent.workflow_former import get_workflow_former_agent +from metachain.agents.meta_agent.workflow_creator import get_workflow_creator_agent +import re +from metachain.agents.meta_agent.worklow_form_complie import parse_workflow_form, WorkflowForm + +def get_args(): + parser = argparse.ArgumentParser(description="working@tjb-tech") + parser.add_argument('--container_name', type=str, default='gpu_test') + parser.add_argument('--model', type=str, default='gpt-4o-2024-08-06') + parser.add_argument('--test_pull_name', type=str, default='test_pull_1010') + parser.add_argument('--debug', action='store_true', default=False) + parser.add_argument('--port', type=int, default=12350) + parser.add_argument('--git_clone', action='store_true', default=False) + parser.add_argument('--task_cfg', type=str, default='benchmarks/case_study/math_workflow/majority_voting(paralizing).yaml') + args = parser.parse_args() + return args +def get_config(args): + container_name = args.container_name + + port_info = check_container_ports(container_name) + port = args.port + if port_info: + port = port_info[0] + else: + # while not check_port_available(port): + # port += 1 + # 使用文件锁来确保端口分配的原子性 + import filelock + lock_file = os.path.join(os.getcwd(), ".port_lock") + lock = filelock.FileLock(lock_file) + + with lock: + port = args.port + while not check_port_available(port): + port += 1 + print(f'{port} is not available, trying {port+1}') + # 立即标记该端口为已使用 + with open(os.path.join(os.getcwd(), f".port_{port}"), 'w') as f: + f.write(container_name) + local_root = os.path.join(os.getcwd(), f"workspace_meta_showcase", f"showcase_{container_name}") + os.makedirs(local_root, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + docker_config = DockerConfig( + workplace_name=DOCKER_WORKPLACE_NAME, + container_name=container_name, + communication_port=port, + conda_path='/root/miniconda3', + local_root=local_root, + git_clone=args.git_clone, + test_pull_name=args.test_pull_name, + task_name = "meta_agent_" + timestamp + ) + return docker_config +def create_environment(docker_config: DockerConfig): + """ + 1. create the code environment + 2. create the web environment + 3. create the file environment + """ + code_env = DockerEnv(docker_config) + code_env.init_container() + + web_env = BrowserEnv(browsergym_eval_env = None, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name) + file_env = RequestsMarkdownBrowser(viewport_size=1024 * 5, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name, downloads_folder=os.path.join(docker_config.local_root, docker_config.workplace_name, "downloads")) + + return code_env, web_env, file_env +def main(args): + print('\033[s\033[?25l', end='') # Save cursor position and hide cursor + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True # 这会让进度条完成后消失 + ) as progress: + task = progress.add_task("[cyan]Initializing...", total=None) + + progress.update(task, description="[cyan]Initializing config...[/cyan]\n") + docker_config = get_config(args) + + progress.update(task, description="[cyan]Setting up logger...[/cyan]\n") + log_path = osp.join("casestudy_results", 'logs', f'agent_{args.container_name}_{args.model}.log') + LoggerManager.set_logger(MetaChainLogger(log_path = None)) + + progress.update(task, description="[cyan]Creating environment...[/cyan]\n") + code_env, web_env, file_env = create_environment(docker_config) + + progress.update(task, description="[cyan]Setting up metachain...[/cyan]\n") + setup_metachain(workplace_name=docker_config.workplace_name, env=code_env) + + context_variables = {"working_dir": docker_config.workplace_name, "code_env": code_env, "web_env": web_env, "file_env": file_env} + task_cfg = read_yaml_file(args.task_cfg) + + # generate agent form + client = MetaChain() + workflow_former = get_workflow_former_agent(args.model) + messages = [ + {"role": "user", "content": task_cfg["requirements"] + """ +Directly output the form in the XML format without ANY other text. +"""} + ] + response = client.run(workflow_former, messages, context_variables) + output_xml_form = response.messages[-1]["content"] + messages.extend(response.messages) + + MAX_RETRY = 3 + for i in range(MAX_RETRY): + workflow_form = parse_workflow_form(output_xml_form) + if isinstance(workflow_form, WorkflowForm): + break + elif isinstance(workflow_form, str): + print(f"Error parsing XML to workflow form: {workflow_form}. Retry {i+1}/{MAX_RETRY}") + messages.append({"role": "user", "content": f"Error parsing XML to workflow form, the error message is: {workflow_form}\nNote that there are some special restrictions for creating workflow form, please try again."}) + response = client.run(workflow_former, messages, context_variables) + output_xml_form = response.messages[-1]["content"] + messages.extend(response.messages) + else: + raise ValueError(f"Unexpected error: {workflow_form}") + print(output_xml_form) +# context_variables["workflow_form"] = workflow_form +# agents = workflow_form.agents +# new_agents = [] +# for agent in agents: +# if agent.category == "new": +# new_agents.append(agent) +# print(new_agents) + +# if len(new_agents) != 0: +# new_agent_str = "AGENT CREATION INSTRUCTIONS:\nBefore you create the workflow, you need to create the following new agents in the workflow:\n" +# for agent in new_agents: +# new_agent_str += f"Agent name: {agent.name}\nAgent description: {agent.description}\n" +# new_agent_str += f"Agent tools: {agent.tools}\n" if agent.tools else "Agent tools: []\n" +# else: +# new_agent_str = "" + +# workflow_creator_agent = get_workflow_creator_agent(args.model) +# def case_resolved(task_response: str, context_variables: dict): +# """ +# Use this tools when the desired workflow is created and tested successfully. You can NOT use this tool if the workflow is not created or tested successfully by running the workflow. +# """ +# return f"Case resolved. The desired workflow is created and tested successfully. : {task_response}" +# def case_not_resolved(task_response: str, context_variables: dict): +# """ +# Use this tools when you encounter irresistible errors after trying your best with multiple attempts for creating the desired workflow. You can NOT use this tool before you have tried your best. +# """ +# return f"Case not resolved. The desired workflow is not created or tested successfully. Details: {task_response}" +# workflow_creator_agent.functions.extend([case_resolved, case_not_resolved]) +# messages.append({"role": "user", "content": f"""\ +# WORKFLOW CREATION INSTRUCTIONS: +# The user's request to create workflow is: {task_cfg["requirements"]} +# Given the completed workflow form with XML format: {output_xml_form} + +# TASK: +# Your task is to create the workflow for me, and then test the workflow by running the workflow using `run_workflow` tool to complete the user's task: +# {task_cfg["task"]} + +# {new_agent_str} + +# TERMINATION INSTRUCTIONS: +# After you have created the workflow and tested it successfully, you can use the `case_resolved` tool to indicate the case is resolved, otherwise you should try your best to create the workflow. And ONLY after you have tried multiple times, you can use the `case_not_resolved` tool to indicate the case is not resolved and give the reason. + +# Remember: you can NOT stop util you have created the workflow and tested it successfully. +# """}) +# response = client.run(workflow_creator_agent, messages, context_variables) +# content = response.messages[-1]["content"] +# for i in range(MAX_RETRY): +# if content.startswith("Case resolved"): +# break +# messages.append({"role": "user", "content": f"""\ +# WORKFLOW CREATION INSTRUCTIONS: +# The user's request to create workflow is: {task_cfg["requirements"]} +# Given the completed workflow form with XML format: {output_xml_form} + +# TASK: +# Your task is to create the workflow for me, and then test the workflow by running the workflow using `run_workflow` tool to complete the user's task: +# {task_cfg["task"]} + +# {new_agent_str} + +# TERMINATION INSTRUCTIONS: +# After you have created the workflow and tested it successfully, you can use the `case_resolved` tool to indicate the case is resolved, otherwise you should try your best to create the workflow. And ONLY after you have tried multiple times, you can use the `case_not_resolved` tool to indicate the case is not resolved and give the reason. + +# Remember: you can NOT stop util you have created the workflow and tested it successfully. + +# FEEDBACK: +# The last attempt failed with the following error: {content}, please try again to create the desired workflow. +# """}) +# response = client.run(workflow_creator_agent, messages, context_variables) +# content = response.messages[-1]["content"] +# if i == MAX_RETRY: +# return f"The desired workflow is not created or tested successfully with {MAX_RETRY} attempts." + + + + + +if __name__ == "__main__": + args = get_args() + main(args) \ No newline at end of file diff --git a/metachain_meta_workflow.sh b/metachain_meta_workflow.sh new file mode 100644 index 0000000..e8758a6 --- /dev/null +++ b/metachain_meta_workflow.sh @@ -0,0 +1,16 @@ +current_dir=$(dirname "$(readlink -f "$0")") + +cd $current_dir +export DOCKER_WORKPLACE_NAME=workplace +export EVAL_MODE=True +export DEBUG=True +export BASE_IMAGES=tjb-gaia-bookworm:v2 +export COMPLETION_MODEL=claude-3-5-sonnet-20241022 +# export COMPLETION_MODEL=gpt-4o-2024-08-06 +export MC_MODE=False + +task_cfg="benchmarks/case_study/math_workflow/majority_voting(paralizing).yaml" +# task_cfg="benchmarks/case_study/math_workflow/condition_mining(evaluator-optimizer).yaml" + +python metachain_meta_workflow.py --container_name nl2agent_showcase --model ${COMPLETION_MODEL} --test_pull_name test_pull_0111 --debug --port 12350 --git_clone --task_cfg ${task_cfg} +# python /Users/tangjiabin/Documents/reasoning/metachain/test_gaia_tool.py \ No newline at end of file diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..e69de29 diff --git a/playground/cli/metachain_cli.py b/playground/cli/metachain_cli.py new file mode 100644 index 0000000..d3434b3 --- /dev/null +++ b/playground/cli/metachain_cli.py @@ -0,0 +1,300 @@ +from constant import DOCKER_WORKPLACE_NAME +from metachain.io_utils import read_yaml_file, get_md5_hash_bytext, read_file +from metachain.environment.utils import setup_metachain +from metachain.types import Response +from metachain import MetaChain +from metachain.util import ask_text, single_select_menu, print_markdown, debug_print, UserCompleter +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.styles import Style +from rich.progress import Progress, SpinnerColumn, TextColumn +import json +import argparse +from datetime import datetime +from metachain.agents.meta_agent import tool_editor, agent_editor +from metachain.tools.meta.edit_tools import list_tools +from metachain.tools.meta.edit_agents import list_agents +from loop_utils.font_page import MC_LOGO, version_table, NOTES, GOODBYE_LOGO +from rich.live import Live +from metachain.environment.docker_env import DockerEnv, DockerConfig, check_container_ports +from metachain.environment.browser_env import BrowserEnv +from metachain.environment.markdown_browser import RequestsMarkdownBrowser +from evaluation.utils import update_progress, check_port_available, run_evaluation, clean_msg +import os +import os.path as osp +from metachain.agents import get_system_triage_agent +from metachain.logger import LoggerManager, MetaChainLogger +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table +from rich.columns import Columns +from rich.text import Text +from rich.panel import Panel +import re + +def get_args(): + parser = argparse.ArgumentParser(description="working@tjb-tech") + parser.add_argument('--container_name', type=str, default='gpu_test') + parser.add_argument('--model', type=str, default='gpt-4o-2024-08-06') + parser.add_argument('--test_pull_name', type=str, default='test_pull_1010') + parser.add_argument('--debug', action='store_true', default=False) + parser.add_argument('--port', type=int, default=12350) + parser.add_argument('--git_clone', action='store_true', default=False) + args = parser.parse_args() + return args +def clear_screen(): + console = Console() + console.print("[bold green]Coming soon...[/bold green]") + print('\033[u\033[J\033[?25h', end='') # Restore cursor and clear everything after it, show cursor +def get_config(args): + container_name = args.container_name + + port_info = check_container_ports(container_name) + port = args.port + if port_info: + port = port_info[0] + else: + # while not check_port_available(port): + # port += 1 + # 使用文件锁来确保端口分配的原子性 + import filelock + lock_file = os.path.join(os.getcwd(), ".port_lock") + lock = filelock.FileLock(lock_file) + + with lock: + port = args.port + while not check_port_available(port): + port += 1 + print(f'{port} is not available, trying {port+1}') + # 立即标记该端口为已使用 + with open(os.path.join(os.getcwd(), f".port_{port}"), 'w') as f: + f.write(container_name) + local_root = os.path.join(os.getcwd(), f"workspace_meta_showcase", f"showcase_{container_name}") + os.makedirs(local_root, exist_ok=True) + docker_config = DockerConfig( + workplace_name=DOCKER_WORKPLACE_NAME, + container_name=container_name, + communication_port=port, + conda_path='/root/miniconda3', + local_root=local_root, + git_clone=args.git_clone, + test_pull_name=args.test_pull_name, + ) + return docker_config +def create_environment(docker_config: DockerConfig): + """ + 1. create the code environment + 2. create the web environment + 3. create the file environment + """ + code_env = DockerEnv(docker_config) + code_env.init_container() + + web_env = BrowserEnv(browsergym_eval_env = None, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name) + file_env = RequestsMarkdownBrowser(viewport_size=1024 * 5, local_root=docker_config.local_root, workplace_name=docker_config.workplace_name, downloads_folder=os.path.join(docker_config.local_root, docker_config.workplace_name, "downloads")) + + return code_env, web_env, file_env + +def user_mode(model: str, context_variables: dict, debug: bool = True): + logger = LoggerManager.get_logger() + console = Console() + system_triage_agent = get_system_triage_agent(model) + assert system_triage_agent.agent_teams != {}, "System Triage Agent must have agent teams" + messages = [] + agent = system_triage_agent + agents = {system_triage_agent.name.replace(' ', '_'): system_triage_agent} + for agent_name in system_triage_agent.agent_teams.keys(): + agents[agent_name.replace(' ', '_')] = system_triage_agent.agent_teams[agent_name]("placeholder").agent + style = Style.from_dict({ + 'bottom-toolbar': 'bg:#333333 #ffffff', + }) + + # 创建会话 + session = PromptSession( + completer=UserCompleter(agents.keys()), + complete_while_typing=True, + style=style + ) + client = MetaChain(log_path=logger) + while True: + # query = ask_text("Tell me what you want to do:") + query = session.prompt( + 'Tell me what you want to do (type "exit" to quit): ', + bottom_toolbar=HTML('Prompt: Enter @ to mention Agents') + ) + if query.strip().lower() == 'exit': + # logger.info('User mode completed. See you next time! :waving_hand:', color='green', title='EXIT') + + logo_text = "User mode completed. See you next time! :waving_hand:" + console.print(Panel(logo_text, style="bold salmon1", expand=True)) + break + words = query.split() + console.print(f"[bold green]Your request: {query}[/bold green]", end=" ") + for word in words: + if word.startswith('@') and word[1:] in agents.keys(): + # print(f"[bold magenta]{word}[bold magenta]", end=' ') + agent = agents[word.replace('@', '')] + else: + # print(word, end=' ') + pass + print() + agent_name = agent.name + console.print(f"[bold green][bold magenta]@{agent_name}[/bold magenta] will help you, be patient...[/bold green]") + messages.append({"role": "user", "content": query}) + response = client.run(agent, messages, context_variables, debug=debug) + messages.extend(response.messages) + model_answer_raw = response.messages[-1]['content'] + + # attempt to parse model_answer + if model_answer_raw.startswith('Case resolved'): + model_answer = re.findall(r'(.*?)', model_answer_raw) + if len(model_answer) == 0: + model_answer = model_answer_raw + else: + model_answer = model_answer[0] + else: + model_answer = model_answer_raw + console.print(f"[bold green][bold magenta]@{agent_name}[/bold magenta] has finished with the response:[/bold green] [bold blue]{model_answer}[/bold blue]") + agent = response.agent + pass +def agent_chain(model: str, context_variables: dict, debug: bool = True): + from metachain.agents import get_plan_agent + from metachain.agents.programming_triage_agent import get_programming_triage_agent, get_agent_run_agent, get_tool_creation_agent, get_agent_creation_agent + programming_triage_agent = get_programming_triage_agent(model) + agent_run_agent = get_agent_run_agent(model) + tool_creation_agent = get_tool_creation_agent(model) + agent_creation_agent = get_agent_creation_agent(model) + def transfer_to_programming_triage_agent(): + return programming_triage_agent + plan_agent = get_plan_agent(model) + plan_agent.functions.append(transfer_to_programming_triage_agent) + + messages = [] + agent = plan_agent + agents = {plan_agent.name.replace(' ', '_'): plan_agent, programming_triage_agent.name.replace(' ', '_'): programming_triage_agent, agent_run_agent.name.replace(' ', '_'): agent_run_agent, tool_creation_agent.name.replace(' ', '_'): tool_creation_agent, agent_creation_agent.name.replace(' ', '_'): agent_creation_agent} + # REPL loop + style = Style.from_dict({ + 'bottom-toolbar': 'bg:#333333 #ffffff', + }) + + # 创建会话 + session = PromptSession( + completer=UserCompleter(agents.keys()), + complete_while_typing=True, + style=style + ) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + mc = MetaChain(timestamp) + while True: + # query = ask_text("Tell me what you want to do:") + query = session.prompt( + 'Tell me what you want to do (type "exit" to quit): ', + bottom_toolbar=HTML('Prompt: Enter @ to mention Agents') + ) + if query.strip().lower() == 'exit': + debug_print(debug, 'Agent completed. See you next time! :waving_hand:', color='green') + break + words = query.split() + for word in words: + if word.startswith('@') and word[1:] in agents.keys(): + print(f"[bold magenta]{word}[bold magenta]", end=' ') + agent = agents[word.replace('@', '')] + else: + print(word, end=' ') + messages.append({"role": "user", "content": query}) + response = mc.run(agent, messages, context_variables, debug=debug) + messages.extend(response.messages) + agent = response.agent + +def tool_to_table(tool_dict: dict): + table = Table(title="Tool List", show_lines=True) + table.add_column("Tool Name") + table.add_column("Description") + for tool_name in tool_dict.keys(): + if tool_name == "tool_dummy": + continue + table.add_row(tool_name, tool_dict[tool_name]["docstring"]) + return table +def agent_to_table(agent_dict: dict): + table = Table(title="Agent List", show_lines=True) + table.add_column("Agent Name") + table.add_column("Description") + for agent_name in agent_dict.keys(): + if agent_name == "get_dummy_agent": + continue + table.add_row(agent_name, agent_dict[agent_name]["docstring"]) + return table + +def update_guidance(context_variables): + tool_dict = json.loads(list_tools(context_variables)) + # print(tool_dict) + tool_table = tool_to_table(tool_dict) + agent_dict = json.loads(list_agents(context_variables)) + agent_table = agent_to_table(agent_dict) + console = Console() + columns = Columns([tool_table, agent_table]) + + # print the logo + logo_text = Text(MC_LOGO, justify="center") + console.print(Panel(logo_text, style="bold salmon1", expand=True)) + console.print(version_table) + console.print(Panel(NOTES,title="Important Notes", expand=True)) + +def workflow_chain(model: str, debug: bool = True): + pass +def main(args): + print('\033[s\033[?25l', end='') # Save cursor position and hide cursor + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True # 这会让进度条完成后消失 + ) as progress: + task = progress.add_task("[cyan]Initializing...", total=None) + + progress.update(task, description="[cyan]Initializing config...[/cyan]\n") + docker_config = get_config(args) + + progress.update(task, description="[cyan]Setting up logger...[/cyan]\n") + log_path = osp.join("casestudy_results", 'logs', f'agent_{args.container_name}_{args.model}.log') + LoggerManager.set_logger(MetaChainLogger(log_path = None)) + + progress.update(task, description="[cyan]Creating environment...[/cyan]\n") + code_env, web_env, file_env = create_environment(docker_config) + + progress.update(task, description="[cyan]Setting up metachain...[/cyan]\n") + setup_metachain(workplace_name=docker_config.workplace_name, env=code_env) + + clear_screen() + # console = Console() + # console.clear() + # print('\033[H\033[J') # ANSI 转义序列清屏 + # print('\033[3J\033[H\033[2J') + # clear_screen() + + context_variables = {"working_dir": docker_config.workplace_name, "code_env": code_env, "web_env": web_env, "file_env": file_env} + + # select the mode + while True: + update_guidance(context_variables) + mode = single_select_menu(['user mode', 'agent editor', 'workflow editor', 'exit'], "Please select the mode:") + match mode: + case 'user mode': + clear_screen() + user_mode(args.model, context_variables, args.debug) + case 'agent editor': + clear_screen() + agent_chain(args.model, context_variables, args.debug) + case 'workflow editor': + clear_screen() + workflow_chain(args.model, context_variables, args.debug) + case 'exit': + console = Console() + logo_text = Text(GOODBYE_LOGO, justify="center") + console.print(Panel(logo_text, style="bold salmon1", expand=True)) + break + + +if __name__ == "__main__": + args = get_args() + main(args) \ No newline at end of file diff --git a/playground/cli/metachain_cli.sh b/playground/cli/metachain_cli.sh new file mode 100644 index 0000000..080e379 --- /dev/null +++ b/playground/cli/metachain_cli.sh @@ -0,0 +1,14 @@ +current_dir=$(dirname "$(readlink -f "$0")") + +cd $current_dir +export DOCKER_WORKPLACE_NAME=workplace +export EVAL_MODE=True +export DEBUG=True +export BASE_IMAGES=tjbtech1/gaia-bookworm:v2 +export COMPLETION_MODEL=claude-3-5-sonnet-20241022 +# export COMPLETION_MODEL=gpt-4o-2024-08-06 +export DEBUG=False +export MC_MODE=True + +python metachain_loop.py --container_name quick_start --model ${COMPLETION_MODEL} --test_pull_name test_pull_1225 --debug --port 12350 --git_clone +# python /Users/tangjiabin/Documents/reasoning/metachain/test_gaia_tool.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7fd26b9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1354610 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,78 @@ +[metadata] +name = metachain +version = 0.1.0 +author = jiabintang +description = dynamic agentic framework. +long_description = file: README.md +long_description_content_type = text/markdown +license = MIT + +[options] +package_dir = + = . +packages = + metachain +zip_safe = True +include_package_data = True +install_requires = + numpy + openai>=1.52.0 + pytest + requests + tqdm + pre-commit + instructor + litellm==1.55.0 + beautifulsoup4 + browsergym==0.13.0 + chromadb + click + datasets + docling + filelock + Flask + gymnasium + html2text + httpx + huggingface_hub + inquirer + loguru + mammoth + markdownify + matplotlib + networkx + pandas + pathvalidate==3.2.1 + pdfminer + pdfminer.six + Pillow + playwright==1.39.0 + prompt_toolkit + psutil + puremagic + pydantic + pydub + python_pptx + PyYAML + rich + SpeechRecognition + tenacity + termcolor + tiktoken + tree_sitter==0.23.1 + uvicorn + youtube_transcript_api + moviepy + faster_whisper + +python_requires = >=3.10 + +[options.entry_points] +console_scripts = + mc = metachain.cli:cli +[tool.autopep8] +max_line_length = 120 +ignore = E501,W6 +in-place = true +recursive = true +aggressive = 3 \ No newline at end of file